From 2b81a80a399ffe3383d120f298e682f176ae8ddd Mon Sep 17 00:00:00 2001 From: njmb Date: Fri, 15 May 2026 16:42:10 +1000 Subject: [PATCH] Strip compressor/limiter DSP, add per-channel pre-fader gain, PFL/HP blend, LUFS metering from external port, MIDI gain binding --- audio_engine.py | 185 +++++++++++----------------------------- main.py | 30 +++---- midi_engine.py | 39 +++++++++ mixer_widget.py | 212 ++++++++++++++++++---------------------------- top_bar_widget.py | 21 ++--- 5 files changed, 193 insertions(+), 294 deletions(-) diff --git a/audio_engine.py b/audio_engine.py index fc890a8..369f45e 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -147,19 +147,12 @@ class DeckState: return f"-{int(remaining//60):02d}:{int(remaining%60):02d}" -class ExternalInputMonitor: - def __init__(self, buffer_size=600): - self.buffer = np.zeros(buffer_size, dtype=np.float32) - self.write_pos = 0 - self.buffer_size = buffer_size - self.monitoring = False - - class MixerChannel: def __init__(self, name: str, index: int): self.name = name self.index = index self.volume = 1.0 + self.gain = 1.0 # pre-fader gain trim self.muted = False self.solo = False self.pfl = False @@ -169,71 +162,12 @@ class MixerChannel: self.in_port_R = None -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 self.volume = 1.0 + self.pfl_hp_blend = 0.5 # 0.0 = PFL only, 0.5 = equal, 1.0 = HP only self._lufs_accum = 0 @@ -258,16 +192,9 @@ class AudioEngine: ), } - # External input monitors (deck3, deck4 — pass-through with waveform) - self.ext_monitors = {} - self.ext_ports = {} - for ext_key, label in [('deck3', 'deck3'), ('deck4', 'deck4')]: - in_L = self.client.inports.register(f"{label}_in_L") - in_R = self.client.inports.register(f"{label}_in_R") - out_L = self.client.outports.register(f"{label}_out_L") - out_R = self.client.outports.register(f"{label}_out_R") - self.ext_ports[ext_key] = (in_L, in_R, out_L, out_R) - self.ext_monitors[ext_key] = ExternalInputMonitor() + # LUFS metering input (external processed signal) + self.lufs_in_L = self.client.inports.register("lufs_in_L") + self.lufs_in_R = self.client.inports.register("lufs_in_R") # Mixer channels: 8 stereo input ports self.mixer = { @@ -326,6 +253,14 @@ class AudioEngine: if 0 <= index < NUM_MIXER_CHANNELS: self.mixer['channels'][index].volume = max(0.0, min(1.0, volume)) + def set_channel_gain(self, index: int, gain: float): + if 0 <= index < NUM_MIXER_CHANNELS: + self.mixer['channels'][index].gain = max(0.0, min(2.0, gain)) + + def set_channel_gain(self, index: int, gain: float): + if 0 <= index < NUM_MIXER_CHANNELS: + self.mixer['channels'][index].gain = max(0.0, min(2.0, gain)) + def set_channel_mute(self, index: int, muted: bool): if 0 <= index < NUM_MIXER_CHANNELS: self.mixer['channels'][index].muted = muted @@ -411,33 +346,8 @@ class AudioEngine: deck.playing = False deck.position = track.num_samples - 1 - # ── External input monitors (deck3, deck4) ───────────────────── - gate_linear = 10 ** (-46.0 / 20.0) - for ext_key in ('deck3', 'deck4'): - in_L, in_R, out_L, out_R = self.ext_ports[ext_key] - in_buf_L = in_L.get_array() - in_buf_R = in_R.get_array() - out_buf_L = out_L.get_array() - out_buf_R = out_R.get_array() - - out_buf_L[:] = in_buf_L[:] - out_buf_R[:] = in_buf_R[:] - - monitor = self.ext_monitors[ext_key] - if not monitor.monitoring: - monitor.buffer[:] = 0.0 - continue - - rms = np.sqrt(np.mean((in_buf_L ** 2 + in_buf_R ** 2) / 2.0)) - if rms > gate_linear: - db = 20 * np.log10(max(rms, 1e-10)) - display = max(0.0, (db + 46.0) / 46.0) - display = min(1.0, display) - else: - display = 0.0 - - monitor.buffer[monitor.write_pos] = display - monitor.write_pos = (monitor.write_pos + 1) % monitor.buffer_size + # ── LUFS metering from external processing input ──────────────── + lufs_audio = np.array([self.lufs_in_L.get_array(), self.lufs_in_R.get_array()]) # ── Mixer ─────────────────────────────────────────────────────── master_buf_L = self.master_out[0].get_array() @@ -470,11 +380,12 @@ class AudioEngine: mute = ch.muted or (any_solo and not ch.solo) is_mic = ch.index < 2 + g = ch.gain - # PFL: pre-fader, pre-mute signal to PFL bus + # PFL: pre-fader, pre-mute signal to PFL bus (with gain applied) if ch.pfl: - pfl_buf_L[:] += in_L[:] - pfl_buf_R[:] += in_R[:] + pfl_buf_L[:] += in_L[:] * g + pfl_buf_R[:] += in_R[:] * g if mute: ch.vu_peak = 0.0 @@ -486,8 +397,8 @@ class AudioEngine: peak = 0.0 for i in range(frames): - s_L = in_L[i] * vol - s_R = in_R[i] * vol + s_L = in_L[i] * g * vol + s_R = in_R[i] * g * vol if goes_to_main: master_buf_L[i] += s_L master_buf_R[i] += s_R @@ -506,34 +417,38 @@ class AudioEngine: studio_buf_L[:] = master_buf_L[:] studio_buf_R[:] = master_buf_R[:] - # ── Master DSP ────────────────────────────────────────────────── - master_audio = np.array([master_buf_L, master_buf_R]) + # ── PFL / Headphone blend ─────────────────────────────────────── + blend = self.mixer['master'].pfl_hp_blend + hp_gain = blend # 0..1 how much normal HP mix + pfl_gain = 1.0 - blend # 0..1 how much PFL + any_pfl = any(ch.pfl for ch in self.mixer['channels']) + if any_pfl and pfl_gain > 0.0: + headphone_buf_L[:] = headphone_buf_L * hp_gain + pfl_buf_L * pfl_gain + headphone_buf_R[:] = headphone_buf_R * hp_gain + pfl_buf_R * pfl_gain + elif any_pfl and hp_gain == 0.0: + headphone_buf_L[:] = pfl_buf_L[:] + headphone_buf_R[:] = pfl_buf_R[:] + # else: no PFL active, headphone stays as-is - self.mixer['master'].compressor.process(master_audio) - self.mixer['master'].limiter.process(master_audio) + # ── PFL / Headphone blend ─────────────────────────────────────── + blend = self.mixer['master'].pfl_hp_blend + hp_gain = blend # 0..1 how much normal HP mix + pfl_gain = 1.0 - blend # 0..1 how much PFL + any_pfl = any(ch.pfl for ch in self.mixer['channels']) + if any_pfl and pfl_gain > 0.0: + headphone_buf_L[:] = headphone_buf_L * hp_gain + pfl_buf_L * pfl_gain + headphone_buf_R[:] = headphone_buf_R * hp_gain + pfl_buf_R * pfl_gain + elif any_pfl and hp_gain == 0.0: + headphone_buf_L[:] = pfl_buf_L[:] + headphone_buf_R[:] = pfl_buf_R[:] + # else: no PFL active, headphone stays as-is - master_buf_L[:] = master_audio[0] - master_buf_R[:] = master_audio[1] - - # Apply same DSP to headphone (without mics) - hp_audio = np.array([headphone_buf_L, headphone_buf_R]) - self.mixer['master'].compressor.process(hp_audio) - self.mixer['master'].limiter.process(hp_audio) - headphone_buf_L[:] = hp_audio[0] - headphone_buf_R[:] = hp_audio[1] - - # Apply same DSP to studio - studio_audio = np.array([studio_buf_L, studio_buf_R]) - self.mixer['master'].compressor.process(studio_audio) - self.mixer['master'].limiter.process(studio_audio) - studio_buf_L[:] = studio_audio[0] - studio_buf_R[:] = studio_audio[1] - - master_peak = max(np.max(np.abs(master_audio[0])), np.max(np.abs(master_audio[1]))) + # ── Master VU and LUFS ───────────────────────────────────────── + master_peak = max( + np.max(np.abs(master_buf_L)), np.max(np.abs(master_buf_R)) + ) self.mixer['master'].vu_peak = master_peak - # Apply master volume fader (master bus only – headphone and studio - # maintain independent levels) vol = self.mixer['master'].volume if vol < 1.0: master_buf_L[:] *= vol @@ -543,7 +458,7 @@ class AudioEngine: master._lufs_accum += frames if master._lufs_accum >= 512: master._lufs_accum -= 512 - self._calculate_lufs(master_audio) + self._calculate_lufs(lufs_audio) def _calculate_lufs(self, audio: np.ndarray): mean_square = np.mean(audio ** 2) diff --git a/main.py b/main.py index 2256fe6..e179911 100644 --- a/main.py +++ b/main.py @@ -186,6 +186,7 @@ class RadioPanelWindow(QMainWindow): state = { 'window_size': (self.width(), self.height()), + 'pfl_hp_blend': self.engine.mixer['master'].pfl_hp_blend, 'channels': [], } @@ -193,6 +194,7 @@ class RadioPanelWindow(QMainWindow): state['channels'].append({ 'name': ch.name, 'volume': ch.volume, + 'gain': ch.gain, 'muted': ch.muted, 'pfl': ch.pfl, 'mic_on': ch.mic_on, @@ -218,11 +220,16 @@ class RadioPanelWindow(QMainWindow): if ws: self.resize(ws[0], ws[1]) + pfl_hp_blend = state.get('pfl_hp_blend') + if pfl_hp_blend is not None: + self.engine.mixer['master'].pfl_hp_blend = pfl_hp_blend + for i, ch_state in enumerate(state.get('channels', [])): if i < len(self.engine.mixer['channels']): ch = self.engine.mixer['channels'][i] ch.name = ch_state.get('name', ch.name) ch.volume = ch_state.get('volume', ch.volume) + ch.gain = ch_state.get('gain', ch.gain) ch.muted = ch_state.get('muted', ch.muted) ch.solo = ch_state.get('solo', ch.solo) ch.pfl = ch_state.get('pfl', ch.pfl) @@ -242,6 +249,7 @@ class RadioPanelWindow(QMainWindow): self.midi.mixer_pfl.connect(self._on_midi_mixer_pfl) self.midi.mixer_mic_on.connect(self._on_midi_mixer_mic_on) self.midi.mixer_mic_on.connect(self._on_midi_mixer_mic_on) + self.midi.mixer_gain.connect(self._on_midi_mixer_gain) self.midi.master_control.connect(self._on_midi_master_control) self.midi.master_volume.connect(self._on_midi_master_volume) self.midi.controller_connected.connect(self._on_midi_status) @@ -291,24 +299,12 @@ class RadioPanelWindow(QMainWindow): if channel < 2: self.engine.set_channel_mic_on(channel, on) + def _on_midi_mixer_gain(self, channel: int, gain: float): + self.engine.set_channel_gain(channel, gain) + def _on_midi_master_control(self, knob: str, val: float): - master = self.engine.mixer['master'] - attrs = { - "threshold_db": (-60, 0), - "ratio": (1, 20), - "attack_ms": (0.1, 50), - "release_ms": (10, 1000), - "makeup_gain_db": (0, 24), - "ceiling_db": (-10, 0), - } - if knob in attrs: - lo, hi = attrs[knob] - v = lo + val * (hi - lo) - if knob == "ratio": - v = round(max(1, v), 1) - elif knob in ("threshold_db", "ceiling_db", "makeup_gain_db"): - v = round(max(lo, min(hi, v)), 1) - setattr(master.compressor if knob != "ceiling_db" else master.limiter, knob, v) + if knob == "pfl_hp_blend": + self.engine.mixer['master'].pfl_hp_blend = max(0.0, min(1.0, val)) def _on_midi_master_volume(self, val: float): self.engine.mixer['master'].volume = max(0.0, min(1.0, val)) diff --git a/midi_engine.py b/midi_engine.py index 1d6358d..3544703 100644 --- a/midi_engine.py +++ b/midi_engine.py @@ -95,6 +95,7 @@ class MidiEngine(QObject): mixer_solo = Signal(int, bool) mixer_pfl = Signal(int, bool) mixer_mic_on = Signal(int, bool) + mixer_gain = Signal(int, float) master_control = Signal(str, float) master_volume = Signal(float) controller_connected = Signal(bool, str) @@ -286,6 +287,44 @@ class MidiEngine(QObject): elif action == 'mixer_mic_on': on = (value > 63) if not inv else (value < 64) self.mixer_mic_on.emit(target_id, on) + elif action == 'mixer_gain': + if is_rel and 1 <= value <= 63: + prev = self._channel_volumes.get(f"gain_{target_id}", 1.0) + new_val = min(2.0, prev + value * 0.02) + self._channel_volumes[f"gain_{target_id}"] = new_val + self.mixer_gain.emit(target_id, new_val) + elif is_rel and 65 <= value <= 127: + prev = self._channel_volumes.get(f"gain_{target_id}", 1.0) + steps = value & 0x3F + new_val = max(0.0, prev - steps * 0.02) + self._channel_volumes[f"gain_{target_id}"] = new_val + self.mixer_gain.emit(target_id, new_val) + else: + if midi_type == PITCH_BEND: + scaled = (16383 - value if inv else value) / 16383.0 + else: + scaled = (127 - value if inv else value) / 127.0 + scaled = scaled * 2.0 # map to 0-2 range + self.mixer_gain.emit(target_id, scaled) + elif action == 'mixer_gain': + if is_rel and 1 <= value <= 63: + prev = self._channel_volumes.get(f"gain_{target_id}", 1.0) + new_val = min(2.0, prev + value * 0.02) + self._channel_volumes[f"gain_{target_id}"] = new_val + self.mixer_gain.emit(target_id, new_val) + elif is_rel and 65 <= value <= 127: + prev = self._channel_volumes.get(f"gain_{target_id}", 1.0) + steps = value & 0x3F + new_val = max(0.0, prev - steps * 0.02) + self._channel_volumes[f"gain_{target_id}"] = new_val + self.mixer_gain.emit(target_id, new_val) + else: + if midi_type == PITCH_BEND: + scaled = (16383 - value if inv else value) / 16383.0 + else: + scaled = (127 - value if inv else value) / 127.0 + scaled = scaled * 2.0 # map to 0-2 range + self.mixer_gain.emit(target_id, scaled) elif action == 'master_control': knob = str(target_id) if is_rel and 1 <= value <= 63: diff --git a/mixer_widget.py b/mixer_widget.py index 2a07c5a..6367df2 100644 --- a/mixer_widget.py +++ b/mixer_widget.py @@ -2,8 +2,8 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider, - QPushButton, QFrame, QScrollArea, QDial, QSizePolicy, - QInputDialog + QPushButton, QFrame, QScrollArea, QSizePolicy, + QInputDialog, QDial ) from PySide6.QtCore import Qt, QTimer, QSize from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen @@ -134,6 +134,27 @@ class ChannelStrip(QFrame): """) root.addWidget(self.name_btn) + self.gain_knob = QDial() + self.gain_knob.setRange(0, 100) + self.gain_knob.setValue(50) + self.gain_knob.setFixedSize(40, 40) + self.gain_knob.setNotchesVisible(True) + self.gain_knob.setWrapping(False) + self.gain_knob.setToolTip("Pre-fader gain trim") + self.gain_knob.setStyleSheet(""" + QDial { + background-color: transparent; + color: #8890a0; + } + """) + root.addWidget(self.gain_knob, alignment=Qt.AlignCenter) + + gain_label = QLabel("GAIN") + gain_label.setFont(QFont("Segoe UI", 7, QFont.Bold)) + gain_label.setAlignment(Qt.AlignCenter) + gain_label.setStyleSheet("color: #556670; background: transparent; border: none;") + root.addWidget(gain_label) + meter_row = QHBoxLayout() meter_row.setSpacing(2) @@ -244,6 +265,7 @@ class ChannelStrip(QFrame): def _connect_signals(self): self.name_btn.clicked.connect(self._rename) + self.gain_knob.valueChanged.connect(self._on_gain_changed) self.volume_slider.valueChanged.connect(self._on_volume_changed) self.mute_btn.toggled.connect(self._on_mute_toggled) self.mic_btn.toggled.connect(self._on_mic_toggled) @@ -262,6 +284,10 @@ class ChannelStrip(QFrame): vol = (value / 100.0) ** 2 self.engine.set_channel_volume(self.channel_index, vol) + def _on_gain_changed(self, value: int): + gain = value / 50.0 + self.engine.set_channel_gain(self.channel_index, gain) + def _on_mute_toggled(self, checked: bool): self.engine.set_channel_mute(self.channel_index, checked) @@ -276,6 +302,7 @@ class ChannelStrip(QFrame): def _refresh(self): self.vu_meter.set_value(self._channel.vu_peak) self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100)) + self.gain_knob.setValue(int(self._channel.gain * 50)) if self.channel_index < 2: color = "#00cc66" if self._channel.mic_on else "#888" self.mic_btn.setIcon(icon_mic(color)) @@ -340,25 +367,53 @@ class MasterPreSection(QFrame): self.volume_slider.valueChanged.connect(self._on_volume_changed) + # PFL / Headphone blend + self.blend_label = QLabel("PFL⇄HP") + self.blend_label.setFont(QFont("Segoe UI", 7, QFont.Bold)) + self.blend_label.setAlignment(Qt.AlignCenter) + self.blend_label.setStyleSheet("color: #556670; background: transparent; border: none;") + root.addWidget(self.blend_label) + + self.pfl_hp_knob = QDial() + self.pfl_hp_knob.setRange(0, 100) + self.pfl_hp_knob.setValue(int(self._master.pfl_hp_blend * 100)) + self.pfl_hp_knob.setFixedSize(36, 36) + self.pfl_hp_knob.setNotchesVisible(True) + self.pfl_hp_knob.setWrapping(False) + self.pfl_hp_knob.setToolTip("Left: PFL only | Centre: equal blend | Right: HP only") + self.pfl_hp_knob.setStyleSheet(""" + QDial { + background-color: transparent; + color: #8890a0; + } + """) + root.addWidget(self.pfl_hp_knob, alignment=Qt.AlignCenter) + + self.pfl_hp_knob.valueChanged.connect(self._on_blend_changed) + def _on_volume_changed(self, value: int): vol = (value / 100.0) ** 2 self._master.volume = vol + def _on_blend_changed(self, value: int): + self._master.pfl_hp_blend = value / 100.0 + def _refresh(self): self.vu_meter.set_value(self._master.vu_peak) self.volume_slider.setValue(int(math.sqrt(self._master.volume) * 100)) + self.pfl_hp_knob.setValue(int(self._master.pfl_hp_blend * 100)) -class EffectSection(QFrame): + +class LUFSExpandedSection(QFrame): def __init__(self, engine, parent=None): super().__init__(parent) self.engine = engine self._master = engine.mixer['master'] - self._knobs = {} - self.setFixedWidth(110) + self.setFixedWidth(200) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.setStyleSheet(""" - EffectSection { + LUFSExpandedSection { background-color: #181a1e; border: 1px solid #282a30; border-radius: 6px; @@ -368,148 +423,40 @@ class EffectSection(QFrame): self._build_ui() self.timer = QTimer(self) - self.timer.setInterval(30) + self.timer.setInterval(100) self.timer.timeout.connect(self._refresh) self.timer.start() def _build_ui(self): root = QVBoxLayout(self) - root.setContentsMargins(3, 6, 3, 6) - root.setSpacing(2) + root.setContentsMargins(8, 12, 8, 12) + root.setSpacing(4) - header = QLabel("FX") + header = QLabel("LUFS") header.setFont(QFont("Segoe UI", 10, QFont.Bold)) header.setAlignment(Qt.AlignCenter) header.setStyleSheet("color: #556670; background: transparent; border: none;") root.addWidget(header) - lufs_frame = QFrame() - lufs_frame.setStyleSheet("QFrame { background-color: #121318; border: 1px solid #22242a; border-radius: 4px; }") - lufs_layout = QVBoxLayout(lufs_frame) - lufs_layout.setContentsMargins(3, 4, 3, 4) - lufs_layout.setSpacing(2) - self.lufs_value = QLabel("-70.0") - self.lufs_value.setFont(QFont("Segoe UI", 14, QFont.Bold)) + self.lufs_value.setFont(QFont("Segoe UI", 32, QFont.Bold)) self.lufs_value.setAlignment(Qt.AlignCenter) self.lufs_value.setStyleSheet("color: #00cc88; background: transparent; border: none;") - lufs_layout.addWidget(self.lufs_value) + root.addWidget(self.lufs_value, stretch=1) lufs_unit = QLabel("LUFS") - lufs_unit.setFont(QFont("Segoe UI", 7)) + lufs_unit.setFont(QFont("Segoe UI", 9)) lufs_unit.setAlignment(Qt.AlignCenter) lufs_unit.setStyleSheet("color: #556; background: transparent; border: none;") - lufs_layout.addWidget(lufs_unit) + root.addWidget(lufs_unit) - self.pfl_indicator = QLabel("PFL\n—") - self.pfl_indicator.setFont(QFont("Segoe UI", 7)) + self.pfl_indicator = QLabel("PFL\n\u2014") + self.pfl_indicator.setFont(QFont("Segoe UI", 8)) self.pfl_indicator.setAlignment(Qt.AlignCenter) self.pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;") - lufs_layout.addWidget(self.pfl_indicator) - - root.addWidget(lufs_frame) - - knob_col = QVBoxLayout() - knob_col.setSpacing(1) - - bypass_row = QHBoxLayout() - bypass_row.setSpacing(2) - for label, btn_ref, obj, attr in [ - ("C", "comp_bypass", self._master.compressor, 'bypassed'), - ("L", "lim_bypass", self._master.limiter, 'bypassed'), - ]: - btn = QPushButton(label) - btn.setFixedSize(24, 18) - btn.setCheckable(True) - btn.setFont(QFont("Segoe UI", 7, QFont.Bold)) - btn.setStyleSheet(""" - QPushButton { background-color: #22242a; color: #889; border: 1px solid #333; border-radius: 3px; } - QPushButton:checked { background-color: #4a1a1a; color: #ff6666; border: 1px solid #ff4444; } - """) - setattr(self, btn_ref, btn) - btn.toggled.connect(lambda c, o=obj, a=attr: setattr(o, a, c)) - bypass_row.addWidget(btn) - knob_col.addLayout(bypass_row) - - 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"), - ("CEIL", "ceiling_db", -0.1, -10, 0, "dB"), - ]: - obj = self._master.compressor if attr != "ceiling_db" else self._master.limiter - k, dial, val_label = self._make_knob(label, obj, attr, default, lo, hi, suffix) - knob_col.addWidget(k) - self._knobs[('compressor' if attr != 'ceiling_db' else 'limiter', attr)] = (dial, val_label, lo, hi, suffix) - root.addLayout(knob_col) - - def _make_knob(self, label, obj, attr, default, lo, hi, suffix): - frame = QFrame() - frame.setStyleSheet("background: transparent; border: none;") - layout = QHBoxLayout(frame) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(2) - - dial = QDial() - dial.setFixedSize(30, 30) - dial.setRange(0, 1000) - dial.setNotchesVisible(False) - dial.setStyleSheet(""" - QDial { background-color: #121318; border: 1px solid #2a2c33; border-radius: 15px; } - """) - - 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)) - - lbl = QLabel(label) - lbl.setFont(QFont("Segoe UI", 9, QFont.Bold)) - lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - lbl.setStyleSheet("color: #889; background: transparent; border: none;") - - val_label = QLabel(f"{default}{suffix}") - val_label.setFont(QFont("Segoe UI", 8)) - val_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - val_label.setStyleSheet("color: #667; 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) - layout.addWidget(lbl, 1) - layout.addWidget(val_label) - return frame, dial, val_label + root.addWidget(self.pfl_indicator) def _refresh(self): - for (section, attr), (dial, val_label, lo, hi, suffix) in self._knobs.items(): - obj = self._master.compressor if section == 'compressor' else self._master.limiter - val = getattr(obj, attr, lo) - dial.blockSignals(True) - dial.setValue(int((val - lo) / (hi - lo) * 1000)) - dial.blockSignals(False) - val_label.setText(f"{val}{suffix}") - self.lufs_value.setText(f"{self._master.lufs_current:.1f}") active_pfl = [ch for ch in self.engine.mixer['channels'] if ch.pfl] if active_pfl: @@ -517,9 +464,13 @@ class EffectSection(QFrame): self.pfl_indicator.setText(f"PFL\n{names}") self.pfl_indicator.setStyleSheet("color: #00aaff; background: transparent; border: none;") else: - self.pfl_indicator.setText("PFL\n—") + self.pfl_indicator.setText("PFL\n\u2014") self.pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;") + def apply_theme(self, theme: dict): + pass + + class MixerWidget(QFrame): def __init__(self, engine, parent=None): super().__init__(parent) @@ -584,8 +535,8 @@ class MixerWidget(QFrame): self.master_pre = MasterPreSection(self.engine) channels_layout.addWidget(self.master_pre) - self.effects = EffectSection(self.engine) - channels_layout.addWidget(self.effects) + self.lufs_section = LUFSExpandedSection(self.engine) + channels_layout.addWidget(self.lufs_section) scroll.setWidget(scroll_content) root.addWidget(scroll) @@ -594,12 +545,13 @@ class MixerWidget(QFrame): for strip in self._strips: strip.apply_theme(theme) self.master_pre.apply_theme(theme) - self.effects.apply_theme(theme) + self.lufs_section.apply_theme(theme) - def refresh_strips(self): +def refresh_strips(self): for strip in self._strips: ch = strip._channel strip.name_btn.setText(ch.name) strip.volume_slider.setValue(int(ch.volume * 100)) + strip.gain_knob.setValue(int(ch.gain * 50)) strip.mute_btn.setChecked(ch.muted) strip.pfl_btn.setChecked(ch.pfl) \ No newline at end of file diff --git a/top_bar_widget.py b/top_bar_widget.py index ff670a4..4a59a46 100644 --- a/top_bar_widget.py +++ b/top_bar_widget.py @@ -360,6 +360,14 @@ class SettingsPopup(QFrame): ("Ch 6 Volume", "mixer_volume", 5), ("Ch 7 Volume", "mixer_volume", 6), ("Ch 8 Volume", "mixer_volume", 7), + ("Ch 1 Gain", "mixer_gain", 0), + ("Ch 2 Gain", "mixer_gain", 1), + ("Ch 3 Gain", "mixer_gain", 2), + ("Ch 4 Gain", "mixer_gain", 3), + ("Ch 5 Gain", "mixer_gain", 4), + ("Ch 6 Gain", "mixer_gain", 5), + ("Ch 7 Gain", "mixer_gain", 6), + ("Ch 8 Gain", "mixer_gain", 7), ("Ch 1 Mic On", "mixer_mic_on", 0), ("Ch 2 Mic On", "mixer_mic_on", 1), ("Ch 1 PFL", "mixer_pfl", 0), @@ -376,13 +384,8 @@ class SettingsPopup(QFrame): ("Ch 6 Mute", "mixer_mute", 5), ("Ch 7 Mute", "mixer_mute", 6), ("Ch 8 Mute", "mixer_mute", 7), - ("Master THR", "master_control", "threshold_db"), - ("Master RAT", "master_control", "ratio"), - ("Master ATK", "master_control", "attack_ms"), - ("Master REL", "master_control", "release_ms"), - ("Master MKG", "master_control", "makeup_gain_db"), - ("Master CEIL", "master_control", "ceiling_db"), ("Master Volume", "master_volume", "master"), + ("PFL⇄HP Blend", "master_control", "pfl_hp_blend"), ] self._learn_btns = {} @@ -519,12 +522,6 @@ QPushButton:hover { background-color: #5a2a2a; } self._midi_engine.mapping.bindings.clear() self._midi_engine.mapping.save() - def _clear_all_midi(self): - if not self._midi_engine: - return - self._midi_engine.mapping.bindings.clear() - self._midi_engine.mapping.save() - def _on_mapping_learned(self, action, target_id, midi_type, channel, note, value): btn = self._learn_btns.get((action, target_id)) if btn and btn.isChecked():