v0.6 — smooth VU metering decay, short/long-term LUFS display, deduplicate PFL blend and MIDI mic toggle logic

This commit is contained in:
2026-05-16 22:41:25 +10:00
parent 2b81a80a39
commit 90d5ac26ec
3 changed files with 82 additions and 80 deletions
+54 -65
View File
@@ -158,6 +158,7 @@ class MixerChannel:
self.pfl = False
self.mic_on = False
self.vu_peak = 0.0
self._vu_smooth = 0.0
self.in_port_L = None
self.in_port_R = None
@@ -165,7 +166,10 @@ class MixerChannel:
class MasterBus:
def __init__(self, sr: int):
self.vu_peak = 0.0
self._vu_smooth = 0.0
self.lufs_current = -70.0
self.lufs_short = -70.0
self.lufs_long = -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
@@ -257,10 +261,6 @@ class AudioEngine:
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
@@ -318,7 +318,7 @@ class AudioEngine:
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
# Mix carts
# ── Mix carts ───────────────────────────────────────────────────
buf_L = self.ports['cart'][0].get_array()
buf_R = self.ports['cart'][1].get_array()
buf_L.fill(0.0)
@@ -346,10 +346,11 @@ class AudioEngine:
deck.playing = False
deck.position = track.num_samples - 1
# ── LUFS metering from external processing input ────────────────
lufs_audio = np.array([self.lufs_in_L.get_array(), self.lufs_in_R.get_array()])
# ── Mixer ───────────────────────────────────────────────────────
dt = frames / self.sr
vu_decay = np.exp(-dt / 0.15)
dt = frames / self.sr
vu_decay = np.exp(-dt / 0.15)
master_buf_L = self.master_out[0].get_array()
master_buf_R = self.master_out[1].get_array()
master_buf_L.fill(0.0)
@@ -365,16 +366,17 @@ class AudioEngine:
studio_buf_L.fill(0.0)
studio_buf_R.fill(0.0)
any_solo = any(ch.solo for ch in self.mixer['channels'])
any_mic_on = any(ch.mic_on for ch in self.mixer['channels'][:2])
# PFL bus
pfl_buf_L = self.pfl_out[0].get_array()
pfl_buf_R = self.pfl_out[1].get_array()
pfl_buf_L.fill(0.0)
pfl_buf_R.fill(0.0)
for ch in self.mixer['channels']:
channels = self.mixer['channels']
any_solo = any(ch.solo for ch in channels)
any_mic_on = any(ch.mic_on for ch in channels[:2])
any_pfl = any(ch.pfl for ch in channels)
for ch in channels:
in_L = ch.in_port_L.get_array()
in_R = ch.in_port_R.get_array()
@@ -382,32 +384,29 @@ class AudioEngine:
is_mic = ch.index < 2
g = ch.gain
# PFL: pre-fader, pre-mute signal to PFL bus (with gain applied)
if ch.pfl:
pfl_buf_L[:] += in_L[:] * g
pfl_buf_R[:] += in_R[:] * g
if mute:
ch.vu_peak = 0.0
ch._vu_smooth = 0.0
continue
vol = ch.volume
factor = g * ch.volume
goes_to_main = not is_mic or ch.mic_on
goes_to_headphone = not is_mic
peak = 0.0
for i in range(frames):
s_L = in_L[i] * 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
if goes_to_headphone:
headphone_buf_L[i] += s_L
headphone_buf_R[i] += s_R
peak = max(peak, abs(s_L), abs(s_R))
if goes_to_main:
master_buf_L[:] += in_L[:] * factor
master_buf_R[:] += in_R[:] * factor
if goes_to_headphone:
headphone_buf_L[:] += in_L[:] * factor
headphone_buf_R[:] += in_R[:] * factor
ch.vu_peak = peak
peak = max(np.max(np.abs(in_L)), np.max(np.abs(in_R))) * factor
ch._vu_smooth = ch._vu_smooth * vu_decay + peak * (1 - vu_decay)
ch.vu_peak = ch._vu_smooth
# ── Studio bus: same as main, muted when any mic is on ────────
if any_mic_on:
@@ -418,54 +417,44 @@ class AudioEngine:
studio_buf_R[:] = 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
if any_pfl:
blend = self.mixer['master'].pfl_hp_blend
hp_gain = blend
pfl_gain = 1.0 - blend
if 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
else:
headphone_buf_L[:] = pfl_buf_L[:]
headphone_buf_R[:] = pfl_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
# ── 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
# ── Master VU ──────────────────────────────────────────────────
master = self.mixer['master']
peak = max(np.max(np.abs(master_buf_L)), np.max(np.abs(master_buf_R)))
master._vu_smooth = master._vu_smooth * vu_decay + peak * (1 - vu_decay)
master.vu_peak = master._vu_smooth
vol = self.mixer['master'].volume
if vol < 1.0:
master_buf_L[:] *= vol
master_buf_R[:] *= vol
master = self.mixer['master']
# ── LUFS metering from external processing input ────────────────
master._lufs_accum += frames
if master._lufs_accum >= 512:
lufs_dt = master._lufs_accum / self.sr
master._lufs_accum -= 512
self._calculate_lufs(lufs_audio)
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:
self.mixer['master'].lufs_current = -0.691 + 10 * np.log10(mean_square)
lufs_L = self.lufs_in_L.get_array()
lufs_R = self.lufs_in_R.get_array()
ms = (np.mean(lufs_L ** 2) + np.mean(lufs_R ** 2)) / 2.0
if ms < 1e-12:
master.lufs_current = -70.0
else:
master.lufs_current = -0.691 + 10 * np.log10(ms)
decay_short = np.exp(-lufs_dt / 4.0)
decay_long = np.exp(-lufs_dt / 600.0)
master.lufs_short = master.lufs_short * decay_short + master.lufs_current * (1 - decay_short)
master.lufs_long = master.lufs_long * decay_long + master.lufs_current * (1 - decay_long)
def load_track(self, deck_key: str, filepath: str):
def _load():