Strip compressor/limiter DSP, add per-channel pre-fader gain, PFL/HP blend, LUFS metering from external port, MIDI gain binding

This commit is contained in:
2026-05-15 16:42:10 +10:00
parent 4d6a639c08
commit 2b81a80a39
5 changed files with 193 additions and 294 deletions
+82 -130
View File
@@ -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)