Files
gti_radiostudio/mixer_widget.py
T

550 lines
18 KiB
Python
Raw Normal View History

# 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)