Add 8-channel mixer with master compressor/limiter, MIDI learn system, separate deck outputs

- Mixer: 8 renamable stereo input channels with VU meter, volume fader, mute/solo
- Master bus: compressor (threshold, ratio, attack, release, makeup) + limiter (ceiling), LUFS metering
- Decks/carts keep their own JACK outputs, completely separate from mixer
- MIDI learn: configurable bindings saved to ~/.gti_radiostudio/midi_map.json
- mixer_widget.py: QDial-based DSP controls, scrollable channel strips
This commit is contained in:
2026-05-12 19:29:16 +10:00
parent 680a9b5d51
commit d2240c61c3
5 changed files with 934 additions and 84 deletions
+177 -35
View File
@@ -13,6 +13,12 @@ LUFS_TARGET = -14.0
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
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",
]
def load_audio(filepath: str, target_sr: int) -> np.ndarray:
ext = os.path.splitext(filepath)[1].lower()
@@ -137,12 +143,99 @@ class DeckState:
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
class MixerChannel:
def __init__(self, name: str, index: int):
self.name = name
self.index = index
self.volume = 1.0
self.muted = False
self.solo = False
self.vu_peak = 0.0
self.in_port_L = None
self.in_port_R = None
self.out_port_L = None
self.out_port_R = None
def reset_vu(self):
self.vu_peak = 0.0
class Compressor:
def __init__(self, sr: int):
self.sr = sr
self.threshold_db = -20.0
self.ratio = 4.0
self.attack_ms = 5.0
self.release_ms = 100.0
self.makeup_gain_db = 0.0
self.bypassed = False
self._envelope = 0.0
self._attack_coeff = self._time_constant(self.attack_ms)
self._release_coeff = self._time_constant(self.release_ms)
def _time_constant(self, ms: float) -> float:
return np.exp(-1.0 / (ms * self.sr / 1000.0))
def set_attack(self, ms: float):
self.attack_ms = ms
self._attack_coeff = self._time_constant(ms)
def set_release(self, ms: float):
self.release_ms = ms
self._release_coeff = self._time_constant(ms)
def process(self, audio: np.ndarray):
if self.bypassed:
return
threshold_linear = 10 ** (self.threshold_db / 20.0)
makeup_linear = 10 ** (self.makeup_gain_db / 20.0)
for ch in range(audio.shape[0]):
for i in range(audio.shape[1]):
sample = audio[ch, i]
level = abs(sample)
if level > self._envelope:
self._envelope += (1 - self._attack_coeff) * (level - self._envelope)
else:
self._envelope += (1 - self._release_coeff) * (level - self._envelope)
if self._envelope > threshold_linear:
env_db = 20 * np.log10(max(self._envelope, 1e-10))
gain_db = (self.threshold_db - env_db) * (1 - 1.0 / self.ratio)
gain = 10 ** (gain_db / 20.0)
else:
gain = 1.0
audio[ch, i] = sample * gain * makeup_linear
class Limiter:
def __init__(self):
self.ceiling_db = -0.1
self.bypassed = False
def process(self, audio: np.ndarray):
if self.bypassed:
return
ceiling = 10 ** (self.ceiling_db / 20.0)
np.clip(audio, -ceiling, ceiling, out=audio)
class MasterBus:
def __init__(self, sr: int):
self.compressor = Compressor(sr)
self.limiter = Limiter()
self.vu_peak = 0.0
self.lufs_current = -70.0
def reset_vu(self):
self.vu_peak = 0.0
class AudioEngine:
def __init__(self):
self.client = jack.Client("RadioPanel")
self.sr = self.client.samplerate
# Output ports
# Output ports for decks (standalone, not mixed into master)
self.ports = {
'deck1': (
self.client.outports.register("deck1_L"),
@@ -158,20 +251,24 @@ class AudioEngine:
),
}
# LUFS meter input ports
self.lufs_inports = (
self.client.inports.register("master_L"),
self.client.inports.register("master_R"),
# Mixer channels: 8 stereo input ports
self.mixer = {
'channels': [],
'master': MasterBus(self.sr),
}
for i in range(NUM_MIXER_CHANNELS):
ch = MixerChannel(DEFAULT_CHANNEL_NAMES[i], i)
ch.in_port_L = self.client.inports.register(f"mixer_ch_{i+1:02d}_in_L")
ch.in_port_R = self.client.inports.register(f"mixer_ch_{i+1:02d}_in_R")
self.mixer['channels'].append(ch)
# Master output ports
self.master_out = (
self.client.outports.register("master_out_L"),
self.client.outports.register("master_out_R"),
)
# LUFS state (3 second short-term buffer)
self.lufs_buffer_seconds = 3.0
self.lufs_buffer_frames = int(self.lufs_buffer_seconds * self.sr)
self.lufs_buffer = np.zeros((2, self.lufs_buffer_frames), dtype=np.float32)
self.lufs_write_pos = 0
self.lufs_current = -70.0 # dB LUFS (very quiet default)
# Deck states
# Deck states (unchanged)
self.decks = {
'deck1': DeckState(),
'deck2': DeckState(),
@@ -184,6 +281,25 @@ class AudioEngine:
self.client.set_process_callback(self._process)
self.client.activate()
def get_channel(self, index: int) -> MixerChannel:
return self.mixer['channels'][index]
def set_channel_name(self, index: int, name: str):
if 0 <= index < NUM_MIXER_CHANNELS:
self.mixer['channels'][index].name = name
def set_channel_volume(self, index: int, volume: float):
if 0 <= index < NUM_MIXER_CHANNELS:
self.mixer['channels'][index].volume = max(0.0, min(1.0, volume))
def set_channel_mute(self, index: int, muted: bool):
if 0 <= index < NUM_MIXER_CHANNELS:
self.mixer['channels'][index].muted = muted
def set_channel_solo(self, index: int, solo: bool):
if 0 <= index < NUM_MIXER_CHANNELS:
self.mixer['channels'][index].solo = solo
def _fill_port(self, port_L, port_R, deck: DeckState, frames: int):
buf_L = port_L.get_array()
buf_R = port_R.get_array()
@@ -211,7 +327,7 @@ class AudioEngine:
deck.position = track.num_samples - 1
def _process(self, frames: int):
# Decks
# ── Decks (unchanged) ──────────────────────────────────────────
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
@@ -239,33 +355,59 @@ class AudioEngine:
deck.playing = False
deck.position = track.num_samples - 1
# LUFS metering
lufs_in_L = self.lufs_inports[0].get_array()
lufs_in_R = self.lufs_inports[1].get_array()
# ── Mixer ───────────────────────────────────────────────────────
master_buf_L = self.master_out[0].get_array()
master_buf_R = self.master_out[1].get_array()
master_buf_L.fill(0.0)
master_buf_R.fill(0.0)
# Write to ring buffer
any_solo = any(ch.solo for ch in self.mixer['channels'])
for ch in self.mixer['channels']:
in_L = ch.in_port_L.get_array()
in_R = ch.in_port_R.get_array()
mute = ch.muted or (any_solo and not ch.solo)
if mute:
ch.vu_peak = 0.0
continue
vol = ch.volume
peak = 0.0
for i in range(frames):
self.lufs_buffer[0, self.lufs_write_pos] = lufs_in_L[i]
self.lufs_buffer[1, self.lufs_write_pos] = lufs_in_R[i]
self.lufs_write_pos = (self.lufs_write_pos + 1) % self.lufs_buffer_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
peak = max(peak, abs(s_L), abs(s_R))
# Calculate LUFS every 512 frames (reduces CPU)
ch.vu_peak = peak
# ── Master DSP ──────────────────────────────────────────────────
master_audio = np.array([master_buf_L, master_buf_R])
self.mixer['master'].compressor.process(master_audio)
self.mixer['master'].limiter.process(master_audio)
# Copy processed audio back to port buffers
master_buf_L[:] = master_audio[0]
master_buf_R[:] = master_audio[1]
# Master VU
master_peak = max(np.max(np.abs(master_audio[0])), np.max(np.abs(master_audio[1])))
self.mixer['master'].vu_peak = master_peak
# LUFS calculation (every ~512 frames)
if frames % 512 < frames:
self._calculate_lufs()
self._calculate_lufs(master_audio)
def _calculate_lufs(self):
"""
Simplified LUFS calculation (BS.1770-4 compliant).
K-weighting filter + gating applied.
"""
# Mean square energy across 3 second window
mean_square = np.mean(self.lufs_buffer ** 2)
if mean_square < 1e-12: # Silence threshold
self.lufs_current = -70.0
def _calculate_lufs(self, audio: np.ndarray):
mean_square = np.mean(audio ** 2)
if mean_square < 1e-12:
self.mixer['master'].lufs_current = -70.0
else:
# Convert to dB (simplified - proper K-weighting would use filters)
self.lufs_current = -0.691 + 10 * np.log10(mean_square)
self.mixer['master'].lufs_current = -0.691 + 10 * np.log10(mean_square)
def load_track(self, deck_key: str, filepath: str):
def _load():
+56
View File
@@ -223,6 +223,62 @@ def icon_export(color: str = "#aaaaaa") -> QIcon:
return _make_icon(paint, color)
def icon_mute(color: str = "#ff4444") -> QIcon:
def paint(p, r, c):
pen = QPen(c, r.width() * 0.12)
pen.setCapStyle(Qt.RoundCap)
p.setPen(pen)
p.setBrush(Qt.NoBrush)
# Speaker body
cx = r.left() + r.width() * 0.3
p.drawLine(QPointF(cx, r.top() + r.height() * 0.25),
QPointF(cx, r.bottom() - r.height() * 0.25))
p.drawLine(QPointF(cx, r.top() + r.height() * 0.25),
QPointF(r.left() + r.width() * 0.5, r.center().y() - r.height() * 0.15))
p.drawLine(QPointF(cx, r.bottom() - r.height() * 0.25),
QPointF(r.left() + r.width() * 0.5, r.center().y() + r.height() * 0.15))
p.drawLine(QPointF(r.left() + r.width() * 0.2, r.center().y() - r.height() * 0.15),
QPointF(r.left() + r.width() * 0.5, r.center().y() - r.height() * 0.15))
p.drawLine(QPointF(r.left() + r.width() * 0.2, r.center().y() + r.height() * 0.15),
QPointF(r.left() + r.width() * 0.5, r.center().y() + r.height() * 0.15))
# X over speaker
x = r.right() - r.width() * 0.2
y1 = r.top() + r.height() * 0.2
y2 = r.bottom() - r.height() * 0.2
p.drawLine(QPointF(x, y1), QPointF(r.right() - r.width() * 0.05, y2))
p.drawLine(QPointF(r.right() - r.width() * 0.05, y1), QPointF(x, y2))
return _make_icon(paint, color)
def icon_solo(color: str = "#ffaa00") -> QIcon:
def paint(p, r, c):
p.setPen(Qt.NoPen)
p.setBrush(c)
# Circle with "S"
center = r.center()
radius = min(r.width(), r.height()) * 0.45
p.drawEllipse(center, radius, radius)
# S letter (white)
pen = QPen(QColor("#000000"), r.width() * 0.12)
pen.setCapStyle(Qt.RoundCap)
p.setPen(pen)
p.setBrush(Qt.NoBrush)
path = QPainterPath()
path.moveTo(r.left() + r.width() * 0.45, r.top() + r.height() * 0.3)
path.cubicTo(
r.left() + r.width() * 0.7, r.top() + r.height() * 0.15,
r.right() - r.width() * 0.25, r.top() + r.height() * 0.35,
r.left() + r.width() * 0.55, r.top() + r.height() * 0.5
)
path.cubicTo(
r.left() + r.width() * 0.3, r.top() + r.height() * 0.65,
r.right() - r.width() * 0.15, r.top() + r.height() * 0.7,
r.left() + r.width() * 0.45, r.bottom() - r.height() * 0.2
)
p.drawPath(path)
return _make_icon(paint, color)
def icon_theme(color: str = "#00ff41") -> QIcon:
def paint(p, r, c):
p.setPen(Qt.NoPen)
+25 -15
View File
@@ -15,7 +15,7 @@ from playlist_widget import PlaylistWidget
from midi_engine import MidiEngine
from top_bar_widget import TopBarWidget, THEMES
from history_widget import HistoryWidget
from lufs_meter_widget import LUFSMeterWidget
from mixer_widget import MixerWidget
class RadioPanelWindow(QMainWindow):
@@ -70,29 +70,30 @@ class RadioPanelWindow(QMainWindow):
self.history_widget = HistoryWidget()
self.lufs_meter = LUFSMeterWidget(self.engine)
self.mixer = MixerWidget(self.engine)
# ── Centre column ─────────────────────────────────────────────────
centre_col = QHBoxLayout()
# ── Centre column (decks + carts + mixer) ─────────────────────────
centre_col = QVBoxLayout()
centre_col.setSpacing(8)
# Decks column
deck_col = QVBoxLayout()
deck_col.setSpacing(8)
# Top: decks row + carts
deck_area = QWidget()
deck_area.setStyleSheet("background: transparent; border: none;")
deck_layout = QVBoxLayout(deck_area)
deck_layout.setContentsMargins(0, 0, 0, 0)
deck_layout.setSpacing(8)
deck_row = QHBoxLayout()
deck_row.setSpacing(8)
deck_row.addWidget(self.deck1)
deck_row.addWidget(self.deck2)
deck_col.addLayout(deck_row)
deck_layout.addLayout(deck_row)
deck_layout.addWidget(self.cart_bank)
deck_layout.addStretch()
deck_col.addWidget(self.cart_bank)
deck_col.addStretch()
centre_col.addLayout(deck_col)
# LUFS meter on right of centre section
centre_col.addWidget(self.lufs_meter)
# Bottom: mixer
centre_col.addWidget(deck_area, stretch=1)
centre_col.addWidget(self.mixer)
centre_widget = QWidget()
centre_widget.setStyleSheet("background: transparent; border: none;")
@@ -138,6 +139,7 @@ class RadioPanelWindow(QMainWindow):
self.deck2.apply_theme(theme)
self.cart_bank.apply_theme(theme)
self.history_widget.apply_theme(theme)
self.mixer.apply_theme(theme)
def _apply_global_style(self, theme: dict):
self.setStyleSheet(f"""
@@ -164,6 +166,8 @@ class RadioPanelWindow(QMainWindow):
self.midi.deck_cue.connect(self._on_midi_cue)
self.midi.deck_jog.connect(self._on_midi_jog)
self.midi.cart_trigger.connect(self._on_midi_cart)
self.midi.mixer_volume.connect(self._on_midi_mixer_volume)
self.midi.mixer_mute.connect(self._on_midi_mixer_mute)
self.midi.controller_connected.connect(self._on_midi_status)
self.midi.start()
@@ -189,6 +193,12 @@ class RadioPanelWindow(QMainWindow):
def _on_midi_cart(self, slot: int):
self.cart_bank.slots[slot].toggle()
def _on_midi_mixer_volume(self, channel: int, value: float):
self.engine.set_channel_volume(channel, value)
def _on_midi_mixer_mute(self, channel: int, muted: bool):
self.engine.set_channel_mute(channel, muted)
def _on_midi_status(self, connected: bool, name: str):
if connected:
print(f"[MIDI] Connected: {name}")
+123 -30
View File
@@ -1,10 +1,12 @@
# midi_engine.py
"""
MIDI Engine for Radio Panel
Numark DJ2Go controller support.
MIDI Engine for GTI Radio Studio.
Supports MIDI learn and configurable control mapping.
"""
import json
import logging
import os
import threading
from typing import Optional
@@ -13,34 +15,18 @@ from PySide6.QtCore import QObject, Signal
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────
# MIDI Constants
# ─────────────────────────────────────────────
NOTE_ON = 0x90
NOTE_OFF = 0x80
CC = 0xB0
DECK1_PLAY = 0x3B
DECK1_CUE = 0x33
DECK1_JOG = 0x19
DECK2_PLAY = 0x42
DECK2_CUE = 0x3C
DECK2_JOG = 0x18
CART_NOTES = {
0x44: 0,
0x43: 1,
0x46: 2,
0x45: 3,
}
CONFIG_DIR = os.path.expanduser("~/.gti_radiostudio")
CONFIG_FILE = os.path.join(CONFIG_DIR, "midi_map.json")
JOG_SCRUB_SECONDS = 5.0
JOG_DEAD_ZONE = 5 # Ignore movements below this threshold
JOG_DEAD_ZONE = 5
def _decode_relative_jog(value: int) -> int:
"""Returns movement amount, or 0 if within dead zone."""
if 1 <= value <= 63:
return value if value >= JOG_DEAD_ZONE else 0
elif 65 <= value <= 127:
@@ -49,12 +35,61 @@ def _decode_relative_jog(value: int) -> int:
return 0
class MidiMapping:
def __init__(self):
self.bindings = {}
def add(self, action: str, target_id, midi_type: int, midi_channel: int,
midi_note: int, inverse: bool = False):
key = (midi_type, midi_channel, midi_note)
self.bindings[key] = {
'action': action,
'target_id': target_id,
'inverse': inverse,
}
def lookup(self, midi_type: int, midi_channel: int,
midi_note: int) -> Optional[dict]:
return self.bindings.get((midi_type, midi_channel, midi_note))
def save(self, filepath: str = CONFIG_FILE):
os.makedirs(os.path.dirname(filepath), exist_ok=True)
data = []
for key, val in self.bindings.items():
data.append({
'midi_type': key[0],
'midi_channel': key[1],
'midi_note': key[2],
'action': val['action'],
'target_id': val['target_id'],
'inverse': val['inverse'],
})
with open(filepath, 'w') as f:
json.dump(data, f, indent=2)
def load(self, filepath: str = CONFIG_FILE):
if not os.path.exists(filepath):
return
with open(filepath) as f:
data = json.load(f)
for entry in data:
key = (entry['midi_type'], entry['midi_channel'], entry['midi_note'])
self.bindings[key] = {
'action': entry['action'],
'target_id': entry['target_id'],
'inverse': entry.get('inverse', False),
}
class MidiEngine(QObject):
deck_play_pause = Signal(str)
deck_cue = Signal(str)
deck_jog = Signal(str, float)
cart_trigger = Signal(int)
mixer_volume = Signal(int, float)
mixer_mute = Signal(int, bool)
controller_connected = Signal(bool, str)
mapping_learned = Signal(str, object, int, int, int, bool)
def __init__(self, parent=None):
super().__init__(parent)
@@ -62,6 +97,30 @@ class MidiEngine(QObject):
self._port_name: str = ""
self._running = False
self.mapping = MidiMapping()
self.mapping.load()
self._learn_target = None
self._learn_mode = False
self._last_cc_values = {}
@property
def learn_mode(self) -> bool:
return self._learn_mode
def set_learn_target(self, target: Optional[tuple]):
self._learn_target = target
def start_learn(self, action: str, target_id):
self._learn_mode = True
self._learn_target = (action, target_id)
logger.info(f"MIDI learn: waiting for {action} ({target_id})...")
def stop_learn(self):
self._learn_mode = False
self._learn_target = None
def start(self, port_index: Optional[int] = None) -> bool:
try:
self._midi_in = rtmidi.MidiIn()
@@ -136,33 +195,67 @@ class MidiEngine(QObject):
value = message[2] if len(message) > 2 else 0
status_type = status & 0xF0
channel = status & 0x0F
# Learn mode: capture any MIDI event
if self._learn_mode and self._learn_target:
action, target_id = self._learn_target
mapping_type = status_type
self.mapping.add(action, target_id, mapping_type, channel, data_byte)
self.mapping.save()
self.mapping_learned.emit(action, target_id, mapping_type, channel, data_byte, value > 0)
self.stop_learn()
return
if status_type == NOTE_ON and value > 0:
self._handle_note_on(data_byte)
self._handle_learned_event(status_type, channel, data_byte, value)
elif status_type == NOTE_OFF or (status_type == NOTE_ON and value == 0):
pass
elif status_type == CC:
self._handle_cc(data_byte, value)
self._handle_learned_event(status_type, channel, data_byte, value)
def _handle_learned_event(self, midi_type: int, channel: int,
note: int, value: int):
binding = self.mapping.lookup(midi_type, channel, note)
if binding is None:
return
action = binding['action']
target_id = binding['target_id']
inv = binding.get('inverse', False)
if action == 'mixer_volume':
scaled = (127 - value if inv else value) / 127.0
self.mixer_volume.emit(target_id, scaled)
elif action == 'mixer_mute':
muted = (value < 64) if inv else (value > 63)
self.mixer_mute.emit(target_id, muted)
def _handle_note_on(self, note: int):
if note == DECK1_PLAY:
# Hardcoded Numark DJ2Go mapping (legacy)
NUMARK_CART = {0x44: 0, 0x43: 1, 0x46: 2, 0x45: 3}
if note == 0x3B:
self.deck_play_pause.emit('deck1')
elif note == DECK1_CUE:
elif note == 0x33:
self.deck_cue.emit('deck1')
elif note == DECK2_PLAY:
elif note == 0x42:
self.deck_play_pause.emit('deck2')
elif note == DECK2_CUE:
elif note == 0x3C:
self.deck_cue.emit('deck2')
elif note in CART_NOTES:
self.cart_trigger.emit(CART_NOTES[note])
elif note in NUMARK_CART:
self.cart_trigger.emit(NUMARK_CART[note])
def _handle_cc(self, cc_num: int, value: int):
movement = _decode_relative_jog(value)
if movement == 0:
return
# Scale movement to seconds
delta = (movement / 10.0) * JOG_SCRUB_SECONDS
if cc_num == DECK1_JOG:
if cc_num == 0x19:
self.deck_jog.emit('deck1', delta)
elif cc_num == DECK2_JOG:
elif cc_num == 0x18:
self.deck_jog.emit('deck2', delta)
+549
View File
@@ -0,0 +1,549 @@
# 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)