From 9e4315886d55b40f3c9ccad893e25161208bfb31 Mon Sep 17 00:00:00 2001 From: njmb Date: Fri, 15 May 2026 00:36:48 +1000 Subject: [PATCH] Add mic ON/OFF toggle, headphone and studio output busses - Channels 0-1 are now dedicated Mic 1/Mic 2 with mic_on toggle - Mic audio only routes to main when mic_on=True, never to headphone - New headphone_out_L/R JACK ports (all channels except mics) - New studio_out_L/R JACK ports (copies main, mutes when any mic is on) - Mic ON/OFF toggle button in mixer UI (hidden on non-mic channels) - Session save/load includes mic_on state --- audio_engine.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++--- icons.py | 34 +++++++++++++++++++++++ main.py | 2 ++ mixer_widget.py | 33 ++++++++++++++++++++++- 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/audio_engine.py b/audio_engine.py index 3584d51..d38da8c 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -15,8 +15,8 @@ FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'} NUM_MIXER_CHANNELS = 8 DEFAULT_CHANNEL_NAMES = [ - "Turntable 1", "Turntable 2", "Mic", "Aux 1", - "Aux 2", "Aux 3", "Aux 4", "Aux 5", + "Mic 1", "Mic 2", "Aux 1", + "Aux 2", "Aux 3", "Aux 4", "Aux 5", "Aux 6", ] @@ -163,6 +163,7 @@ class MixerChannel: self.muted = False self.solo = False self.pfl = False + self.mic_on = False self.vu_peak = 0.0 self.in_port_L = None self.in_port_R = None @@ -285,6 +286,18 @@ class AudioEngine: self.client.outports.register("master_out_R"), ) + # Headphone output ports (all channels except mics) + self.headphone_out = ( + self.client.outports.register("headphone_out_L"), + self.client.outports.register("headphone_out_R"), + ) + + # Studio output ports (same as main, mutes when any mic is on) + self.studio_out = ( + self.client.outports.register("studio_out_L"), + self.client.outports.register("studio_out_R"), + ) + # PFL output ports self.pfl_out = ( self.client.outports.register("pfl_out_L"), @@ -325,6 +338,15 @@ class AudioEngine: if 0 <= index < NUM_MIXER_CHANNELS: self.mixer['channels'][index].pfl = pfl + def set_channel_mic_on(self, index: int, on: bool): + if 0 <= index < NUM_MIXER_CHANNELS: + self.mixer['channels'][index].mic_on = on + + def get_channel_mic_on(self, index: int) -> bool: + if 0 <= index < NUM_MIXER_CHANNELS: + return self.mixer['channels'][index].mic_on + return False + def _fill_port(self, port_L, port_R, deck: DeckState, frames: int): buf_L = port_L.get_array() buf_R = port_R.get_array() @@ -423,7 +445,18 @@ class AudioEngine: master_buf_L.fill(0.0) master_buf_R.fill(0.0) + headphone_buf_L = self.headphone_out[0].get_array() + headphone_buf_R = self.headphone_out[1].get_array() + headphone_buf_L.fill(0.0) + headphone_buf_R.fill(0.0) + + studio_buf_L = self.studio_out[0].get_array() + studio_buf_R = self.studio_out[1].get_array() + studio_buf_L.fill(0.0) + studio_buf_R.fill(0.0) + any_solo = any(ch.solo for ch in self.mixer['channels']) + any_mic_on = any(ch.mic_on for ch in self.mixer['channels'][:2]) # PFL bus pfl_buf_L = self.pfl_out[0].get_array() @@ -436,6 +469,7 @@ class AudioEngine: in_R = ch.in_port_R.get_array() mute = ch.muted or (any_solo and not ch.solo) + is_mic = ch.index < 2 # PFL: pre-fader, pre-mute signal to PFL bus if ch.pfl: @@ -447,16 +481,31 @@ class AudioEngine: continue vol = ch.volume + goes_to_main = not is_mic or ch.mic_on + goes_to_headphone = not is_mic + peak = 0.0 for i in range(frames): s_L = in_L[i] * vol s_R = in_R[i] * vol - master_buf_L[i] += s_L - master_buf_R[i] += s_R + if goes_to_main: + master_buf_L[i] += s_L + master_buf_R[i] += s_R + if goes_to_headphone: + headphone_buf_L[i] += s_L + headphone_buf_R[i] += s_R peak = max(peak, abs(s_L), abs(s_R)) ch.vu_peak = peak + # ── Studio bus: same as main, muted when any mic is on ──────── + if any_mic_on: + studio_buf_L.fill(0.0) + studio_buf_R.fill(0.0) + else: + studio_buf_L[:] = master_buf_L[:] + studio_buf_R[:] = master_buf_R[:] + # ── Master DSP ────────────────────────────────────────────────── master_audio = np.array([master_buf_L, master_buf_R]) @@ -466,6 +515,20 @@ class AudioEngine: master_buf_L[:] = master_audio[0] master_buf_R[:] = master_audio[1] + # Apply same DSP to headphone (without mics) + hp_audio = np.array([headphone_buf_L, headphone_buf_R]) + self.mixer['master'].compressor.process(hp_audio) + self.mixer['master'].limiter.process(hp_audio) + headphone_buf_L[:] = hp_audio[0] + headphone_buf_R[:] = hp_audio[1] + + # Apply same DSP to studio + studio_audio = np.array([studio_buf_L, studio_buf_R]) + self.mixer['master'].compressor.process(studio_audio) + self.mixer['master'].limiter.process(studio_audio) + studio_buf_L[:] = studio_audio[0] + studio_buf_R[:] = studio_audio[1] + master_peak = max(np.max(np.abs(master_audio[0])), np.max(np.abs(master_audio[1]))) self.mixer['master'].vu_peak = master_peak diff --git a/icons.py b/icons.py index 4dc7620..72cfbed 100644 --- a/icons.py +++ b/icons.py @@ -279,6 +279,40 @@ def icon_solo(color: str = "#ffaa00") -> QIcon: return _make_icon(paint, color) +def icon_mic(color: str = "#00ff41") -> QIcon: + def paint(p, r, c): + p.setPen(Qt.NoPen) + p.setBrush(c) + # Mic body - pill/rectangle shape + bw = r.width() * 0.35 + bh = r.height() * 0.45 + bx = r.center().x() - bw / 2 + by = r.top() + r.height() * 0.05 + p.drawRoundedRect(QRectF(bx, by, bw, bh), bw * 0.3, bw * 0.3) + # Mic arc at top + path = QPainterPath() + arc_w = bw * 0.6 + arc_h = r.height() * 0.08 + path.moveTo(r.center().x() - arc_w / 2, by) + path.quadTo(r.center().x(), by - arc_h, r.center().x() + arc_w / 2, by) + p.drawPath(path) + # Stand lines down from body + pen = QPen(c, r.width() * 0.06) + pen.setCapStyle(Qt.RoundCap) + p.setPen(pen) + p.setBrush(Qt.NoBrush) + stand_top = by + bh + p.drawLine(QPointF(r.center().x(), stand_top), + QPointF(r.center().x(), stand_top + r.height() * 0.15)) + # Base + base_w = r.width() * 0.45 + base_h = r.height() * 0.1 + p.drawRoundedRect(QRectF(r.center().x() - base_w / 2, + stand_top + r.height() * 0.12, + base_w, base_h), base_w * 0.15, base_w * 0.15) + return _make_icon(paint, color) + + def icon_cog(color: str = "#aaaaaa") -> QIcon: def paint(p, r, c): pen = QPen(c, r.width() * 0.1) diff --git a/main.py b/main.py index 9c99403..341693b 100644 --- a/main.py +++ b/main.py @@ -196,6 +196,7 @@ class RadioPanelWindow(QMainWindow): 'muted': ch.muted, 'solo': ch.solo, 'pfl': ch.pfl, + 'mic_on': ch.mic_on, }) os.makedirs(os.path.dirname(path), exist_ok=True) @@ -226,6 +227,7 @@ class RadioPanelWindow(QMainWindow): ch.muted = ch_state.get('muted', ch.muted) ch.solo = ch_state.get('solo', ch.solo) ch.pfl = ch_state.get('pfl', ch.pfl) + ch.mic_on = ch_state.get('mic_on', ch.mic_on) self.mixer.refresh_strips() diff --git a/mixer_widget.py b/mixer_widget.py index d56fc7c..81ff24a 100644 --- a/mixer_widget.py +++ b/mixer_widget.py @@ -9,7 +9,7 @@ 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 +from icons import icon_mute, icon_solo, icon_mic import math @@ -214,6 +214,28 @@ class ChannelStrip(QFrame): """) 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)) @@ -242,6 +264,7 @@ class ChannelStrip(QFrame): 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): @@ -266,9 +289,17 @@ class ChannelStrip(QFrame): 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)) + if self.channel_index < 2: + color = "#00cc66" if self._channel.mic_on else "#888" + self.mic_btn.setIcon(icon_mic(color)) class MasterPreSection(QFrame):