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
This commit is contained in:
2026-05-15 00:36:48 +10:00
parent 89fb7010fb
commit 9e4315886d
4 changed files with 135 additions and 5 deletions
+65 -2
View File
@@ -15,8 +15,8 @@ FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
NUM_MIXER_CHANNELS = 8 NUM_MIXER_CHANNELS = 8
DEFAULT_CHANNEL_NAMES = [ DEFAULT_CHANNEL_NAMES = [
"Turntable 1", "Turntable 2", "Mic", "Aux 1", "Mic 1", "Mic 2", "Aux 1",
"Aux 2", "Aux 3", "Aux 4", "Aux 5", "Aux 2", "Aux 3", "Aux 4", "Aux 5", "Aux 6",
] ]
@@ -163,6 +163,7 @@ class MixerChannel:
self.muted = False self.muted = False
self.solo = False self.solo = False
self.pfl = False self.pfl = False
self.mic_on = False
self.vu_peak = 0.0 self.vu_peak = 0.0
self.in_port_L = None self.in_port_L = None
self.in_port_R = None self.in_port_R = None
@@ -285,6 +286,18 @@ class AudioEngine:
self.client.outports.register("master_out_R"), 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 # PFL output ports
self.pfl_out = ( self.pfl_out = (
self.client.outports.register("pfl_out_L"), self.client.outports.register("pfl_out_L"),
@@ -325,6 +338,15 @@ class AudioEngine:
if 0 <= index < NUM_MIXER_CHANNELS: if 0 <= index < NUM_MIXER_CHANNELS:
self.mixer['channels'][index].pfl = pfl 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): def _fill_port(self, port_L, port_R, deck: DeckState, frames: int):
buf_L = port_L.get_array() buf_L = port_L.get_array()
buf_R = port_R.get_array() buf_R = port_R.get_array()
@@ -423,7 +445,18 @@ class AudioEngine:
master_buf_L.fill(0.0) master_buf_L.fill(0.0)
master_buf_R.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_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 bus
pfl_buf_L = self.pfl_out[0].get_array() pfl_buf_L = self.pfl_out[0].get_array()
@@ -436,6 +469,7 @@ class AudioEngine:
in_R = ch.in_port_R.get_array() in_R = ch.in_port_R.get_array()
mute = ch.muted or (any_solo and not ch.solo) mute = ch.muted or (any_solo and not ch.solo)
is_mic = ch.index < 2
# PFL: pre-fader, pre-mute signal to PFL bus # PFL: pre-fader, pre-mute signal to PFL bus
if ch.pfl: if ch.pfl:
@@ -447,16 +481,31 @@ class AudioEngine:
continue continue
vol = ch.volume vol = ch.volume
goes_to_main = not is_mic or ch.mic_on
goes_to_headphone = not is_mic
peak = 0.0 peak = 0.0
for i in range(frames): for i in range(frames):
s_L = in_L[i] * vol s_L = in_L[i] * vol
s_R = in_R[i] * vol s_R = in_R[i] * vol
if goes_to_main:
master_buf_L[i] += s_L master_buf_L[i] += s_L
master_buf_R[i] += s_R 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)) peak = max(peak, abs(s_L), abs(s_R))
ch.vu_peak = peak 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 DSP ──────────────────────────────────────────────────
master_audio = np.array([master_buf_L, master_buf_R]) master_audio = np.array([master_buf_L, master_buf_R])
@@ -466,6 +515,20 @@ class AudioEngine:
master_buf_L[:] = master_audio[0] master_buf_L[:] = master_audio[0]
master_buf_R[:] = master_audio[1] 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]))) master_peak = max(np.max(np.abs(master_audio[0])), np.max(np.abs(master_audio[1])))
self.mixer['master'].vu_peak = master_peak self.mixer['master'].vu_peak = master_peak
+34
View File
@@ -279,6 +279,40 @@ def icon_solo(color: str = "#ffaa00") -> QIcon:
return _make_icon(paint, color) 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 icon_cog(color: str = "#aaaaaa") -> QIcon:
def paint(p, r, c): def paint(p, r, c):
pen = QPen(c, r.width() * 0.1) pen = QPen(c, r.width() * 0.1)
+2
View File
@@ -196,6 +196,7 @@ class RadioPanelWindow(QMainWindow):
'muted': ch.muted, 'muted': ch.muted,
'solo': ch.solo, 'solo': ch.solo,
'pfl': ch.pfl, 'pfl': ch.pfl,
'mic_on': ch.mic_on,
}) })
os.makedirs(os.path.dirname(path), exist_ok=True) 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.muted = ch_state.get('muted', ch.muted)
ch.solo = ch_state.get('solo', ch.solo) ch.solo = ch_state.get('solo', ch.solo)
ch.pfl = ch_state.get('pfl', ch.pfl) ch.pfl = ch_state.get('pfl', ch.pfl)
ch.mic_on = ch_state.get('mic_on', ch.mic_on)
self.mixer.refresh_strips() self.mixer.refresh_strips()
+32 -1
View File
@@ -9,7 +9,7 @@ from PySide6.QtCore import Qt, QTimer, QSize
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen
from audio_engine import NUM_MIXER_CHANNELS 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 import math
@@ -214,6 +214,28 @@ class ChannelStrip(QFrame):
""") """)
btn_row.addWidget(self.solo_btn) 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 = QPushButton("PFL")
self.pfl_btn.setFixedSize(28, 24) self.pfl_btn.setFixedSize(28, 24)
self.pfl_btn.setFont(QFont("Segoe UI", 7, QFont.Bold)) 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.volume_slider.valueChanged.connect(self._on_volume_changed)
self.mute_btn.toggled.connect(self._on_mute_toggled) self.mute_btn.toggled.connect(self._on_mute_toggled)
self.solo_btn.toggled.connect(self._on_solo_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) self.pfl_btn.toggled.connect(self._on_pfl_toggled)
def _rename(self): def _rename(self):
@@ -266,9 +289,17 @@ class ChannelStrip(QFrame):
def _on_pfl_toggled(self, checked: bool): def _on_pfl_toggled(self, checked: bool):
self.engine.set_channel_pfl(self.channel_index, checked) 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): def _refresh(self):
self.vu_meter.set_value(self._channel.vu_peak) self.vu_meter.set_value(self._channel.vu_peak)
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100)) 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): class MasterPreSection(QFrame):