# 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.setMinimumWidth(160) self.setSizePolicy(QSizePolicy.Expanding, 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 MicTimerWidget(QFrame): def __init__(self, engine, parent=None): super().__init__(parent) self.engine = engine self.setMinimumWidth(120) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.setStyleSheet(""" MicTimerWidget { background-color: #181a1e; border: 1px solid #282a30; border-radius: 6px; } """) root = QVBoxLayout(self) root.setContentsMargins(8, 12, 8, 12) root.setSpacing(4) header = QLabel("MIC ON TIME") header.setFont(QFont("Segoe UI", 9, QFont.Bold)) header.setAlignment(Qt.AlignCenter) header.setStyleSheet("color: #556670; background: transparent; border: none;") root.addWidget(header) self.duration_label = QLabel("00:00") self.duration_label.setFont(QFont("Courier New", 28, QFont.Bold)) self.duration_label.setAlignment(Qt.AlignCenter) self.duration_label.setStyleSheet("color: #00cc66; background: transparent; border: none;") root.addWidget(self.duration_label, stretch=1) self.status_label = QLabel("MIC OFF") self.status_label.setFont(QFont("Segoe UI", 10, QFont.Bold)) self.status_label.setAlignment(Qt.AlignCenter) self.status_label.setStyleSheet("color: #444; background: transparent; border: none;") root.addWidget(self.status_label) self.timer = QTimer(self) self.timer.setInterval(100) self.timer.timeout.connect(self._refresh) self.timer.start() def _refresh(self): dur = self.engine.get_mic_duration() channels = self.engine.mixer['channels'] any_on = any(ch.mic_on for ch in channels[:2]) if any_on: mins = int(dur // 60) secs = int(dur % 60) self.duration_label.setText(f"{mins:02d}:{secs:02d}") self.duration_label.setStyleSheet("color: #00ff41; background: transparent; border: none;") self.status_label.setText("MIC LIVE") self.status_label.setStyleSheet("color: #ff4444; font-weight: bold; background: transparent; border: none;") else: self.duration_label.setText("00:00") self.duration_label.setStyleSheet("color: #333; background: transparent; border: none;") self.status_label.setText("MIC OFF") self.status_label.setStyleSheet("color: #444; 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) 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) 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)