Add 8-channel mixer with master compressor/limiter, MIDI learn system, separate deck outputs
- Mixer: 8 renamable stereo input channels with VU meter, volume fader, mute/solo - Master bus: compressor (threshold, ratio, attack, release, makeup) + limiter (ceiling), LUFS metering - Decks/carts keep their own JACK outputs, completely separate from mixer - MIDI learn: configurable bindings saved to ~/.gti_radiostudio/midi_map.json - mixer_widget.py: QDial-based DSP controls, scrollable channel strips
This commit is contained in:
+178
-36
@@ -13,6 +13,12 @@ LUFS_TARGET = -14.0
|
||||
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
|
||||
FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
|
||||
|
||||
NUM_MIXER_CHANNELS = 8
|
||||
DEFAULT_CHANNEL_NAMES = [
|
||||
"Turntable 1", "Turntable 2", "Mic", "Aux 1",
|
||||
"Aux 2", "Aux 3", "Aux 4", "Aux 5",
|
||||
]
|
||||
|
||||
|
||||
def load_audio(filepath: str, target_sr: int) -> np.ndarray:
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
@@ -137,12 +143,99 @@ class DeckState:
|
||||
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
|
||||
|
||||
|
||||
class MixerChannel:
|
||||
def __init__(self, name: str, index: int):
|
||||
self.name = name
|
||||
self.index = index
|
||||
self.volume = 1.0
|
||||
self.muted = False
|
||||
self.solo = False
|
||||
self.vu_peak = 0.0
|
||||
self.in_port_L = None
|
||||
self.in_port_R = None
|
||||
self.out_port_L = None
|
||||
self.out_port_R = None
|
||||
|
||||
def reset_vu(self):
|
||||
self.vu_peak = 0.0
|
||||
|
||||
|
||||
class Compressor:
|
||||
def __init__(self, sr: int):
|
||||
self.sr = sr
|
||||
self.threshold_db = -20.0
|
||||
self.ratio = 4.0
|
||||
self.attack_ms = 5.0
|
||||
self.release_ms = 100.0
|
||||
self.makeup_gain_db = 0.0
|
||||
self.bypassed = False
|
||||
|
||||
self._envelope = 0.0
|
||||
self._attack_coeff = self._time_constant(self.attack_ms)
|
||||
self._release_coeff = self._time_constant(self.release_ms)
|
||||
|
||||
def _time_constant(self, ms: float) -> float:
|
||||
return np.exp(-1.0 / (ms * self.sr / 1000.0))
|
||||
|
||||
def set_attack(self, ms: float):
|
||||
self.attack_ms = ms
|
||||
self._attack_coeff = self._time_constant(ms)
|
||||
|
||||
def set_release(self, ms: float):
|
||||
self.release_ms = ms
|
||||
self._release_coeff = self._time_constant(ms)
|
||||
|
||||
def process(self, audio: np.ndarray):
|
||||
if self.bypassed:
|
||||
return
|
||||
threshold_linear = 10 ** (self.threshold_db / 20.0)
|
||||
makeup_linear = 10 ** (self.makeup_gain_db / 20.0)
|
||||
for ch in range(audio.shape[0]):
|
||||
for i in range(audio.shape[1]):
|
||||
sample = audio[ch, i]
|
||||
level = abs(sample)
|
||||
if level > self._envelope:
|
||||
self._envelope += (1 - self._attack_coeff) * (level - self._envelope)
|
||||
else:
|
||||
self._envelope += (1 - self._release_coeff) * (level - self._envelope)
|
||||
if self._envelope > threshold_linear:
|
||||
env_db = 20 * np.log10(max(self._envelope, 1e-10))
|
||||
gain_db = (self.threshold_db - env_db) * (1 - 1.0 / self.ratio)
|
||||
gain = 10 ** (gain_db / 20.0)
|
||||
else:
|
||||
gain = 1.0
|
||||
audio[ch, i] = sample * gain * makeup_linear
|
||||
|
||||
|
||||
class Limiter:
|
||||
def __init__(self):
|
||||
self.ceiling_db = -0.1
|
||||
self.bypassed = False
|
||||
|
||||
def process(self, audio: np.ndarray):
|
||||
if self.bypassed:
|
||||
return
|
||||
ceiling = 10 ** (self.ceiling_db / 20.0)
|
||||
np.clip(audio, -ceiling, ceiling, out=audio)
|
||||
|
||||
|
||||
class MasterBus:
|
||||
def __init__(self, sr: int):
|
||||
self.compressor = Compressor(sr)
|
||||
self.limiter = Limiter()
|
||||
self.vu_peak = 0.0
|
||||
self.lufs_current = -70.0
|
||||
|
||||
def reset_vu(self):
|
||||
self.vu_peak = 0.0
|
||||
|
||||
|
||||
class AudioEngine:
|
||||
def __init__(self):
|
||||
self.client = jack.Client("RadioPanel")
|
||||
self.sr = self.client.samplerate
|
||||
|
||||
# Output ports
|
||||
# Output ports for decks (standalone, not mixed into master)
|
||||
self.ports = {
|
||||
'deck1': (
|
||||
self.client.outports.register("deck1_L"),
|
||||
@@ -158,20 +251,24 @@ class AudioEngine:
|
||||
),
|
||||
}
|
||||
|
||||
# LUFS meter input ports
|
||||
self.lufs_inports = (
|
||||
self.client.inports.register("master_L"),
|
||||
self.client.inports.register("master_R"),
|
||||
# Mixer channels: 8 stereo input ports
|
||||
self.mixer = {
|
||||
'channels': [],
|
||||
'master': MasterBus(self.sr),
|
||||
}
|
||||
for i in range(NUM_MIXER_CHANNELS):
|
||||
ch = MixerChannel(DEFAULT_CHANNEL_NAMES[i], i)
|
||||
ch.in_port_L = self.client.inports.register(f"mixer_ch_{i+1:02d}_in_L")
|
||||
ch.in_port_R = self.client.inports.register(f"mixer_ch_{i+1:02d}_in_R")
|
||||
self.mixer['channels'].append(ch)
|
||||
|
||||
# Master output ports
|
||||
self.master_out = (
|
||||
self.client.outports.register("master_out_L"),
|
||||
self.client.outports.register("master_out_R"),
|
||||
)
|
||||
|
||||
# LUFS state (3 second short-term buffer)
|
||||
self.lufs_buffer_seconds = 3.0
|
||||
self.lufs_buffer_frames = int(self.lufs_buffer_seconds * self.sr)
|
||||
self.lufs_buffer = np.zeros((2, self.lufs_buffer_frames), dtype=np.float32)
|
||||
self.lufs_write_pos = 0
|
||||
self.lufs_current = -70.0 # dB LUFS (very quiet default)
|
||||
|
||||
# Deck states
|
||||
# Deck states (unchanged)
|
||||
self.decks = {
|
||||
'deck1': DeckState(),
|
||||
'deck2': DeckState(),
|
||||
@@ -184,6 +281,25 @@ class AudioEngine:
|
||||
self.client.set_process_callback(self._process)
|
||||
self.client.activate()
|
||||
|
||||
def get_channel(self, index: int) -> MixerChannel:
|
||||
return self.mixer['channels'][index]
|
||||
|
||||
def set_channel_name(self, index: int, name: str):
|
||||
if 0 <= index < NUM_MIXER_CHANNELS:
|
||||
self.mixer['channels'][index].name = name
|
||||
|
||||
def set_channel_volume(self, index: int, volume: float):
|
||||
if 0 <= index < NUM_MIXER_CHANNELS:
|
||||
self.mixer['channels'][index].volume = max(0.0, min(1.0, volume))
|
||||
|
||||
def set_channel_mute(self, index: int, muted: bool):
|
||||
if 0 <= index < NUM_MIXER_CHANNELS:
|
||||
self.mixer['channels'][index].muted = muted
|
||||
|
||||
def set_channel_solo(self, index: int, solo: bool):
|
||||
if 0 <= index < NUM_MIXER_CHANNELS:
|
||||
self.mixer['channels'][index].solo = solo
|
||||
|
||||
def _fill_port(self, port_L, port_R, deck: DeckState, frames: int):
|
||||
buf_L = port_L.get_array()
|
||||
buf_R = port_R.get_array()
|
||||
@@ -211,7 +327,7 @@ class AudioEngine:
|
||||
deck.position = track.num_samples - 1
|
||||
|
||||
def _process(self, frames: int):
|
||||
# Decks
|
||||
# ── Decks (unchanged) ──────────────────────────────────────────
|
||||
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
|
||||
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
|
||||
|
||||
@@ -239,33 +355,59 @@ class AudioEngine:
|
||||
deck.playing = False
|
||||
deck.position = track.num_samples - 1
|
||||
|
||||
# LUFS metering
|
||||
lufs_in_L = self.lufs_inports[0].get_array()
|
||||
lufs_in_R = self.lufs_inports[1].get_array()
|
||||
# ── Mixer ───────────────────────────────────────────────────────
|
||||
master_buf_L = self.master_out[0].get_array()
|
||||
master_buf_R = self.master_out[1].get_array()
|
||||
master_buf_L.fill(0.0)
|
||||
master_buf_R.fill(0.0)
|
||||
|
||||
# Write to ring buffer
|
||||
for i in range(frames):
|
||||
self.lufs_buffer[0, self.lufs_write_pos] = lufs_in_L[i]
|
||||
self.lufs_buffer[1, self.lufs_write_pos] = lufs_in_R[i]
|
||||
self.lufs_write_pos = (self.lufs_write_pos + 1) % self.lufs_buffer_frames
|
||||
any_solo = any(ch.solo for ch in self.mixer['channels'])
|
||||
|
||||
# Calculate LUFS every 512 frames (reduces CPU)
|
||||
for ch in self.mixer['channels']:
|
||||
in_L = ch.in_port_L.get_array()
|
||||
in_R = ch.in_port_R.get_array()
|
||||
|
||||
mute = ch.muted or (any_solo and not ch.solo)
|
||||
|
||||
if mute:
|
||||
ch.vu_peak = 0.0
|
||||
continue
|
||||
|
||||
vol = ch.volume
|
||||
peak = 0.0
|
||||
for i in range(frames):
|
||||
s_L = in_L[i] * vol
|
||||
s_R = in_R[i] * vol
|
||||
master_buf_L[i] += s_L
|
||||
master_buf_R[i] += s_R
|
||||
peak = max(peak, abs(s_L), abs(s_R))
|
||||
|
||||
ch.vu_peak = peak
|
||||
|
||||
# ── Master DSP ──────────────────────────────────────────────────
|
||||
master_audio = np.array([master_buf_L, master_buf_R])
|
||||
|
||||
self.mixer['master'].compressor.process(master_audio)
|
||||
self.mixer['master'].limiter.process(master_audio)
|
||||
|
||||
# Copy processed audio back to port buffers
|
||||
master_buf_L[:] = master_audio[0]
|
||||
master_buf_R[:] = master_audio[1]
|
||||
|
||||
# Master VU
|
||||
master_peak = max(np.max(np.abs(master_audio[0])), np.max(np.abs(master_audio[1])))
|
||||
self.mixer['master'].vu_peak = master_peak
|
||||
|
||||
# LUFS calculation (every ~512 frames)
|
||||
if frames % 512 < frames:
|
||||
self._calculate_lufs()
|
||||
self._calculate_lufs(master_audio)
|
||||
|
||||
def _calculate_lufs(self):
|
||||
"""
|
||||
Simplified LUFS calculation (BS.1770-4 compliant).
|
||||
K-weighting filter + gating applied.
|
||||
"""
|
||||
# Mean square energy across 3 second window
|
||||
mean_square = np.mean(self.lufs_buffer ** 2)
|
||||
|
||||
if mean_square < 1e-12: # Silence threshold
|
||||
self.lufs_current = -70.0
|
||||
def _calculate_lufs(self, audio: np.ndarray):
|
||||
mean_square = np.mean(audio ** 2)
|
||||
if mean_square < 1e-12:
|
||||
self.mixer['master'].lufs_current = -70.0
|
||||
else:
|
||||
# Convert to dB (simplified - proper K-weighting would use filters)
|
||||
self.lufs_current = -0.691 + 10 * np.log10(mean_square)
|
||||
self.mixer['master'].lufs_current = -0.691 + 10 * np.log10(mean_square)
|
||||
|
||||
def load_track(self, deck_key: str, filepath: str):
|
||||
def _load():
|
||||
|
||||
@@ -223,6 +223,62 @@ def icon_export(color: str = "#aaaaaa") -> QIcon:
|
||||
return _make_icon(paint, color)
|
||||
|
||||
|
||||
def icon_mute(color: str = "#ff4444") -> QIcon:
|
||||
def paint(p, r, c):
|
||||
pen = QPen(c, r.width() * 0.12)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
# Speaker body
|
||||
cx = r.left() + r.width() * 0.3
|
||||
p.drawLine(QPointF(cx, r.top() + r.height() * 0.25),
|
||||
QPointF(cx, r.bottom() - r.height() * 0.25))
|
||||
p.drawLine(QPointF(cx, r.top() + r.height() * 0.25),
|
||||
QPointF(r.left() + r.width() * 0.5, r.center().y() - r.height() * 0.15))
|
||||
p.drawLine(QPointF(cx, r.bottom() - r.height() * 0.25),
|
||||
QPointF(r.left() + r.width() * 0.5, r.center().y() + r.height() * 0.15))
|
||||
p.drawLine(QPointF(r.left() + r.width() * 0.2, r.center().y() - r.height() * 0.15),
|
||||
QPointF(r.left() + r.width() * 0.5, r.center().y() - r.height() * 0.15))
|
||||
p.drawLine(QPointF(r.left() + r.width() * 0.2, r.center().y() + r.height() * 0.15),
|
||||
QPointF(r.left() + r.width() * 0.5, r.center().y() + r.height() * 0.15))
|
||||
# X over speaker
|
||||
x = r.right() - r.width() * 0.2
|
||||
y1 = r.top() + r.height() * 0.2
|
||||
y2 = r.bottom() - r.height() * 0.2
|
||||
p.drawLine(QPointF(x, y1), QPointF(r.right() - r.width() * 0.05, y2))
|
||||
p.drawLine(QPointF(r.right() - r.width() * 0.05, y1), QPointF(x, y2))
|
||||
return _make_icon(paint, color)
|
||||
|
||||
|
||||
def icon_solo(color: str = "#ffaa00") -> QIcon:
|
||||
def paint(p, r, c):
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(c)
|
||||
# Circle with "S"
|
||||
center = r.center()
|
||||
radius = min(r.width(), r.height()) * 0.45
|
||||
p.drawEllipse(center, radius, radius)
|
||||
# S letter (white)
|
||||
pen = QPen(QColor("#000000"), r.width() * 0.12)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
path = QPainterPath()
|
||||
path.moveTo(r.left() + r.width() * 0.45, r.top() + r.height() * 0.3)
|
||||
path.cubicTo(
|
||||
r.left() + r.width() * 0.7, r.top() + r.height() * 0.15,
|
||||
r.right() - r.width() * 0.25, r.top() + r.height() * 0.35,
|
||||
r.left() + r.width() * 0.55, r.top() + r.height() * 0.5
|
||||
)
|
||||
path.cubicTo(
|
||||
r.left() + r.width() * 0.3, r.top() + r.height() * 0.65,
|
||||
r.right() - r.width() * 0.15, r.top() + r.height() * 0.7,
|
||||
r.left() + r.width() * 0.45, r.bottom() - r.height() * 0.2
|
||||
)
|
||||
p.drawPath(path)
|
||||
return _make_icon(paint, color)
|
||||
|
||||
|
||||
def icon_theme(color: str = "#00ff41") -> QIcon:
|
||||
def paint(p, r, c):
|
||||
p.setPen(Qt.NoPen)
|
||||
|
||||
@@ -15,7 +15,7 @@ from playlist_widget import PlaylistWidget
|
||||
from midi_engine import MidiEngine
|
||||
from top_bar_widget import TopBarWidget, THEMES
|
||||
from history_widget import HistoryWidget
|
||||
from lufs_meter_widget import LUFSMeterWidget
|
||||
from mixer_widget import MixerWidget
|
||||
|
||||
|
||||
class RadioPanelWindow(QMainWindow):
|
||||
@@ -70,29 +70,30 @@ class RadioPanelWindow(QMainWindow):
|
||||
|
||||
self.history_widget = HistoryWidget()
|
||||
|
||||
self.lufs_meter = LUFSMeterWidget(self.engine)
|
||||
self.mixer = MixerWidget(self.engine)
|
||||
|
||||
# ── Centre column ─────────────────────────────────────────────────
|
||||
centre_col = QHBoxLayout()
|
||||
# ── Centre column (decks + carts + mixer) ─────────────────────────
|
||||
centre_col = QVBoxLayout()
|
||||
centre_col.setSpacing(8)
|
||||
|
||||
# Decks column
|
||||
deck_col = QVBoxLayout()
|
||||
deck_col.setSpacing(8)
|
||||
# Top: decks row + carts
|
||||
deck_area = QWidget()
|
||||
deck_area.setStyleSheet("background: transparent; border: none;")
|
||||
deck_layout = QVBoxLayout(deck_area)
|
||||
deck_layout.setContentsMargins(0, 0, 0, 0)
|
||||
deck_layout.setSpacing(8)
|
||||
|
||||
deck_row = QHBoxLayout()
|
||||
deck_row.setSpacing(8)
|
||||
deck_row.addWidget(self.deck1)
|
||||
deck_row.addWidget(self.deck2)
|
||||
deck_col.addLayout(deck_row)
|
||||
deck_layout.addLayout(deck_row)
|
||||
deck_layout.addWidget(self.cart_bank)
|
||||
deck_layout.addStretch()
|
||||
|
||||
deck_col.addWidget(self.cart_bank)
|
||||
deck_col.addStretch()
|
||||
|
||||
centre_col.addLayout(deck_col)
|
||||
|
||||
# LUFS meter on right of centre section
|
||||
centre_col.addWidget(self.lufs_meter)
|
||||
# Bottom: mixer
|
||||
centre_col.addWidget(deck_area, stretch=1)
|
||||
centre_col.addWidget(self.mixer)
|
||||
|
||||
centre_widget = QWidget()
|
||||
centre_widget.setStyleSheet("background: transparent; border: none;")
|
||||
@@ -138,6 +139,7 @@ class RadioPanelWindow(QMainWindow):
|
||||
self.deck2.apply_theme(theme)
|
||||
self.cart_bank.apply_theme(theme)
|
||||
self.history_widget.apply_theme(theme)
|
||||
self.mixer.apply_theme(theme)
|
||||
|
||||
def _apply_global_style(self, theme: dict):
|
||||
self.setStyleSheet(f"""
|
||||
@@ -164,6 +166,8 @@ class RadioPanelWindow(QMainWindow):
|
||||
self.midi.deck_cue.connect(self._on_midi_cue)
|
||||
self.midi.deck_jog.connect(self._on_midi_jog)
|
||||
self.midi.cart_trigger.connect(self._on_midi_cart)
|
||||
self.midi.mixer_volume.connect(self._on_midi_mixer_volume)
|
||||
self.midi.mixer_mute.connect(self._on_midi_mixer_mute)
|
||||
self.midi.controller_connected.connect(self._on_midi_status)
|
||||
|
||||
self.midi.start()
|
||||
@@ -189,6 +193,12 @@ class RadioPanelWindow(QMainWindow):
|
||||
def _on_midi_cart(self, slot: int):
|
||||
self.cart_bank.slots[slot].toggle()
|
||||
|
||||
def _on_midi_mixer_volume(self, channel: int, value: float):
|
||||
self.engine.set_channel_volume(channel, value)
|
||||
|
||||
def _on_midi_mixer_mute(self, channel: int, muted: bool):
|
||||
self.engine.set_channel_mute(channel, muted)
|
||||
|
||||
def _on_midi_status(self, connected: bool, name: str):
|
||||
if connected:
|
||||
print(f"[MIDI] Connected: {name}")
|
||||
|
||||
+123
-30
@@ -1,10 +1,12 @@
|
||||
# midi_engine.py
|
||||
"""
|
||||
MIDI Engine for Radio Panel
|
||||
Numark DJ2Go controller support.
|
||||
MIDI Engine for GTI Radio Studio.
|
||||
Supports MIDI learn and configurable control mapping.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
@@ -13,34 +15,18 @@ from PySide6.QtCore import QObject, Signal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# MIDI Constants
|
||||
# ─────────────────────────────────────────────
|
||||
NOTE_ON = 0x90
|
||||
NOTE_OFF = 0x80
|
||||
CC = 0xB0
|
||||
|
||||
DECK1_PLAY = 0x3B
|
||||
DECK1_CUE = 0x33
|
||||
DECK1_JOG = 0x19
|
||||
|
||||
DECK2_PLAY = 0x42
|
||||
DECK2_CUE = 0x3C
|
||||
DECK2_JOG = 0x18
|
||||
|
||||
CART_NOTES = {
|
||||
0x44: 0,
|
||||
0x43: 1,
|
||||
0x46: 2,
|
||||
0x45: 3,
|
||||
}
|
||||
CONFIG_DIR = os.path.expanduser("~/.gti_radiostudio")
|
||||
CONFIG_FILE = os.path.join(CONFIG_DIR, "midi_map.json")
|
||||
|
||||
JOG_SCRUB_SECONDS = 5.0
|
||||
JOG_DEAD_ZONE = 5 # Ignore movements below this threshold
|
||||
JOG_DEAD_ZONE = 5
|
||||
|
||||
|
||||
def _decode_relative_jog(value: int) -> int:
|
||||
"""Returns movement amount, or 0 if within dead zone."""
|
||||
if 1 <= value <= 63:
|
||||
return value if value >= JOG_DEAD_ZONE else 0
|
||||
elif 65 <= value <= 127:
|
||||
@@ -49,12 +35,61 @@ def _decode_relative_jog(value: int) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
class MidiMapping:
|
||||
def __init__(self):
|
||||
self.bindings = {}
|
||||
|
||||
def add(self, action: str, target_id, midi_type: int, midi_channel: int,
|
||||
midi_note: int, inverse: bool = False):
|
||||
key = (midi_type, midi_channel, midi_note)
|
||||
self.bindings[key] = {
|
||||
'action': action,
|
||||
'target_id': target_id,
|
||||
'inverse': inverse,
|
||||
}
|
||||
|
||||
def lookup(self, midi_type: int, midi_channel: int,
|
||||
midi_note: int) -> Optional[dict]:
|
||||
return self.bindings.get((midi_type, midi_channel, midi_note))
|
||||
|
||||
def save(self, filepath: str = CONFIG_FILE):
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
data = []
|
||||
for key, val in self.bindings.items():
|
||||
data.append({
|
||||
'midi_type': key[0],
|
||||
'midi_channel': key[1],
|
||||
'midi_note': key[2],
|
||||
'action': val['action'],
|
||||
'target_id': val['target_id'],
|
||||
'inverse': val['inverse'],
|
||||
})
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def load(self, filepath: str = CONFIG_FILE):
|
||||
if not os.path.exists(filepath):
|
||||
return
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
for entry in data:
|
||||
key = (entry['midi_type'], entry['midi_channel'], entry['midi_note'])
|
||||
self.bindings[key] = {
|
||||
'action': entry['action'],
|
||||
'target_id': entry['target_id'],
|
||||
'inverse': entry.get('inverse', False),
|
||||
}
|
||||
|
||||
|
||||
class MidiEngine(QObject):
|
||||
deck_play_pause = Signal(str)
|
||||
deck_cue = Signal(str)
|
||||
deck_jog = Signal(str, float)
|
||||
cart_trigger = Signal(int)
|
||||
mixer_volume = Signal(int, float)
|
||||
mixer_mute = Signal(int, bool)
|
||||
controller_connected = Signal(bool, str)
|
||||
mapping_learned = Signal(str, object, int, int, int, bool)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -62,6 +97,30 @@ class MidiEngine(QObject):
|
||||
self._port_name: str = ""
|
||||
self._running = False
|
||||
|
||||
self.mapping = MidiMapping()
|
||||
self.mapping.load()
|
||||
|
||||
self._learn_target = None
|
||||
self._learn_mode = False
|
||||
|
||||
self._last_cc_values = {}
|
||||
|
||||
@property
|
||||
def learn_mode(self) -> bool:
|
||||
return self._learn_mode
|
||||
|
||||
def set_learn_target(self, target: Optional[tuple]):
|
||||
self._learn_target = target
|
||||
|
||||
def start_learn(self, action: str, target_id):
|
||||
self._learn_mode = True
|
||||
self._learn_target = (action, target_id)
|
||||
logger.info(f"MIDI learn: waiting for {action} ({target_id})...")
|
||||
|
||||
def stop_learn(self):
|
||||
self._learn_mode = False
|
||||
self._learn_target = None
|
||||
|
||||
def start(self, port_index: Optional[int] = None) -> bool:
|
||||
try:
|
||||
self._midi_in = rtmidi.MidiIn()
|
||||
@@ -136,33 +195,67 @@ class MidiEngine(QObject):
|
||||
value = message[2] if len(message) > 2 else 0
|
||||
|
||||
status_type = status & 0xF0
|
||||
channel = status & 0x0F
|
||||
|
||||
# Learn mode: capture any MIDI event
|
||||
if self._learn_mode and self._learn_target:
|
||||
action, target_id = self._learn_target
|
||||
mapping_type = status_type
|
||||
self.mapping.add(action, target_id, mapping_type, channel, data_byte)
|
||||
self.mapping.save()
|
||||
self.mapping_learned.emit(action, target_id, mapping_type, channel, data_byte, value > 0)
|
||||
self.stop_learn()
|
||||
return
|
||||
|
||||
if status_type == NOTE_ON and value > 0:
|
||||
self._handle_note_on(data_byte)
|
||||
self._handle_learned_event(status_type, channel, data_byte, value)
|
||||
elif status_type == NOTE_OFF or (status_type == NOTE_ON and value == 0):
|
||||
pass
|
||||
elif status_type == CC:
|
||||
self._handle_cc(data_byte, value)
|
||||
self._handle_learned_event(status_type, channel, data_byte, value)
|
||||
|
||||
def _handle_learned_event(self, midi_type: int, channel: int,
|
||||
note: int, value: int):
|
||||
binding = self.mapping.lookup(midi_type, channel, note)
|
||||
if binding is None:
|
||||
return
|
||||
|
||||
action = binding['action']
|
||||
target_id = binding['target_id']
|
||||
inv = binding.get('inverse', False)
|
||||
|
||||
if action == 'mixer_volume':
|
||||
scaled = (127 - value if inv else value) / 127.0
|
||||
self.mixer_volume.emit(target_id, scaled)
|
||||
elif action == 'mixer_mute':
|
||||
muted = (value < 64) if inv else (value > 63)
|
||||
self.mixer_mute.emit(target_id, muted)
|
||||
|
||||
def _handle_note_on(self, note: int):
|
||||
if note == DECK1_PLAY:
|
||||
# Hardcoded Numark DJ2Go mapping (legacy)
|
||||
NUMARK_CART = {0x44: 0, 0x43: 1, 0x46: 2, 0x45: 3}
|
||||
|
||||
if note == 0x3B:
|
||||
self.deck_play_pause.emit('deck1')
|
||||
elif note == DECK1_CUE:
|
||||
elif note == 0x33:
|
||||
self.deck_cue.emit('deck1')
|
||||
elif note == DECK2_PLAY:
|
||||
elif note == 0x42:
|
||||
self.deck_play_pause.emit('deck2')
|
||||
elif note == DECK2_CUE:
|
||||
elif note == 0x3C:
|
||||
self.deck_cue.emit('deck2')
|
||||
elif note in CART_NOTES:
|
||||
self.cart_trigger.emit(CART_NOTES[note])
|
||||
elif note in NUMARK_CART:
|
||||
self.cart_trigger.emit(NUMARK_CART[note])
|
||||
|
||||
def _handle_cc(self, cc_num: int, value: int):
|
||||
movement = _decode_relative_jog(value)
|
||||
if movement == 0:
|
||||
return
|
||||
|
||||
# Scale movement to seconds
|
||||
delta = (movement / 10.0) * JOG_SCRUB_SECONDS
|
||||
|
||||
if cc_num == DECK1_JOG:
|
||||
if cc_num == 0x19:
|
||||
self.deck_jog.emit('deck1', delta)
|
||||
elif cc_num == DECK2_JOG:
|
||||
elif cc_num == 0x18:
|
||||
self.deck_jog.emit('deck2', delta)
|
||||
+549
@@ -0,0 +1,549 @@
|
||||
# mixer_widget.py
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider,
|
||||
QPushButton, QFrame, QScrollArea, QDial, QSizePolicy,
|
||||
QInputDialog, QCheckBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen
|
||||
|
||||
from audio_engine import NUM_MIXER_CHANNELS
|
||||
from icons import icon_mute, icon_solo
|
||||
|
||||
|
||||
STYLESHEET = """
|
||||
QWidget {{
|
||||
background-color: {bg};
|
||||
color: {text};
|
||||
font-family: Arial;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
class VUMeter(QWidget):
|
||||
def __init__(self, parent=None, orientation=Qt.Vertical):
|
||||
super().__init__(parent)
|
||||
self._value = 0.0
|
||||
self._orientation = orientation
|
||||
self.setMinimumWidth(24 if orientation == Qt.Vertical else 100)
|
||||
self.setMinimumHeight(100 if orientation == Qt.Vertical else 24)
|
||||
|
||||
def set_value(self, value: float):
|
||||
self._value = max(0.0, min(1.0, value))
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
rect = self.rect().adjusted(2, 2, -2, -2)
|
||||
if rect.width() < 1 or rect.height() < 1:
|
||||
painter.end()
|
||||
return
|
||||
|
||||
# Background
|
||||
painter.fillRect(rect, QColor("#0a0a0a"))
|
||||
|
||||
val = self._value
|
||||
if val < 1e-10:
|
||||
painter.setPen(QPen(QColor("#222222"), 1))
|
||||
painter.drawRect(rect)
|
||||
painter.end()
|
||||
return
|
||||
|
||||
# Scale to dB-like (log-ish): map 0.0..1.0 to -40..0 dB display
|
||||
db = -40 * (1 - val) if val < 1.0 else 3
|
||||
fill_pct = (db + 40) / 43.0 if db <= 0 else 1.0
|
||||
fill_pct = max(0.0, min(1.0, fill_pct))
|
||||
|
||||
gradient = QLinearGradient(0, rect.bottom(), 0, rect.top())
|
||||
gradient.setColorAt(0.0, QColor("#003300"))
|
||||
gradient.setColorAt(0.5, QColor("#00cc00"))
|
||||
gradient.setColorAt(0.75, QColor("#cccc00"))
|
||||
gradient.setColorAt(0.9, QColor("#ff6600"))
|
||||
gradient.setColorAt(1.0, QColor("#ff0000"))
|
||||
|
||||
fill_h = int(rect.height() * fill_pct)
|
||||
painter.fillRect(
|
||||
rect.x(), rect.bottom() - fill_h,
|
||||
rect.width(), fill_h, gradient
|
||||
)
|
||||
|
||||
painter.setPen(QPen(QColor("#333333"), 1))
|
||||
painter.drawRect(rect)
|
||||
painter.end()
|
||||
|
||||
|
||||
class ChannelStrip(QFrame):
|
||||
def __init__(self, engine, channel_index, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self.channel_index = channel_index
|
||||
self._channel = engine.get_channel(channel_index)
|
||||
|
||||
self.setFixedWidth(110)
|
||||
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #151515;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
""")
|
||||
|
||||
self._build_ui()
|
||||
self._connect_signals()
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(80)
|
||||
self.timer.timeout.connect(self._refresh)
|
||||
self.timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(4, 6, 4, 6)
|
||||
root.setSpacing(4)
|
||||
|
||||
# Name label
|
||||
self.name_btn = QPushButton(self._channel.name)
|
||||
self.name_btn.setFont(QFont("Arial", 9, QFont.Bold))
|
||||
self.name_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: transparent;
|
||||
color: #aaaaaa;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
color: #ffffff;
|
||||
background-color: #222222;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
root.addWidget(self.name_btn)
|
||||
|
||||
# VU Meter
|
||||
self.vu_meter = VUMeter(orientation=Qt.Vertical)
|
||||
self.vu_meter.setMinimumHeight(120)
|
||||
root.addWidget(self.vu_meter, stretch=1)
|
||||
|
||||
# Volume slider
|
||||
vol_row = QHBoxLayout()
|
||||
vol_row.setSpacing(2)
|
||||
vol_label = QLabel("V")
|
||||
vol_label.setFont(QFont("Arial", 8))
|
||||
vol_label.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||||
vol_row.addWidget(vol_label)
|
||||
|
||||
self.volume_slider = QSlider(Qt.Vertical)
|
||||
self.volume_slider.setRange(0, 100)
|
||||
self.volume_slider.setValue(100)
|
||||
self.volume_slider.setFixedHeight(80)
|
||||
self.volume_slider.setStyleSheet("""
|
||||
QSlider::groove:vertical {
|
||||
background: #222222;
|
||||
width: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QSlider::handle:vertical {
|
||||
background: #666666;
|
||||
height: 12px;
|
||||
width: 16px;
|
||||
margin: -4px -4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::handle:vertical:hover {
|
||||
background: #aaaaaa;
|
||||
}
|
||||
QSlider::sub-page:vertical {
|
||||
background: #00aa44;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
vol_row.addWidget(self.volume_slider, alignment=Qt.AlignCenter)
|
||||
root.addLayout(vol_row)
|
||||
|
||||
# Mute / Solo buttons
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.setSpacing(4)
|
||||
|
||||
self.mute_btn = QPushButton()
|
||||
self.mute_btn.setFixedSize(32, 28)
|
||||
self.mute_btn.setIconSize(self.mute_btn.size() * 0.7)
|
||||
self.mute_btn.setIcon(icon_mute())
|
||||
self.mute_btn.setCheckable(True)
|
||||
self.mute_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a1a1a;
|
||||
border: 1px solid #4a2a2a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #5a2020;
|
||||
border: 1px solid #ff4444;
|
||||
}
|
||||
QPushButton:hover { background-color: #3a2a2a; }
|
||||
""")
|
||||
self.mute_btn.setToolTip("Mute")
|
||||
btn_row.addWidget(self.mute_btn)
|
||||
|
||||
self.solo_btn = QPushButton()
|
||||
self.solo_btn.setFixedSize(32, 28)
|
||||
self.solo_btn.setIconSize(self.solo_btn.size() * 0.7)
|
||||
self.solo_btn.setIcon(icon_solo())
|
||||
self.solo_btn.setCheckable(True)
|
||||
self.solo_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a2a1a;
|
||||
border: 1px solid #4a4a2a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #5a5a20;
|
||||
border: 1px solid #ffaa00;
|
||||
}
|
||||
QPushButton:hover { background-color: #3a3a2a; }
|
||||
""")
|
||||
self.solo_btn.setToolTip("Solo")
|
||||
btn_row.addWidget(self.solo_btn)
|
||||
|
||||
root.addLayout(btn_row)
|
||||
|
||||
def _connect_signals(self):
|
||||
self.name_btn.clicked.connect(self._rename)
|
||||
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
||||
self.mute_btn.toggled.connect(self._on_mute_toggled)
|
||||
self.solo_btn.toggled.connect(self._on_solo_toggled)
|
||||
|
||||
def _rename(self):
|
||||
name, ok = QInputDialog.getText(
|
||||
self, "Rename Channel",
|
||||
"Channel name:",
|
||||
text=self._channel.name
|
||||
)
|
||||
if ok and name.strip():
|
||||
self._channel.name = name.strip()
|
||||
self.name_btn.setText(self._channel.name)
|
||||
|
||||
def _on_volume_changed(self, value: int):
|
||||
self.engine.set_channel_volume(self.channel_index, value / 100.0)
|
||||
|
||||
def _on_mute_toggled(self, checked: bool):
|
||||
self.engine.set_channel_mute(self.channel_index, checked)
|
||||
|
||||
def _on_solo_toggled(self, checked: bool):
|
||||
self.engine.set_channel_solo(self.channel_index, checked)
|
||||
|
||||
def _refresh(self):
|
||||
self.vu_meter.set_value(self._channel.vu_peak)
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
pass
|
||||
|
||||
|
||||
class MasterSection(QFrame):
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self._master = engine.mixer['master']
|
||||
|
||||
self.setFixedWidth(180)
|
||||
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #151515;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
""")
|
||||
|
||||
self._build_ui()
|
||||
self._connect_signals()
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(80)
|
||||
self.timer.timeout.connect(self._refresh)
|
||||
self.timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(6, 6, 6, 6)
|
||||
root.setSpacing(6)
|
||||
|
||||
# Header
|
||||
header = QLabel("MASTER")
|
||||
header.setFont(QFont("Arial", 10, QFont.Bold))
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
header.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
root.addWidget(header)
|
||||
|
||||
# VU Meter
|
||||
self.vu_meter = VUMeter(orientation=Qt.Vertical)
|
||||
self.vu_meter.setMinimumHeight(100)
|
||||
root.addWidget(self.vu_meter)
|
||||
|
||||
# LUFS readout
|
||||
self.lufs_label = QLabel("-70.0 LUFS")
|
||||
self.lufs_label.setFont(QFont("Courier New", 10, QFont.Bold))
|
||||
self.lufs_label.setAlignment(Qt.AlignCenter)
|
||||
self.lufs_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
root.addWidget(self.lufs_label)
|
||||
|
||||
# Compressor section
|
||||
comp_frame = QFrame()
|
||||
comp_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #111111;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
comp_layout = QVBoxLayout(comp_frame)
|
||||
comp_layout.setContentsMargins(4, 4, 4, 4)
|
||||
comp_layout.setSpacing(3)
|
||||
|
||||
comp_header_row = QHBoxLayout()
|
||||
comp_label = QLabel("COMPRESSOR")
|
||||
comp_label.setFont(QFont("Arial", 8, QFont.Bold))
|
||||
comp_label.setStyleSheet("color: #888888; background: transparent; border: none;")
|
||||
comp_header_row.addWidget(comp_label)
|
||||
|
||||
self.comp_bypass = QPushButton("B")
|
||||
self.comp_bypass.setFixedSize(24, 20)
|
||||
self.comp_bypass.setCheckable(True)
|
||||
self.comp_bypass.setFont(QFont("Arial", 8, QFont.Bold))
|
||||
self.comp_bypass.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a2a2a;
|
||||
color: #888888;
|
||||
border: 1px solid #444;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #5a2020;
|
||||
color: #ff6666;
|
||||
border: 1px solid #ff4444;
|
||||
}
|
||||
""")
|
||||
self.comp_bypass.setToolTip("Bypass compressor")
|
||||
comp_header_row.addWidget(self.comp_bypass)
|
||||
comp_layout.addLayout(comp_header_row)
|
||||
|
||||
knobs_row = QHBoxLayout()
|
||||
knobs_row.setSpacing(2)
|
||||
for label, attr, default, lo, hi, suffix in [
|
||||
("THR", "threshold_db", -20, -60, 0, "dB"),
|
||||
("RAT", "ratio", 4, 1, 20, ":1"),
|
||||
("ATK", "attack_ms", 5, 0.1, 50, "ms"),
|
||||
("REL", "release_ms", 100, 10, 1000, "ms"),
|
||||
("MKG", "makeup_gain_db", 0, 0, 24, "dB"),
|
||||
]:
|
||||
k = self._make_knob(label, self._master.compressor, attr, default, lo, hi, suffix)
|
||||
knobs_row.addWidget(k)
|
||||
comp_layout.addLayout(knobs_row)
|
||||
|
||||
root.addWidget(comp_frame)
|
||||
|
||||
# Limiter section
|
||||
lim_frame = QFrame()
|
||||
lim_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #111111;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
lim_layout = QVBoxLayout(lim_frame)
|
||||
lim_layout.setContentsMargins(4, 4, 4, 4)
|
||||
lim_layout.setSpacing(3)
|
||||
|
||||
lim_header_row = QHBoxLayout()
|
||||
lim_label = QLabel("LIMITER")
|
||||
lim_label.setFont(QFont("Arial", 8, QFont.Bold))
|
||||
lim_label.setStyleSheet("color: #888888; background: transparent; border: none;")
|
||||
lim_header_row.addWidget(lim_label)
|
||||
|
||||
self.lim_bypass = QPushButton("B")
|
||||
self.lim_bypass.setFixedSize(24, 20)
|
||||
self.lim_bypass.setCheckable(True)
|
||||
self.lim_bypass.setFont(QFont("Arial", 8, QFont.Bold))
|
||||
self.lim_bypass.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a2a2a;
|
||||
color: #888888;
|
||||
border: 1px solid #444;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #5a2020;
|
||||
color: #ff6666;
|
||||
border: 1px solid #ff4444;
|
||||
}
|
||||
""")
|
||||
self.lim_bypass.setToolTip("Bypass limiter")
|
||||
lim_header_row.addWidget(self.lim_bypass)
|
||||
lim_layout.addLayout(lim_header_row)
|
||||
|
||||
lim_knobs = QHBoxLayout()
|
||||
lim_knobs.setSpacing(2)
|
||||
k = self._make_knob("CEIL", self._master.limiter, "ceiling_db", -0.1, -10, 0, "dB")
|
||||
lim_knobs.addWidget(k)
|
||||
lim_layout.addLayout(lim_knobs)
|
||||
|
||||
root.addWidget(lim_frame)
|
||||
|
||||
root.addStretch()
|
||||
|
||||
def _make_knob(self, label, obj, attr, default, lo, hi, suffix):
|
||||
frame = QFrame()
|
||||
frame.setStyleSheet("background: transparent; border: none;")
|
||||
layout = QVBoxLayout(frame)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(1)
|
||||
|
||||
lbl = QLabel(label)
|
||||
lbl.setFont(QFont("Arial", 7, QFont.Bold))
|
||||
lbl.setAlignment(Qt.AlignCenter)
|
||||
lbl.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
dial = QDial()
|
||||
dial.setFixedSize(32, 32)
|
||||
dial.setRange(0, 1000)
|
||||
dial.setNotchesVisible(False)
|
||||
dial.setStyleSheet("""
|
||||
QDial {
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 16px;
|
||||
}
|
||||
""")
|
||||
|
||||
# Map value to dial position
|
||||
def _val_to_pos(v):
|
||||
return int((v - lo) / (hi - lo) * 1000)
|
||||
|
||||
def _pos_to_val(p):
|
||||
return lo + (p / 1000.0) * (hi - lo)
|
||||
|
||||
dial.setValue(_val_to_pos(default))
|
||||
|
||||
val_label = QLabel(f"{default}{suffix}")
|
||||
val_label.setFont(QFont("Courier New", 8))
|
||||
val_label.setAlignment(Qt.AlignCenter)
|
||||
val_label.setStyleSheet("color: #aaaaaa; background: transparent; border: none;")
|
||||
|
||||
def _on_dial(v):
|
||||
val = _pos_to_val(v)
|
||||
if attr == "ratio":
|
||||
val = round(max(1, val), 1)
|
||||
elif attr in ("threshold_db", "ceiling_db", "makeup_gain_db"):
|
||||
val = round(max(lo, min(hi, val)), 1)
|
||||
elif attr in ("attack_ms",):
|
||||
val = round(max(0.1, val), 1)
|
||||
elif attr == "release_ms":
|
||||
val = round(max(1, val), 0)
|
||||
setattr(obj, attr, val)
|
||||
if attr == "attack_ms":
|
||||
obj.set_attack(val)
|
||||
elif attr == "release_ms":
|
||||
obj.set_release(val)
|
||||
val_label.setText(f"{val}{suffix}")
|
||||
|
||||
dial.valueChanged.connect(_on_dial)
|
||||
layout.addWidget(dial, alignment=Qt.AlignCenter)
|
||||
layout.addWidget(val_label)
|
||||
|
||||
return frame
|
||||
|
||||
def _connect_signals(self):
|
||||
self.comp_bypass.toggled.connect(
|
||||
lambda c: setattr(self._master.compressor, 'bypassed', c)
|
||||
)
|
||||
self.lim_bypass.toggled.connect(
|
||||
lambda c: setattr(self._master.limiter, 'bypassed', c)
|
||||
)
|
||||
|
||||
def _refresh(self):
|
||||
self.vu_meter.set_value(self._master.vu_peak)
|
||||
self.lufs_label.setText(f"{self._master.lufs_current:.1f} LUFS")
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
pass
|
||||
|
||||
|
||||
class MixerWidget(QFrame):
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
|
||||
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #0d0d0d;
|
||||
border: 2px solid #333333;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(8, 8, 8, 8)
|
||||
root.setSpacing(6)
|
||||
|
||||
# Header
|
||||
header = QLabel("MIXER")
|
||||
header.setFont(QFont("Arial", 10, QFont.Bold))
|
||||
header.setStyleSheet("color: #666666; background: transparent; border: none;")
|
||||
root.addWidget(header)
|
||||
|
||||
# Scrollable channel strips
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll.setFixedHeight(350)
|
||||
scroll.setStyleSheet("""
|
||||
QScrollArea {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
QScrollBar:horizontal {
|
||||
background: #1a1a1a;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QScrollBar::handle:horizontal {
|
||||
background: #444444;
|
||||
border-radius: 4px;
|
||||
}
|
||||
""")
|
||||
|
||||
scroll_content = QWidget()
|
||||
scroll_content.setStyleSheet("background: transparent;")
|
||||
channels_layout = QHBoxLayout(scroll_content)
|
||||
channels_layout.setContentsMargins(0, 0, 0, 0)
|
||||
channels_layout.setSpacing(4)
|
||||
|
||||
self._strips = []
|
||||
for i in range(NUM_MIXER_CHANNELS):
|
||||
strip = ChannelStrip(self.engine, i)
|
||||
self._strips.append(strip)
|
||||
channels_layout.addWidget(strip)
|
||||
|
||||
# Master section
|
||||
self.master_section = MasterSection(self.engine)
|
||||
channels_layout.addWidget(self.master_section)
|
||||
|
||||
channels_layout.addStretch()
|
||||
|
||||
scroll.setWidget(scroll_content)
|
||||
root.addWidget(scroll)
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
for strip in self._strips:
|
||||
strip.apply_theme(theme)
|
||||
self.master_section.apply_theme(theme)
|
||||
Reference in New Issue
Block a user