Files
gti_radiostudio/mixer_widget.py
T

570 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# mixer_widget.py
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider,
QPushButton, QFrame, QScrollArea, QSizePolicy,
QInputDialog, QDial
)
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_mic
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)
rect = self.rect().adjusted(18, 3, -3, -3)
if rect.width() < 1 or rect.height() < 1:
painter.end()
return
painter.fillRect(rect, QColor("#080808"))
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)
if db == -6:
painter.setPen(QPen(QColor("#00ff88"), 2))
painter.drawLine(rect.right() - 3, y, rect.right(), y)
painter.setFont(QFont("Segoe UI", 9, 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", 8))
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))
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)
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(100)
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)
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", 9, QFont.Bold))
self.name_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
color: #999aaa;
border: none;
padding: 2px;
font-size: 9pt;
}
QPushButton:hover {
color: #ffffff;
background-color: #22252c;
border-radius: 3px;
}
""")
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)
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._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; }
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)
btn_row = QHBoxLayout()
btn_row.setSpacing(3)
is_mic = self.channel_index < 2
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; }
""")
self.mute_btn.setVisible(not is_mic)
btn_row.addWidget(self.mute_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(is_mic)
btn_row.addWidget(self.mic_btn)
self.pfl_btn = QPushButton("PFL")
self.pfl_btn.setFixedSize(28, 24)
self.pfl_btn.setFont(QFont("Segoe UI", 8, 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.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)
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):
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)
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)
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))
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)
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", 10, 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)
# 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 LUFSExpandedSection(QFrame):
def __init__(self, engine, parent=None):
super().__init__(parent)
self.engine = engine
self._master = engine.mixer['master']
self.setFixedWidth(200)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.setStyleSheet("""
LUFSExpandedSection {
background-color: #181a1e;
border: 1px solid #282a30;
border-radius: 6px;
}
""")
self._build_ui()
self.timer = QTimer(self)
self.timer.setInterval(100)
self.timer.timeout.connect(self._refresh)
self.timer.start()
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(8, 12, 8, 12)
root.setSpacing(4)
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)
self.lufs_short = QLabel("-70.0")
self.lufs_short.setFont(QFont("Segoe UI", 28, QFont.Bold))
self.lufs_short.setAlignment(Qt.AlignCenter)
self.lufs_short.setStyleSheet("color: #00cc88; background: transparent; border: none;")
root.addWidget(self.lufs_short, stretch=1)
short_hint = QLabel("4 s")
short_hint.setFont(QFont("Segoe UI", 7))
short_hint.setAlignment(Qt.AlignCenter)
short_hint.setStyleSheet("color: #445; background: transparent; border: none;")
root.addWidget(short_hint)
self.lufs_long = QLabel("-70.0")
self.lufs_long.setFont(QFont("Segoe UI", 18, QFont.Bold))
self.lufs_long.setAlignment(Qt.AlignCenter)
self.lufs_long.setStyleSheet("color: #5588aa; background: transparent; border: none;")
root.addWidget(self.lufs_long)
long_hint = QLabel("10 min")
long_hint.setFont(QFont("Segoe UI", 7))
long_hint.setAlignment(Qt.AlignCenter)
long_hint.setStyleSheet("color: #445; background: transparent; border: none;")
root.addWidget(long_hint)
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;")
root.addWidget(self.pfl_indicator)
def _refresh(self):
self.lufs_short.setText(f"{self._master.lufs_short:.1f}")
self.lufs_long.setText(f"{self._master.lufs_long:.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\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)
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", 11, 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.lufs_section = LUFSExpandedSection(self.engine)
channels_layout.addWidget(self.lufs_section)
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.lufs_section.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.gain_knob.setValue(int(ch.gain * 50))
strip.mute_btn.setChecked(ch.muted)
strip.pfl_btn.setChecked(ch.pfl)