Files
gti_radiostudio/mixer_widget.py
T

627 lines
22 KiB
Python
Raw Normal View History

# mixer_widget.py
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider,
QPushButton, QFrame, QScrollArea, QDial, QSizePolicy,
QInputDialog
)
from PySide6.QtCore import Qt, QTimer, QSize
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen
from audio_engine import NUM_MIXER_CHANNELS
from icons import icon_mute, icon_solo, icon_mic
2026-05-13 20:53:15 +10:00
import math
class VUMeter(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._value = 0.0
self.setMinimumWidth(28)
self.setMinimumHeight(80)
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)
2026-05-13 20:53:15 +10:00
rect = self.rect().adjusted(18, 3, -3, -3)
if rect.width() < 1 or rect.height() < 1:
painter.end()
return
painter.fillRect(rect, QColor("#080808"))
2026-05-13 20:53:15 +10:00
db_markings = [(-40,), (-30,), (-20,), (-12,), (-6,), (0,)]
for (db,) in db_markings:
pct = (db + 46) / 46.0 if db <= 0 else 1.0
pct = max(0.0, min(1.0, pct))
y = rect.bottom() - int(rect.height() * pct)
2026-05-13 20:53:15 +10:00
if db == -6:
painter.setPen(QPen(QColor("#00ff88"), 2))
painter.drawLine(rect.right() - 3, y, rect.right(), y)
painter.setFont(QFont("Segoe UI", 8, QFont.Bold))
painter.setPen(QPen(QColor("#ffffff"), 1))
else:
painter.setPen(QPen(QColor("#2a2a2a"), 1))
painter.drawLine(rect.right() - 5, y, rect.right(), y)
painter.setPen(QPen(QColor("#cccccc"), 1))
painter.setFont(QFont("Segoe UI", 7))
painter.drawText(rect.left() - 16, y + 3, f"{db}")
painter.setPen(QPen(QColor("#1a1a1a"), 1))
painter.drawRect(rect)
val = self._value
if val < 1e-10:
painter.end()
return
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"))
db = -46 * (1 - val) if val < 1.0 else 3
fill_pct = (db + 46) / 49.0 if db <= 0 else 1.0
fill_pct = max(0.0, min(1.0, fill_pct))
2026-05-13 20:53:15 +10:00
fill_pct = math.sqrt(fill_pct)
fill_h = int(rect.height() * fill_pct)
painter.fillRect(rect.x(), rect.bottom() - fill_h,
rect.width(), fill_h, gradient)
2026-05-13 20:53:15 +10:00
for (db,) in db_markings:
pct = (db + 46) / 46.0 if db <= 0 else 1.0
pct = max(0.0, min(1.0, pct))
y = rect.bottom() - int(rect.height() * pct)
painter.setPen(QPen(QColor("#000000"), 1))
painter.drawLine(rect.x(), y, rect.right(), y)
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(90)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.setStyleSheet("""
ChannelStrip {
background-color: #181a1e;
border: 1px solid #282a30;
border-radius: 6px;
}
""")
self._build_ui()
self._connect_signals()
self.timer = QTimer(self)
2026-05-13 20:53:15 +10:00
self.timer.setInterval(30)
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)
self.name_btn = QPushButton(self._channel.name)
self.name_btn.setFont(QFont("Segoe UI", 8, QFont.Bold))
self.name_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
color: #999aaa;
border: none;
padding: 2px;
font-size: 8pt;
}
QPushButton:hover {
color: #ffffff;
background-color: #22252c;
border-radius: 3px;
}
""")
root.addWidget(self.name_btn)
2026-05-13 20:53:15 +10:00
meter_row = QHBoxLayout()
meter_row.setSpacing(2)
self.vu_meter = VUMeter()
2026-05-13 20:53:15 +10:00
meter_row.addWidget(self.vu_meter, stretch=1)
self.volume_slider = QSlider(Qt.Vertical)
self.volume_slider.setRange(0, 100)
2026-05-13 20:53:15 +10:00
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100))
self.volume_slider.setStyleSheet("""
QSlider::groove:vertical {
background: #22242a;
width: 4px;
border-radius: 2px;
}
QSlider::handle:vertical {
background: #8890a0;
height: 12px;
width: 14px;
margin: -4px -5px;
border-radius: 2px;
}
QSlider::handle:vertical:hover { background: #b0b8c8; }
2026-05-13 20:53:15 +10:00
QSlider::add-page:vertical {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #00cc66, stop:1 #008844);
border-radius: 2px;
}
2026-05-13 20:53:15 +10:00
QSlider::sub-page:vertical {
background: transparent;
border-radius: 2px;
}
""")
2026-05-13 20:53:15 +10:00
meter_row.addWidget(self.volume_slider, stretch=1)
root.addLayout(meter_row, stretch=1)
btn_row = QHBoxLayout()
btn_row.setSpacing(3)
self.mute_btn = QPushButton()
self.mute_btn.setFixedSize(28, 24)
self.mute_btn.setIconSize(QSize(16, 16))
self.mute_btn.setIcon(icon_mute())
self.mute_btn.setCheckable(True)
self.mute_btn.setToolTip("Mute")
self.mute_btn.setStyleSheet("""
QPushButton {
background-color: #242026;
border: 1px solid #3a303a;
border-radius: 3px;
}
QPushButton:checked {
background-color: #4a1a1a;
border: 1px solid #ff4444;
}
QPushButton:hover { background-color: #343036; }
""")
btn_row.addWidget(self.mute_btn)
self.solo_btn = QPushButton()
self.solo_btn.setFixedSize(28, 24)
self.solo_btn.setIconSize(QSize(16, 16))
self.solo_btn.setIcon(icon_solo())
self.solo_btn.setCheckable(True)
self.solo_btn.setToolTip("Solo")
self.solo_btn.setStyleSheet("""
QPushButton {
background-color: #262420;
border: 1px solid #3a3830;
border-radius: 3px;
}
QPushButton:checked {
background-color: #4a4a1a;
border: 1px solid #ffaa00;
}
QPushButton:hover { background-color: #363430; }
""")
btn_row.addWidget(self.solo_btn)
# Mic ON/OFF button for channels 0 and 1
self.mic_btn = QPushButton()
self.mic_btn.setFixedSize(28, 24)
self.mic_btn.setIconSize(QSize(16, 16))
self.mic_btn.setIcon(icon_mic("#888"))
self.mic_btn.setCheckable(True)
self.mic_btn.setToolTip("Mic ON audio goes to main")
self.mic_btn.setStyleSheet("""
QPushButton {
background-color: #202426;
border: 1px solid #303a3a;
border-radius: 3px;
}
QPushButton:checked {
background-color: #1a3a1a;
border: 1px solid #00cc66;
}
QPushButton:hover { background-color: #303436; }
""")
self.mic_btn.setVisible(self.channel_index < 2)
btn_row.addWidget(self.mic_btn)
self.pfl_btn = QPushButton("PFL")
self.pfl_btn.setFixedSize(28, 24)
self.pfl_btn.setFont(QFont("Segoe UI", 7, QFont.Bold))
self.pfl_btn.setCheckable(True)
self.pfl_btn.setToolTip("Pre-Fader Listen")
self.pfl_btn.setStyleSheet("""
QPushButton {
background-color: #202426;
border: 1px solid #303a3a;
border-radius: 3px;
color: #668;
}
QPushButton:checked {
background-color: #1a3a4a;
border: 1px solid #00aaff;
color: #00ccff;
}
QPushButton:hover { background-color: #303436; }
""")
btn_row.addWidget(self.pfl_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)
self.mic_btn.toggled.connect(self._on_mic_toggled)
self.pfl_btn.toggled.connect(self._on_pfl_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):
2026-05-13 20:53:15 +10:00
vol = (value / 100.0) ** 2
self.engine.set_channel_volume(self.channel_index, vol)
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 _on_pfl_toggled(self, checked: bool):
self.engine.set_channel_pfl(self.channel_index, checked)
def _on_mic_toggled(self, checked: bool):
self.engine.set_channel_mic_on(self.channel_index, checked)
color = "#00cc66" if checked else "#888"
self.mic_btn.setIcon(icon_mic(color))
def _refresh(self):
self.vu_meter.set_value(self._channel.vu_peak)
2026-05-13 20:53:15 +10:00
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100))
if self.channel_index < 2:
color = "#00cc66" if self._channel.mic_on else "#888"
self.mic_btn.setIcon(icon_mic(color))
class MasterPreSection(QFrame):
def __init__(self, engine, parent=None):
super().__init__(parent)
self.engine = engine
self._master = engine.mixer['master']
self.setFixedWidth(80)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.setStyleSheet("""
MasterPreSection {
background-color: #101216;
border: 1px solid #1e2026;
border-radius: 6px;
}
""")
self._build_ui()
self.timer = QTimer(self)
2026-05-13 20:53:15 +10:00
self.timer.setInterval(30)
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)
header = QLabel("MASTER")
header.setFont(QFont("Segoe UI", 8, QFont.Bold))
header.setAlignment(Qt.AlignCenter)
header.setStyleSheet("color: #888; background: transparent; border: none;")
root.addWidget(header)
meter_row = QHBoxLayout()
meter_row.setSpacing(2)
self.vu_meter = VUMeter()
meter_row.addWidget(self.vu_meter, stretch=1)
self.volume_slider = QSlider(Qt.Vertical)
self.volume_slider.setRange(0, 100)
self.volume_slider.setValue(int(math.sqrt(self._master.volume) * 100))
self.volume_slider.setStyleSheet("""
QSlider::groove:vertical { background: #22242a; width: 4px; border-radius: 2px; }
QSlider::handle:vertical { background: #8890a0; height: 12px; width: 14px; margin: -4px -5px; border-radius: 2px; }
QSlider::handle:vertical:hover { background: #b0b8c8; }
QSlider::add-page:vertical {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #00cc66, stop:1 #008844);
border-radius: 2px;
}
QSlider::sub-page:vertical { background: transparent; border-radius: 2px; }
""")
meter_row.addWidget(self.volume_slider, stretch=1)
root.addLayout(meter_row, stretch=1)
self.volume_slider.valueChanged.connect(self._on_volume_changed)
def _on_volume_changed(self, value: int):
vol = (value / 100.0) ** 2
self._master.volume = vol
def _refresh(self):
self.vu_meter.set_value(self._master.vu_peak)
self.volume_slider.setValue(int(math.sqrt(self._master.volume) * 100))
class EffectSection(QFrame):
def __init__(self, engine, parent=None):
super().__init__(parent)
self.engine = engine
self._master = engine.mixer['master']
self._knobs = {}
self.setFixedWidth(110)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.setStyleSheet("""
EffectSection {
background-color: #181a1e;
border: 1px solid #282a30;
border-radius: 6px;
}
""")
self._build_ui()
self.timer = QTimer(self)
self.timer.setInterval(30)
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)
header = QLabel("FX")
header.setFont(QFont("Segoe UI", 8, 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", 13, QFont.Bold))
self.lufs_value.setAlignment(Qt.AlignCenter)
self.lufs_value.setStyleSheet("color: #00cc88; background: transparent; border: none;")
lufs_layout.addWidget(self.lufs_value)
lufs_unit = QLabel("LUFS")
lufs_unit.setFont(QFont("Segoe UI", 6))
lufs_unit.setAlignment(Qt.AlignCenter)
lufs_unit.setStyleSheet("color: #556; background: transparent; border: none;")
lufs_layout.addWidget(lufs_unit)
self.pfl_indicator = QLabel("PFL\n—")
self.pfl_indicator.setFont(QFont("Segoe UI", 5))
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", 8, 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", 7))
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)
2026-05-13 20:53:15 +10:00
return frame, dial, val_label
def _refresh(self):
2026-05-13 20:53:15 +10:00
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:
names = ", ".join(ch.name[:3] for ch in active_pfl)
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.setStyleSheet("color: #556; background: transparent; border: none;")
class MixerWidget(QFrame):
def __init__(self, engine, parent=None):
super().__init__(parent)
self.engine = engine
self.setStyleSheet("""
MixerWidget {
background-color: #121318;
border: 1px solid #282a30;
border-radius: 8px;
}
""")
self._build_ui()
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(6, 4, 6, 6)
root.setSpacing(4)
header = QLabel("MIXER")
header.setFont(QFont("Segoe UI", 9, QFont.Bold))
header.setStyleSheet("color: #556670; background: transparent; border: none; "
"letter-spacing: 2px;")
root.addWidget(header)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setStyleSheet("""
QScrollArea {
border: none;
background: transparent;
}
QScrollBar:horizontal {
background: #1a1c22;
height: 6px;
border-radius: 3px;
}
QScrollBar::handle:horizontal {
background: #3a3c44;
border-radius: 3px;
}
QScrollBar::handle:horizontal:hover { background: #5a5c64; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
width: 0px;
}
""")
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)
self.master_pre = MasterPreSection(self.engine)
channels_layout.addWidget(self.master_pre)
self.effects = EffectSection(self.engine)
channels_layout.addWidget(self.effects)
scroll.setWidget(scroll_content)
root.addWidget(scroll)
def apply_theme(self, theme: dict):
for strip in self._strips:
strip.apply_theme(theme)
self.master_pre.apply_theme(theme)
self.effects.apply_theme(theme)
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.mute_btn.setChecked(ch.muted)
strip.solo_btn.setChecked(ch.solo)
strip.pfl_btn.setChecked(ch.pfl)