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:
+179
-37
@@ -13,6 +13,12 @@ LUFS_TARGET = -14.0
|
|||||||
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
|
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
|
||||||
FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
|
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:
|
def load_audio(filepath: str, target_sr: int) -> np.ndarray:
|
||||||
ext = os.path.splitext(filepath)[1].lower()
|
ext = os.path.splitext(filepath)[1].lower()
|
||||||
@@ -137,12 +143,99 @@ class DeckState:
|
|||||||
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
|
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:
|
class AudioEngine:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.client = jack.Client("RadioPanel")
|
self.client = jack.Client("RadioPanel")
|
||||||
self.sr = self.client.samplerate
|
self.sr = self.client.samplerate
|
||||||
|
|
||||||
# Output ports
|
# Output ports for decks (standalone, not mixed into master)
|
||||||
self.ports = {
|
self.ports = {
|
||||||
'deck1': (
|
'deck1': (
|
||||||
self.client.outports.register("deck1_L"),
|
self.client.outports.register("deck1_L"),
|
||||||
@@ -158,20 +251,24 @@ class AudioEngine:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# LUFS meter input ports
|
# Mixer channels: 8 stereo input ports
|
||||||
self.lufs_inports = (
|
self.mixer = {
|
||||||
self.client.inports.register("master_L"),
|
'channels': [],
|
||||||
self.client.inports.register("master_R"),
|
'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)
|
# Deck states (unchanged)
|
||||||
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
|
|
||||||
self.decks = {
|
self.decks = {
|
||||||
'deck1': DeckState(),
|
'deck1': DeckState(),
|
||||||
'deck2': DeckState(),
|
'deck2': DeckState(),
|
||||||
@@ -184,6 +281,25 @@ class AudioEngine:
|
|||||||
self.client.set_process_callback(self._process)
|
self.client.set_process_callback(self._process)
|
||||||
self.client.activate()
|
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):
|
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()
|
||||||
@@ -211,7 +327,7 @@ class AudioEngine:
|
|||||||
deck.position = track.num_samples - 1
|
deck.position = track.num_samples - 1
|
||||||
|
|
||||||
def _process(self, frames: int):
|
def _process(self, frames: int):
|
||||||
# Decks
|
# ── Decks (unchanged) ──────────────────────────────────────────
|
||||||
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
|
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
|
||||||
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
|
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
|
||||||
|
|
||||||
@@ -239,33 +355,59 @@ class AudioEngine:
|
|||||||
deck.playing = False
|
deck.playing = False
|
||||||
deck.position = track.num_samples - 1
|
deck.position = track.num_samples - 1
|
||||||
|
|
||||||
# LUFS metering
|
# ── Mixer ───────────────────────────────────────────────────────
|
||||||
lufs_in_L = self.lufs_inports[0].get_array()
|
master_buf_L = self.master_out[0].get_array()
|
||||||
lufs_in_R = self.lufs_inports[1].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 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
|
|
||||||
|
|
||||||
# Calculate LUFS every 512 frames (reduces CPU)
|
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):
|
||||||
|
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))
|
||||||
|
|
||||||
|
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:
|
if frames % 512 < frames:
|
||||||
self._calculate_lufs()
|
self._calculate_lufs(master_audio)
|
||||||
|
|
||||||
def _calculate_lufs(self):
|
def _calculate_lufs(self, audio: np.ndarray):
|
||||||
"""
|
mean_square = np.mean(audio ** 2)
|
||||||
Simplified LUFS calculation (BS.1770-4 compliant).
|
if mean_square < 1e-12:
|
||||||
K-weighting filter + gating applied.
|
self.mixer['master'].lufs_current = -70.0
|
||||||
"""
|
|
||||||
# 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
|
|
||||||
else:
|
else:
|
||||||
# Convert to dB (simplified - proper K-weighting would use filters)
|
self.mixer['master'].lufs_current = -0.691 + 10 * np.log10(mean_square)
|
||||||
self.lufs_current = -0.691 + 10 * np.log10(mean_square)
|
|
||||||
|
|
||||||
def load_track(self, deck_key: str, filepath: str):
|
def load_track(self, deck_key: str, filepath: str):
|
||||||
def _load():
|
def _load():
|
||||||
@@ -291,4 +433,4 @@ class AudioEngine:
|
|||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.client.deactivate()
|
self.client.deactivate()
|
||||||
self.client.close()
|
self.client.close()
|
||||||
|
|||||||
@@ -223,6 +223,62 @@ def icon_export(color: str = "#aaaaaa") -> QIcon:
|
|||||||
return _make_icon(paint, color)
|
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 icon_theme(color: str = "#00ff41") -> QIcon:
|
||||||
def paint(p, r, c):
|
def paint(p, r, c):
|
||||||
p.setPen(Qt.NoPen)
|
p.setPen(Qt.NoPen)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from playlist_widget import PlaylistWidget
|
|||||||
from midi_engine import MidiEngine
|
from midi_engine import MidiEngine
|
||||||
from top_bar_widget import TopBarWidget, THEMES
|
from top_bar_widget import TopBarWidget, THEMES
|
||||||
from history_widget import HistoryWidget
|
from history_widget import HistoryWidget
|
||||||
from lufs_meter_widget import LUFSMeterWidget
|
from mixer_widget import MixerWidget
|
||||||
|
|
||||||
|
|
||||||
class RadioPanelWindow(QMainWindow):
|
class RadioPanelWindow(QMainWindow):
|
||||||
@@ -70,29 +70,30 @@ class RadioPanelWindow(QMainWindow):
|
|||||||
|
|
||||||
self.history_widget = HistoryWidget()
|
self.history_widget = HistoryWidget()
|
||||||
|
|
||||||
self.lufs_meter = LUFSMeterWidget(self.engine)
|
self.mixer = MixerWidget(self.engine)
|
||||||
|
|
||||||
# ── Centre column ─────────────────────────────────────────────────
|
# ── Centre column (decks + carts + mixer) ─────────────────────────
|
||||||
centre_col = QHBoxLayout()
|
centre_col = QVBoxLayout()
|
||||||
centre_col.setSpacing(8)
|
centre_col.setSpacing(8)
|
||||||
|
|
||||||
# Decks column
|
# Top: decks row + carts
|
||||||
deck_col = QVBoxLayout()
|
deck_area = QWidget()
|
||||||
deck_col.setSpacing(8)
|
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 = QHBoxLayout()
|
||||||
deck_row.setSpacing(8)
|
deck_row.setSpacing(8)
|
||||||
deck_row.addWidget(self.deck1)
|
deck_row.addWidget(self.deck1)
|
||||||
deck_row.addWidget(self.deck2)
|
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)
|
# Bottom: mixer
|
||||||
deck_col.addStretch()
|
centre_col.addWidget(deck_area, stretch=1)
|
||||||
|
centre_col.addWidget(self.mixer)
|
||||||
centre_col.addLayout(deck_col)
|
|
||||||
|
|
||||||
# LUFS meter on right of centre section
|
|
||||||
centre_col.addWidget(self.lufs_meter)
|
|
||||||
|
|
||||||
centre_widget = QWidget()
|
centre_widget = QWidget()
|
||||||
centre_widget.setStyleSheet("background: transparent; border: none;")
|
centre_widget.setStyleSheet("background: transparent; border: none;")
|
||||||
@@ -138,6 +139,7 @@ class RadioPanelWindow(QMainWindow):
|
|||||||
self.deck2.apply_theme(theme)
|
self.deck2.apply_theme(theme)
|
||||||
self.cart_bank.apply_theme(theme)
|
self.cart_bank.apply_theme(theme)
|
||||||
self.history_widget.apply_theme(theme)
|
self.history_widget.apply_theme(theme)
|
||||||
|
self.mixer.apply_theme(theme)
|
||||||
|
|
||||||
def _apply_global_style(self, theme: dict):
|
def _apply_global_style(self, theme: dict):
|
||||||
self.setStyleSheet(f"""
|
self.setStyleSheet(f"""
|
||||||
@@ -164,6 +166,8 @@ class RadioPanelWindow(QMainWindow):
|
|||||||
self.midi.deck_cue.connect(self._on_midi_cue)
|
self.midi.deck_cue.connect(self._on_midi_cue)
|
||||||
self.midi.deck_jog.connect(self._on_midi_jog)
|
self.midi.deck_jog.connect(self._on_midi_jog)
|
||||||
self.midi.cart_trigger.connect(self._on_midi_cart)
|
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.controller_connected.connect(self._on_midi_status)
|
||||||
|
|
||||||
self.midi.start()
|
self.midi.start()
|
||||||
@@ -189,6 +193,12 @@ class RadioPanelWindow(QMainWindow):
|
|||||||
def _on_midi_cart(self, slot: int):
|
def _on_midi_cart(self, slot: int):
|
||||||
self.cart_bank.slots[slot].toggle()
|
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):
|
def _on_midi_status(self, connected: bool, name: str):
|
||||||
if connected:
|
if connected:
|
||||||
print(f"[MIDI] Connected: {name}")
|
print(f"[MIDI] Connected: {name}")
|
||||||
@@ -225,4 +235,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+124
-31
@@ -1,10 +1,12 @@
|
|||||||
# midi_engine.py
|
# midi_engine.py
|
||||||
"""
|
"""
|
||||||
MIDI Engine for Radio Panel
|
MIDI Engine for GTI Radio Studio.
|
||||||
Numark DJ2Go controller support.
|
Supports MIDI learn and configurable control mapping.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -13,34 +15,18 @@ from PySide6.QtCore import QObject, Signal
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# MIDI Constants
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
NOTE_ON = 0x90
|
NOTE_ON = 0x90
|
||||||
NOTE_OFF = 0x80
|
NOTE_OFF = 0x80
|
||||||
CC = 0xB0
|
CC = 0xB0
|
||||||
|
|
||||||
DECK1_PLAY = 0x3B
|
CONFIG_DIR = os.path.expanduser("~/.gti_radiostudio")
|
||||||
DECK1_CUE = 0x33
|
CONFIG_FILE = os.path.join(CONFIG_DIR, "midi_map.json")
|
||||||
DECK1_JOG = 0x19
|
|
||||||
|
|
||||||
DECK2_PLAY = 0x42
|
|
||||||
DECK2_CUE = 0x3C
|
|
||||||
DECK2_JOG = 0x18
|
|
||||||
|
|
||||||
CART_NOTES = {
|
|
||||||
0x44: 0,
|
|
||||||
0x43: 1,
|
|
||||||
0x46: 2,
|
|
||||||
0x45: 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
JOG_SCRUB_SECONDS = 5.0
|
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:
|
def _decode_relative_jog(value: int) -> int:
|
||||||
"""Returns movement amount, or 0 if within dead zone."""
|
|
||||||
if 1 <= value <= 63:
|
if 1 <= value <= 63:
|
||||||
return value if value >= JOG_DEAD_ZONE else 0
|
return value if value >= JOG_DEAD_ZONE else 0
|
||||||
elif 65 <= value <= 127:
|
elif 65 <= value <= 127:
|
||||||
@@ -49,12 +35,61 @@ def _decode_relative_jog(value: int) -> int:
|
|||||||
return 0
|
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):
|
class MidiEngine(QObject):
|
||||||
deck_play_pause = Signal(str)
|
deck_play_pause = Signal(str)
|
||||||
deck_cue = Signal(str)
|
deck_cue = Signal(str)
|
||||||
deck_jog = Signal(str, float)
|
deck_jog = Signal(str, float)
|
||||||
cart_trigger = Signal(int)
|
cart_trigger = Signal(int)
|
||||||
|
mixer_volume = Signal(int, float)
|
||||||
|
mixer_mute = Signal(int, bool)
|
||||||
controller_connected = Signal(bool, str)
|
controller_connected = Signal(bool, str)
|
||||||
|
mapping_learned = Signal(str, object, int, int, int, bool)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -62,6 +97,30 @@ class MidiEngine(QObject):
|
|||||||
self._port_name: str = ""
|
self._port_name: str = ""
|
||||||
self._running = False
|
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:
|
def start(self, port_index: Optional[int] = None) -> bool:
|
||||||
try:
|
try:
|
||||||
self._midi_in = rtmidi.MidiIn()
|
self._midi_in = rtmidi.MidiIn()
|
||||||
@@ -136,33 +195,67 @@ class MidiEngine(QObject):
|
|||||||
value = message[2] if len(message) > 2 else 0
|
value = message[2] if len(message) > 2 else 0
|
||||||
|
|
||||||
status_type = status & 0xF0
|
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:
|
if status_type == NOTE_ON and value > 0:
|
||||||
self._handle_note_on(data_byte)
|
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:
|
elif status_type == CC:
|
||||||
self._handle_cc(data_byte, value)
|
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):
|
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')
|
self.deck_play_pause.emit('deck1')
|
||||||
elif note == DECK1_CUE:
|
elif note == 0x33:
|
||||||
self.deck_cue.emit('deck1')
|
self.deck_cue.emit('deck1')
|
||||||
elif note == DECK2_PLAY:
|
elif note == 0x42:
|
||||||
self.deck_play_pause.emit('deck2')
|
self.deck_play_pause.emit('deck2')
|
||||||
elif note == DECK2_CUE:
|
elif note == 0x3C:
|
||||||
self.deck_cue.emit('deck2')
|
self.deck_cue.emit('deck2')
|
||||||
elif note in CART_NOTES:
|
elif note in NUMARK_CART:
|
||||||
self.cart_trigger.emit(CART_NOTES[note])
|
self.cart_trigger.emit(NUMARK_CART[note])
|
||||||
|
|
||||||
def _handle_cc(self, cc_num: int, value: int):
|
def _handle_cc(self, cc_num: int, value: int):
|
||||||
movement = _decode_relative_jog(value)
|
movement = _decode_relative_jog(value)
|
||||||
if movement == 0:
|
if movement == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Scale movement to seconds
|
|
||||||
delta = (movement / 10.0) * JOG_SCRUB_SECONDS
|
delta = (movement / 10.0) * JOG_SCRUB_SECONDS
|
||||||
|
|
||||||
if cc_num == DECK1_JOG:
|
if cc_num == 0x19:
|
||||||
self.deck_jog.emit('deck1', delta)
|
self.deck_jog.emit('deck1', delta)
|
||||||
elif cc_num == DECK2_JOG:
|
elif cc_num == 0x18:
|
||||||
self.deck_jog.emit('deck2', delta)
|
self.deck_jog.emit('deck2', delta)
|
||||||
|
|||||||
+549
@@ -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)
|
||||||
Reference in New Issue
Block a user