From d2240c61c346242d5c71797add4fd7bca7a17999 Mon Sep 17 00:00:00 2001 From: njmb Date: Tue, 12 May 2026 19:29:16 +1000 Subject: [PATCH] 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 --- audio_engine.py | 216 +++++++++++++++---- icons.py | 56 +++++ main.py | 42 ++-- midi_engine.py | 155 +++++++++++--- mixer_widget.py | 549 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 934 insertions(+), 84 deletions(-) create mode 100644 mixer_widget.py diff --git a/audio_engine.py b/audio_engine.py index 1f32fd4..9b96d20 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -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(): @@ -291,4 +433,4 @@ class AudioEngine: def shutdown(self): self.client.deactivate() - self.client.close() \ No newline at end of file + self.client.close() diff --git a/icons.py b/icons.py index 96ba599..51d0d10 100644 --- a/icons.py +++ b/icons.py @@ -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) diff --git a/main.py b/main.py index 07bdb85..4fa7a40 100644 --- a/main.py +++ b/main.py @@ -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}") @@ -225,4 +235,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/midi_engine.py b/midi_engine.py index a28e633..02479e6 100644 --- a/midi_engine.py +++ b/midi_engine.py @@ -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: - self.deck_jog.emit('deck2', delta) \ No newline at end of file + elif cc_num == 0x18: + self.deck_jog.emit('deck2', delta) diff --git a/mixer_widget.py b/mixer_widget.py new file mode 100644 index 0000000..a32abe3 --- /dev/null +++ b/mixer_widget.py @@ -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)