Strip compressor/limiter DSP, add per-channel pre-fader gain, PFL/HP blend, LUFS metering from external port, MIDI gain binding
This commit is contained in:
+50
-135
@@ -147,19 +147,12 @@ class DeckState:
|
||||
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
|
||||
|
||||
|
||||
class ExternalInputMonitor:
|
||||
def __init__(self, buffer_size=600):
|
||||
self.buffer = np.zeros(buffer_size, dtype=np.float32)
|
||||
self.write_pos = 0
|
||||
self.buffer_size = buffer_size
|
||||
self.monitoring = False
|
||||
|
||||
|
||||
class MixerChannel:
|
||||
def __init__(self, name: str, index: int):
|
||||
self.name = name
|
||||
self.index = index
|
||||
self.volume = 1.0
|
||||
self.gain = 1.0 # pre-fader gain trim
|
||||
self.muted = False
|
||||
self.solo = False
|
||||
self.pfl = False
|
||||
@@ -169,71 +162,12 @@ class MixerChannel:
|
||||
self.in_port_R = None
|
||||
|
||||
|
||||
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
|
||||
self.volume = 1.0
|
||||
self.pfl_hp_blend = 0.5 # 0.0 = PFL only, 0.5 = equal, 1.0 = HP only
|
||||
self._lufs_accum = 0
|
||||
|
||||
|
||||
@@ -258,16 +192,9 @@ class AudioEngine:
|
||||
),
|
||||
}
|
||||
|
||||
# External input monitors (deck3, deck4 — pass-through with waveform)
|
||||
self.ext_monitors = {}
|
||||
self.ext_ports = {}
|
||||
for ext_key, label in [('deck3', 'deck3'), ('deck4', 'deck4')]:
|
||||
in_L = self.client.inports.register(f"{label}_in_L")
|
||||
in_R = self.client.inports.register(f"{label}_in_R")
|
||||
out_L = self.client.outports.register(f"{label}_out_L")
|
||||
out_R = self.client.outports.register(f"{label}_out_R")
|
||||
self.ext_ports[ext_key] = (in_L, in_R, out_L, out_R)
|
||||
self.ext_monitors[ext_key] = ExternalInputMonitor()
|
||||
# LUFS metering input (external processed signal)
|
||||
self.lufs_in_L = self.client.inports.register("lufs_in_L")
|
||||
self.lufs_in_R = self.client.inports.register("lufs_in_R")
|
||||
|
||||
# Mixer channels: 8 stereo input ports
|
||||
self.mixer = {
|
||||
@@ -326,6 +253,14 @@ class AudioEngine:
|
||||
if 0 <= index < NUM_MIXER_CHANNELS:
|
||||
self.mixer['channels'][index].volume = max(0.0, min(1.0, volume))
|
||||
|
||||
def set_channel_gain(self, index: int, gain: float):
|
||||
if 0 <= index < NUM_MIXER_CHANNELS:
|
||||
self.mixer['channels'][index].gain = max(0.0, min(2.0, gain))
|
||||
|
||||
def set_channel_gain(self, index: int, gain: float):
|
||||
if 0 <= index < NUM_MIXER_CHANNELS:
|
||||
self.mixer['channels'][index].gain = max(0.0, min(2.0, gain))
|
||||
|
||||
def set_channel_mute(self, index: int, muted: bool):
|
||||
if 0 <= index < NUM_MIXER_CHANNELS:
|
||||
self.mixer['channels'][index].muted = muted
|
||||
@@ -411,33 +346,8 @@ class AudioEngine:
|
||||
deck.playing = False
|
||||
deck.position = track.num_samples - 1
|
||||
|
||||
# ── External input monitors (deck3, deck4) ─────────────────────
|
||||
gate_linear = 10 ** (-46.0 / 20.0)
|
||||
for ext_key in ('deck3', 'deck4'):
|
||||
in_L, in_R, out_L, out_R = self.ext_ports[ext_key]
|
||||
in_buf_L = in_L.get_array()
|
||||
in_buf_R = in_R.get_array()
|
||||
out_buf_L = out_L.get_array()
|
||||
out_buf_R = out_R.get_array()
|
||||
|
||||
out_buf_L[:] = in_buf_L[:]
|
||||
out_buf_R[:] = in_buf_R[:]
|
||||
|
||||
monitor = self.ext_monitors[ext_key]
|
||||
if not monitor.monitoring:
|
||||
monitor.buffer[:] = 0.0
|
||||
continue
|
||||
|
||||
rms = np.sqrt(np.mean((in_buf_L ** 2 + in_buf_R ** 2) / 2.0))
|
||||
if rms > gate_linear:
|
||||
db = 20 * np.log10(max(rms, 1e-10))
|
||||
display = max(0.0, (db + 46.0) / 46.0)
|
||||
display = min(1.0, display)
|
||||
else:
|
||||
display = 0.0
|
||||
|
||||
monitor.buffer[monitor.write_pos] = display
|
||||
monitor.write_pos = (monitor.write_pos + 1) % monitor.buffer_size
|
||||
# ── LUFS metering from external processing input ────────────────
|
||||
lufs_audio = np.array([self.lufs_in_L.get_array(), self.lufs_in_R.get_array()])
|
||||
|
||||
# ── Mixer ───────────────────────────────────────────────────────
|
||||
master_buf_L = self.master_out[0].get_array()
|
||||
@@ -470,11 +380,12 @@ class AudioEngine:
|
||||
|
||||
mute = ch.muted or (any_solo and not ch.solo)
|
||||
is_mic = ch.index < 2
|
||||
g = ch.gain
|
||||
|
||||
# PFL: pre-fader, pre-mute signal to PFL bus
|
||||
# PFL: pre-fader, pre-mute signal to PFL bus (with gain applied)
|
||||
if ch.pfl:
|
||||
pfl_buf_L[:] += in_L[:]
|
||||
pfl_buf_R[:] += in_R[:]
|
||||
pfl_buf_L[:] += in_L[:] * g
|
||||
pfl_buf_R[:] += in_R[:] * g
|
||||
|
||||
if mute:
|
||||
ch.vu_peak = 0.0
|
||||
@@ -486,8 +397,8 @@ class AudioEngine:
|
||||
|
||||
peak = 0.0
|
||||
for i in range(frames):
|
||||
s_L = in_L[i] * vol
|
||||
s_R = in_R[i] * vol
|
||||
s_L = in_L[i] * g * vol
|
||||
s_R = in_R[i] * g * vol
|
||||
if goes_to_main:
|
||||
master_buf_L[i] += s_L
|
||||
master_buf_R[i] += s_R
|
||||
@@ -506,34 +417,38 @@ class AudioEngine:
|
||||
studio_buf_L[:] = master_buf_L[:]
|
||||
studio_buf_R[:] = master_buf_R[:]
|
||||
|
||||
# ── Master DSP ──────────────────────────────────────────────────
|
||||
master_audio = np.array([master_buf_L, master_buf_R])
|
||||
# ── PFL / Headphone blend ───────────────────────────────────────
|
||||
blend = self.mixer['master'].pfl_hp_blend
|
||||
hp_gain = blend # 0..1 how much normal HP mix
|
||||
pfl_gain = 1.0 - blend # 0..1 how much PFL
|
||||
any_pfl = any(ch.pfl for ch in self.mixer['channels'])
|
||||
if any_pfl and pfl_gain > 0.0:
|
||||
headphone_buf_L[:] = headphone_buf_L * hp_gain + pfl_buf_L * pfl_gain
|
||||
headphone_buf_R[:] = headphone_buf_R * hp_gain + pfl_buf_R * pfl_gain
|
||||
elif any_pfl and hp_gain == 0.0:
|
||||
headphone_buf_L[:] = pfl_buf_L[:]
|
||||
headphone_buf_R[:] = pfl_buf_R[:]
|
||||
# else: no PFL active, headphone stays as-is
|
||||
|
||||
self.mixer['master'].compressor.process(master_audio)
|
||||
self.mixer['master'].limiter.process(master_audio)
|
||||
# ── PFL / Headphone blend ───────────────────────────────────────
|
||||
blend = self.mixer['master'].pfl_hp_blend
|
||||
hp_gain = blend # 0..1 how much normal HP mix
|
||||
pfl_gain = 1.0 - blend # 0..1 how much PFL
|
||||
any_pfl = any(ch.pfl for ch in self.mixer['channels'])
|
||||
if any_pfl and pfl_gain > 0.0:
|
||||
headphone_buf_L[:] = headphone_buf_L * hp_gain + pfl_buf_L * pfl_gain
|
||||
headphone_buf_R[:] = headphone_buf_R * hp_gain + pfl_buf_R * pfl_gain
|
||||
elif any_pfl and hp_gain == 0.0:
|
||||
headphone_buf_L[:] = pfl_buf_L[:]
|
||||
headphone_buf_R[:] = pfl_buf_R[:]
|
||||
# else: no PFL active, headphone stays as-is
|
||||
|
||||
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])))
|
||||
# ── Master VU and LUFS ─────────────────────────────────────────
|
||||
master_peak = max(
|
||||
np.max(np.abs(master_buf_L)), np.max(np.abs(master_buf_R))
|
||||
)
|
||||
self.mixer['master'].vu_peak = master_peak
|
||||
|
||||
# Apply master volume fader (master bus only – headphone and studio
|
||||
# maintain independent levels)
|
||||
vol = self.mixer['master'].volume
|
||||
if vol < 1.0:
|
||||
master_buf_L[:] *= vol
|
||||
@@ -543,7 +458,7 @@ class AudioEngine:
|
||||
master._lufs_accum += frames
|
||||
if master._lufs_accum >= 512:
|
||||
master._lufs_accum -= 512
|
||||
self._calculate_lufs(master_audio)
|
||||
self._calculate_lufs(lufs_audio)
|
||||
|
||||
def _calculate_lufs(self, audio: np.ndarray):
|
||||
mean_square = np.mean(audio ** 2)
|
||||
|
||||
@@ -186,6 +186,7 @@ class RadioPanelWindow(QMainWindow):
|
||||
|
||||
state = {
|
||||
'window_size': (self.width(), self.height()),
|
||||
'pfl_hp_blend': self.engine.mixer['master'].pfl_hp_blend,
|
||||
'channels': [],
|
||||
}
|
||||
|
||||
@@ -193,6 +194,7 @@ class RadioPanelWindow(QMainWindow):
|
||||
state['channels'].append({
|
||||
'name': ch.name,
|
||||
'volume': ch.volume,
|
||||
'gain': ch.gain,
|
||||
'muted': ch.muted,
|
||||
'pfl': ch.pfl,
|
||||
'mic_on': ch.mic_on,
|
||||
@@ -218,11 +220,16 @@ class RadioPanelWindow(QMainWindow):
|
||||
if ws:
|
||||
self.resize(ws[0], ws[1])
|
||||
|
||||
pfl_hp_blend = state.get('pfl_hp_blend')
|
||||
if pfl_hp_blend is not None:
|
||||
self.engine.mixer['master'].pfl_hp_blend = pfl_hp_blend
|
||||
|
||||
for i, ch_state in enumerate(state.get('channels', [])):
|
||||
if i < len(self.engine.mixer['channels']):
|
||||
ch = self.engine.mixer['channels'][i]
|
||||
ch.name = ch_state.get('name', ch.name)
|
||||
ch.volume = ch_state.get('volume', ch.volume)
|
||||
ch.gain = ch_state.get('gain', ch.gain)
|
||||
ch.muted = ch_state.get('muted', ch.muted)
|
||||
ch.solo = ch_state.get('solo', ch.solo)
|
||||
ch.pfl = ch_state.get('pfl', ch.pfl)
|
||||
@@ -242,6 +249,7 @@ class RadioPanelWindow(QMainWindow):
|
||||
self.midi.mixer_pfl.connect(self._on_midi_mixer_pfl)
|
||||
self.midi.mixer_mic_on.connect(self._on_midi_mixer_mic_on)
|
||||
self.midi.mixer_mic_on.connect(self._on_midi_mixer_mic_on)
|
||||
self.midi.mixer_gain.connect(self._on_midi_mixer_gain)
|
||||
self.midi.master_control.connect(self._on_midi_master_control)
|
||||
self.midi.master_volume.connect(self._on_midi_master_volume)
|
||||
self.midi.controller_connected.connect(self._on_midi_status)
|
||||
@@ -291,24 +299,12 @@ class RadioPanelWindow(QMainWindow):
|
||||
if channel < 2:
|
||||
self.engine.set_channel_mic_on(channel, on)
|
||||
|
||||
def _on_midi_mixer_gain(self, channel: int, gain: float):
|
||||
self.engine.set_channel_gain(channel, gain)
|
||||
|
||||
def _on_midi_master_control(self, knob: str, val: float):
|
||||
master = self.engine.mixer['master']
|
||||
attrs = {
|
||||
"threshold_db": (-60, 0),
|
||||
"ratio": (1, 20),
|
||||
"attack_ms": (0.1, 50),
|
||||
"release_ms": (10, 1000),
|
||||
"makeup_gain_db": (0, 24),
|
||||
"ceiling_db": (-10, 0),
|
||||
}
|
||||
if knob in attrs:
|
||||
lo, hi = attrs[knob]
|
||||
v = lo + val * (hi - lo)
|
||||
if knob == "ratio":
|
||||
v = round(max(1, v), 1)
|
||||
elif knob in ("threshold_db", "ceiling_db", "makeup_gain_db"):
|
||||
v = round(max(lo, min(hi, v)), 1)
|
||||
setattr(master.compressor if knob != "ceiling_db" else master.limiter, knob, v)
|
||||
if knob == "pfl_hp_blend":
|
||||
self.engine.mixer['master'].pfl_hp_blend = max(0.0, min(1.0, val))
|
||||
|
||||
def _on_midi_master_volume(self, val: float):
|
||||
self.engine.mixer['master'].volume = max(0.0, min(1.0, val))
|
||||
|
||||
@@ -95,6 +95,7 @@ class MidiEngine(QObject):
|
||||
mixer_solo = Signal(int, bool)
|
||||
mixer_pfl = Signal(int, bool)
|
||||
mixer_mic_on = Signal(int, bool)
|
||||
mixer_gain = Signal(int, float)
|
||||
master_control = Signal(str, float)
|
||||
master_volume = Signal(float)
|
||||
controller_connected = Signal(bool, str)
|
||||
@@ -286,6 +287,44 @@ class MidiEngine(QObject):
|
||||
elif action == 'mixer_mic_on':
|
||||
on = (value > 63) if not inv else (value < 64)
|
||||
self.mixer_mic_on.emit(target_id, on)
|
||||
elif action == 'mixer_gain':
|
||||
if is_rel and 1 <= value <= 63:
|
||||
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
|
||||
new_val = min(2.0, prev + value * 0.02)
|
||||
self._channel_volumes[f"gain_{target_id}"] = new_val
|
||||
self.mixer_gain.emit(target_id, new_val)
|
||||
elif is_rel and 65 <= value <= 127:
|
||||
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
|
||||
steps = value & 0x3F
|
||||
new_val = max(0.0, prev - steps * 0.02)
|
||||
self._channel_volumes[f"gain_{target_id}"] = new_val
|
||||
self.mixer_gain.emit(target_id, new_val)
|
||||
else:
|
||||
if midi_type == PITCH_BEND:
|
||||
scaled = (16383 - value if inv else value) / 16383.0
|
||||
else:
|
||||
scaled = (127 - value if inv else value) / 127.0
|
||||
scaled = scaled * 2.0 # map to 0-2 range
|
||||
self.mixer_gain.emit(target_id, scaled)
|
||||
elif action == 'mixer_gain':
|
||||
if is_rel and 1 <= value <= 63:
|
||||
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
|
||||
new_val = min(2.0, prev + value * 0.02)
|
||||
self._channel_volumes[f"gain_{target_id}"] = new_val
|
||||
self.mixer_gain.emit(target_id, new_val)
|
||||
elif is_rel and 65 <= value <= 127:
|
||||
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
|
||||
steps = value & 0x3F
|
||||
new_val = max(0.0, prev - steps * 0.02)
|
||||
self._channel_volumes[f"gain_{target_id}"] = new_val
|
||||
self.mixer_gain.emit(target_id, new_val)
|
||||
else:
|
||||
if midi_type == PITCH_BEND:
|
||||
scaled = (16383 - value if inv else value) / 16383.0
|
||||
else:
|
||||
scaled = (127 - value if inv else value) / 127.0
|
||||
scaled = scaled * 2.0 # map to 0-2 range
|
||||
self.mixer_gain.emit(target_id, scaled)
|
||||
elif action == 'master_control':
|
||||
knob = str(target_id)
|
||||
if is_rel and 1 <= value <= 63:
|
||||
|
||||
+81
-129
@@ -2,8 +2,8 @@
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider,
|
||||
QPushButton, QFrame, QScrollArea, QDial, QSizePolicy,
|
||||
QInputDialog
|
||||
QPushButton, QFrame, QScrollArea, QSizePolicy,
|
||||
QInputDialog, QDial
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, QSize
|
||||
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen
|
||||
@@ -134,6 +134,27 @@ class ChannelStrip(QFrame):
|
||||
""")
|
||||
root.addWidget(self.name_btn)
|
||||
|
||||
self.gain_knob = QDial()
|
||||
self.gain_knob.setRange(0, 100)
|
||||
self.gain_knob.setValue(50)
|
||||
self.gain_knob.setFixedSize(40, 40)
|
||||
self.gain_knob.setNotchesVisible(True)
|
||||
self.gain_knob.setWrapping(False)
|
||||
self.gain_knob.setToolTip("Pre-fader gain trim")
|
||||
self.gain_knob.setStyleSheet("""
|
||||
QDial {
|
||||
background-color: transparent;
|
||||
color: #8890a0;
|
||||
}
|
||||
""")
|
||||
root.addWidget(self.gain_knob, alignment=Qt.AlignCenter)
|
||||
|
||||
gain_label = QLabel("GAIN")
|
||||
gain_label.setFont(QFont("Segoe UI", 7, QFont.Bold))
|
||||
gain_label.setAlignment(Qt.AlignCenter)
|
||||
gain_label.setStyleSheet("color: #556670; background: transparent; border: none;")
|
||||
root.addWidget(gain_label)
|
||||
|
||||
meter_row = QHBoxLayout()
|
||||
meter_row.setSpacing(2)
|
||||
|
||||
@@ -244,6 +265,7 @@ class ChannelStrip(QFrame):
|
||||
|
||||
def _connect_signals(self):
|
||||
self.name_btn.clicked.connect(self._rename)
|
||||
self.gain_knob.valueChanged.connect(self._on_gain_changed)
|
||||
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
||||
self.mute_btn.toggled.connect(self._on_mute_toggled)
|
||||
self.mic_btn.toggled.connect(self._on_mic_toggled)
|
||||
@@ -262,6 +284,10 @@ class ChannelStrip(QFrame):
|
||||
vol = (value / 100.0) ** 2
|
||||
self.engine.set_channel_volume(self.channel_index, vol)
|
||||
|
||||
def _on_gain_changed(self, value: int):
|
||||
gain = value / 50.0
|
||||
self.engine.set_channel_gain(self.channel_index, gain)
|
||||
|
||||
def _on_mute_toggled(self, checked: bool):
|
||||
self.engine.set_channel_mute(self.channel_index, checked)
|
||||
|
||||
@@ -276,6 +302,7 @@ class ChannelStrip(QFrame):
|
||||
def _refresh(self):
|
||||
self.vu_meter.set_value(self._channel.vu_peak)
|
||||
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100))
|
||||
self.gain_knob.setValue(int(self._channel.gain * 50))
|
||||
if self.channel_index < 2:
|
||||
color = "#00cc66" if self._channel.mic_on else "#888"
|
||||
self.mic_btn.setIcon(icon_mic(color))
|
||||
@@ -340,25 +367,53 @@ class MasterPreSection(QFrame):
|
||||
|
||||
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
||||
|
||||
# PFL / Headphone blend
|
||||
self.blend_label = QLabel("PFL⇄HP")
|
||||
self.blend_label.setFont(QFont("Segoe UI", 7, QFont.Bold))
|
||||
self.blend_label.setAlignment(Qt.AlignCenter)
|
||||
self.blend_label.setStyleSheet("color: #556670; background: transparent; border: none;")
|
||||
root.addWidget(self.blend_label)
|
||||
|
||||
self.pfl_hp_knob = QDial()
|
||||
self.pfl_hp_knob.setRange(0, 100)
|
||||
self.pfl_hp_knob.setValue(int(self._master.pfl_hp_blend * 100))
|
||||
self.pfl_hp_knob.setFixedSize(36, 36)
|
||||
self.pfl_hp_knob.setNotchesVisible(True)
|
||||
self.pfl_hp_knob.setWrapping(False)
|
||||
self.pfl_hp_knob.setToolTip("Left: PFL only | Centre: equal blend | Right: HP only")
|
||||
self.pfl_hp_knob.setStyleSheet("""
|
||||
QDial {
|
||||
background-color: transparent;
|
||||
color: #8890a0;
|
||||
}
|
||||
""")
|
||||
root.addWidget(self.pfl_hp_knob, alignment=Qt.AlignCenter)
|
||||
|
||||
self.pfl_hp_knob.valueChanged.connect(self._on_blend_changed)
|
||||
|
||||
def _on_volume_changed(self, value: int):
|
||||
vol = (value / 100.0) ** 2
|
||||
self._master.volume = vol
|
||||
|
||||
def _on_blend_changed(self, value: int):
|
||||
self._master.pfl_hp_blend = value / 100.0
|
||||
|
||||
def _refresh(self):
|
||||
self.vu_meter.set_value(self._master.vu_peak)
|
||||
self.volume_slider.setValue(int(math.sqrt(self._master.volume) * 100))
|
||||
self.pfl_hp_knob.setValue(int(self._master.pfl_hp_blend * 100))
|
||||
|
||||
class EffectSection(QFrame):
|
||||
|
||||
class LUFSExpandedSection(QFrame):
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self._master = engine.mixer['master']
|
||||
self._knobs = {}
|
||||
|
||||
self.setFixedWidth(110)
|
||||
self.setFixedWidth(200)
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
self.setStyleSheet("""
|
||||
EffectSection {
|
||||
LUFSExpandedSection {
|
||||
background-color: #181a1e;
|
||||
border: 1px solid #282a30;
|
||||
border-radius: 6px;
|
||||
@@ -368,148 +423,40 @@ class EffectSection(QFrame):
|
||||
self._build_ui()
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(30)
|
||||
self.timer.setInterval(100)
|
||||
self.timer.timeout.connect(self._refresh)
|
||||
self.timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(3, 6, 3, 6)
|
||||
root.setSpacing(2)
|
||||
root.setContentsMargins(8, 12, 8, 12)
|
||||
root.setSpacing(4)
|
||||
|
||||
header = QLabel("FX")
|
||||
header = QLabel("LUFS")
|
||||
header.setFont(QFont("Segoe UI", 10, QFont.Bold))
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
header.setStyleSheet("color: #556670; background: transparent; border: none;")
|
||||
root.addWidget(header)
|
||||
|
||||
lufs_frame = QFrame()
|
||||
lufs_frame.setStyleSheet("QFrame { background-color: #121318; border: 1px solid #22242a; border-radius: 4px; }")
|
||||
lufs_layout = QVBoxLayout(lufs_frame)
|
||||
lufs_layout.setContentsMargins(3, 4, 3, 4)
|
||||
lufs_layout.setSpacing(2)
|
||||
|
||||
self.lufs_value = QLabel("-70.0")
|
||||
self.lufs_value.setFont(QFont("Segoe UI", 14, QFont.Bold))
|
||||
self.lufs_value.setFont(QFont("Segoe UI", 32, QFont.Bold))
|
||||
self.lufs_value.setAlignment(Qt.AlignCenter)
|
||||
self.lufs_value.setStyleSheet("color: #00cc88; background: transparent; border: none;")
|
||||
lufs_layout.addWidget(self.lufs_value)
|
||||
root.addWidget(self.lufs_value, stretch=1)
|
||||
|
||||
lufs_unit = QLabel("LUFS")
|
||||
lufs_unit.setFont(QFont("Segoe UI", 7))
|
||||
lufs_unit.setFont(QFont("Segoe UI", 9))
|
||||
lufs_unit.setAlignment(Qt.AlignCenter)
|
||||
lufs_unit.setStyleSheet("color: #556; background: transparent; border: none;")
|
||||
lufs_layout.addWidget(lufs_unit)
|
||||
root.addWidget(lufs_unit)
|
||||
|
||||
self.pfl_indicator = QLabel("PFL\n—")
|
||||
self.pfl_indicator.setFont(QFont("Segoe UI", 7))
|
||||
self.pfl_indicator = QLabel("PFL\n\u2014")
|
||||
self.pfl_indicator.setFont(QFont("Segoe UI", 8))
|
||||
self.pfl_indicator.setAlignment(Qt.AlignCenter)
|
||||
self.pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;")
|
||||
lufs_layout.addWidget(self.pfl_indicator)
|
||||
|
||||
root.addWidget(lufs_frame)
|
||||
|
||||
knob_col = QVBoxLayout()
|
||||
knob_col.setSpacing(1)
|
||||
|
||||
bypass_row = QHBoxLayout()
|
||||
bypass_row.setSpacing(2)
|
||||
for label, btn_ref, obj, attr in [
|
||||
("C", "comp_bypass", self._master.compressor, 'bypassed'),
|
||||
("L", "lim_bypass", self._master.limiter, 'bypassed'),
|
||||
]:
|
||||
btn = QPushButton(label)
|
||||
btn.setFixedSize(24, 18)
|
||||
btn.setCheckable(True)
|
||||
btn.setFont(QFont("Segoe UI", 7, QFont.Bold))
|
||||
btn.setStyleSheet("""
|
||||
QPushButton { background-color: #22242a; color: #889; border: 1px solid #333; border-radius: 3px; }
|
||||
QPushButton:checked { background-color: #4a1a1a; color: #ff6666; border: 1px solid #ff4444; }
|
||||
""")
|
||||
setattr(self, btn_ref, btn)
|
||||
btn.toggled.connect(lambda c, o=obj, a=attr: setattr(o, a, c))
|
||||
bypass_row.addWidget(btn)
|
||||
knob_col.addLayout(bypass_row)
|
||||
|
||||
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"),
|
||||
("CEIL", "ceiling_db", -0.1, -10, 0, "dB"),
|
||||
]:
|
||||
obj = self._master.compressor if attr != "ceiling_db" else self._master.limiter
|
||||
k, dial, val_label = self._make_knob(label, obj, attr, default, lo, hi, suffix)
|
||||
knob_col.addWidget(k)
|
||||
self._knobs[('compressor' if attr != 'ceiling_db' else 'limiter', attr)] = (dial, val_label, lo, hi, suffix)
|
||||
root.addLayout(knob_col)
|
||||
|
||||
def _make_knob(self, label, obj, attr, default, lo, hi, suffix):
|
||||
frame = QFrame()
|
||||
frame.setStyleSheet("background: transparent; border: none;")
|
||||
layout = QHBoxLayout(frame)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(2)
|
||||
|
||||
dial = QDial()
|
||||
dial.setFixedSize(30, 30)
|
||||
dial.setRange(0, 1000)
|
||||
dial.setNotchesVisible(False)
|
||||
dial.setStyleSheet("""
|
||||
QDial { background-color: #121318; border: 1px solid #2a2c33; border-radius: 15px; }
|
||||
""")
|
||||
|
||||
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))
|
||||
|
||||
lbl = QLabel(label)
|
||||
lbl.setFont(QFont("Segoe UI", 9, QFont.Bold))
|
||||
lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
lbl.setStyleSheet("color: #889; background: transparent; border: none;")
|
||||
|
||||
val_label = QLabel(f"{default}{suffix}")
|
||||
val_label.setFont(QFont("Segoe UI", 8))
|
||||
val_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
val_label.setStyleSheet("color: #667; 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)
|
||||
layout.addWidget(lbl, 1)
|
||||
layout.addWidget(val_label)
|
||||
return frame, dial, val_label
|
||||
root.addWidget(self.pfl_indicator)
|
||||
|
||||
def _refresh(self):
|
||||
for (section, attr), (dial, val_label, lo, hi, suffix) in self._knobs.items():
|
||||
obj = self._master.compressor if section == 'compressor' else self._master.limiter
|
||||
val = getattr(obj, attr, lo)
|
||||
dial.blockSignals(True)
|
||||
dial.setValue(int((val - lo) / (hi - lo) * 1000))
|
||||
dial.blockSignals(False)
|
||||
val_label.setText(f"{val}{suffix}")
|
||||
|
||||
self.lufs_value.setText(f"{self._master.lufs_current:.1f}")
|
||||
active_pfl = [ch for ch in self.engine.mixer['channels'] if ch.pfl]
|
||||
if active_pfl:
|
||||
@@ -517,9 +464,13 @@ class EffectSection(QFrame):
|
||||
self.pfl_indicator.setText(f"PFL\n{names}")
|
||||
self.pfl_indicator.setStyleSheet("color: #00aaff; background: transparent; border: none;")
|
||||
else:
|
||||
self.pfl_indicator.setText("PFL\n—")
|
||||
self.pfl_indicator.setText("PFL\n\u2014")
|
||||
self.pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;")
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
pass
|
||||
|
||||
|
||||
class MixerWidget(QFrame):
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -584,8 +535,8 @@ class MixerWidget(QFrame):
|
||||
self.master_pre = MasterPreSection(self.engine)
|
||||
channels_layout.addWidget(self.master_pre)
|
||||
|
||||
self.effects = EffectSection(self.engine)
|
||||
channels_layout.addWidget(self.effects)
|
||||
self.lufs_section = LUFSExpandedSection(self.engine)
|
||||
channels_layout.addWidget(self.lufs_section)
|
||||
|
||||
scroll.setWidget(scroll_content)
|
||||
root.addWidget(scroll)
|
||||
@@ -594,12 +545,13 @@ class MixerWidget(QFrame):
|
||||
for strip in self._strips:
|
||||
strip.apply_theme(theme)
|
||||
self.master_pre.apply_theme(theme)
|
||||
self.effects.apply_theme(theme)
|
||||
self.lufs_section.apply_theme(theme)
|
||||
|
||||
def refresh_strips(self):
|
||||
for strip in self._strips:
|
||||
ch = strip._channel
|
||||
strip.name_btn.setText(ch.name)
|
||||
strip.volume_slider.setValue(int(ch.volume * 100))
|
||||
strip.gain_knob.setValue(int(ch.gain * 50))
|
||||
strip.mute_btn.setChecked(ch.muted)
|
||||
strip.pfl_btn.setChecked(ch.pfl)
|
||||
+9
-12
@@ -360,6 +360,14 @@ class SettingsPopup(QFrame):
|
||||
("Ch 6 Volume", "mixer_volume", 5),
|
||||
("Ch 7 Volume", "mixer_volume", 6),
|
||||
("Ch 8 Volume", "mixer_volume", 7),
|
||||
("Ch 1 Gain", "mixer_gain", 0),
|
||||
("Ch 2 Gain", "mixer_gain", 1),
|
||||
("Ch 3 Gain", "mixer_gain", 2),
|
||||
("Ch 4 Gain", "mixer_gain", 3),
|
||||
("Ch 5 Gain", "mixer_gain", 4),
|
||||
("Ch 6 Gain", "mixer_gain", 5),
|
||||
("Ch 7 Gain", "mixer_gain", 6),
|
||||
("Ch 8 Gain", "mixer_gain", 7),
|
||||
("Ch 1 Mic On", "mixer_mic_on", 0),
|
||||
("Ch 2 Mic On", "mixer_mic_on", 1),
|
||||
("Ch 1 PFL", "mixer_pfl", 0),
|
||||
@@ -376,13 +384,8 @@ class SettingsPopup(QFrame):
|
||||
("Ch 6 Mute", "mixer_mute", 5),
|
||||
("Ch 7 Mute", "mixer_mute", 6),
|
||||
("Ch 8 Mute", "mixer_mute", 7),
|
||||
("Master THR", "master_control", "threshold_db"),
|
||||
("Master RAT", "master_control", "ratio"),
|
||||
("Master ATK", "master_control", "attack_ms"),
|
||||
("Master REL", "master_control", "release_ms"),
|
||||
("Master MKG", "master_control", "makeup_gain_db"),
|
||||
("Master CEIL", "master_control", "ceiling_db"),
|
||||
("Master Volume", "master_volume", "master"),
|
||||
("PFL⇄HP Blend", "master_control", "pfl_hp_blend"),
|
||||
]
|
||||
|
||||
self._learn_btns = {}
|
||||
@@ -519,12 +522,6 @@ QPushButton:hover { background-color: #5a2a2a; }
|
||||
self._midi_engine.mapping.bindings.clear()
|
||||
self._midi_engine.mapping.save()
|
||||
|
||||
def _clear_all_midi(self):
|
||||
if not self._midi_engine:
|
||||
return
|
||||
self._midi_engine.mapping.bindings.clear()
|
||||
self._midi_engine.mapping.save()
|
||||
|
||||
def _on_mapping_learned(self, action, target_id, midi_type, channel, note, value):
|
||||
btn = self._learn_btns.get((action, target_id))
|
||||
if btn and btn.isChecked():
|
||||
|
||||
Reference in New Issue
Block a user