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.pfl = False
self.mic_on = False self.mic_on = False
self.vu_peak = 0.0 self.vu_peak = 0.0
self._vu_smooth = 0.0
self.in_port_L = None self.in_port_L = None
self.in_port_R = None self.in_port_R = None
@@ -165,7 +166,10 @@ class MixerChannel:
class MasterBus: class MasterBus:
def __init__(self, sr: int): def __init__(self, sr: int):
self.vu_peak = 0.0 self.vu_peak = 0.0
self._vu_smooth = 0.0
self.lufs_current = -70.0 self.lufs_current = -70.0
self.lufs_short = -70.0
self.lufs_long = -70.0
self.volume = 1.0 self.volume = 1.0
self.pfl_hp_blend = 0.5 # 0.0 = PFL only, 0.5 = equal, 1.0 = HP only self.pfl_hp_blend = 0.5 # 0.0 = PFL only, 0.5 = equal, 1.0 = HP only
self._lufs_accum = 0 self._lufs_accum = 0
@@ -257,10 +261,6 @@ class AudioEngine:
if 0 <= index < NUM_MIXER_CHANNELS: if 0 <= index < NUM_MIXER_CHANNELS:
self.mixer['channels'][index].gain = max(0.0, min(2.0, gain)) 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): def set_channel_mute(self, index: int, muted: bool):
if 0 <= index < NUM_MIXER_CHANNELS: if 0 <= index < NUM_MIXER_CHANNELS:
self.mixer['channels'][index].muted = muted 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['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)
# Mix carts # ── Mix carts ───────────────────────────────────────────────────
buf_L = self.ports['cart'][0].get_array() buf_L = self.ports['cart'][0].get_array()
buf_R = self.ports['cart'][1].get_array() buf_R = self.ports['cart'][1].get_array()
buf_L.fill(0.0) buf_L.fill(0.0)
@@ -346,10 +346,11 @@ class AudioEngine:
deck.playing = False deck.playing = False
deck.position = track.num_samples - 1 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 ─────────────────────────────────────────────────────── # ── 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_L = self.master_out[0].get_array()
master_buf_R = self.master_out[1].get_array() master_buf_R = self.master_out[1].get_array()
master_buf_L.fill(0.0) master_buf_L.fill(0.0)
@@ -365,16 +366,17 @@ class AudioEngine:
studio_buf_L.fill(0.0) studio_buf_L.fill(0.0)
studio_buf_R.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_L = self.pfl_out[0].get_array()
pfl_buf_R = self.pfl_out[1].get_array() pfl_buf_R = self.pfl_out[1].get_array()
pfl_buf_L.fill(0.0) pfl_buf_L.fill(0.0)
pfl_buf_R.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_L = ch.in_port_L.get_array()
in_R = ch.in_port_R.get_array() in_R = ch.in_port_R.get_array()
@@ -382,32 +384,29 @@ class AudioEngine:
is_mic = ch.index < 2 is_mic = ch.index < 2
g = ch.gain g = ch.gain
# PFL: pre-fader, pre-mute signal to PFL bus (with gain applied)
if ch.pfl: if ch.pfl:
pfl_buf_L[:] += in_L[:] * g pfl_buf_L[:] += in_L[:] * g
pfl_buf_R[:] += in_R[:] * g pfl_buf_R[:] += in_R[:] * g
if mute: if mute:
ch.vu_peak = 0.0 ch.vu_peak = 0.0
ch._vu_smooth = 0.0
continue continue
vol = ch.volume factor = g * ch.volume
goes_to_main = not is_mic or ch.mic_on goes_to_main = not is_mic or ch.mic_on
goes_to_headphone = not is_mic goes_to_headphone = not is_mic
peak = 0.0 if goes_to_main:
for i in range(frames): master_buf_L[:] += in_L[:] * factor
s_L = in_L[i] * g * vol master_buf_R[:] += in_R[:] * factor
s_R = in_R[i] * g * vol if goes_to_headphone:
if goes_to_main: headphone_buf_L[:] += in_L[:] * factor
master_buf_L[i] += s_L headphone_buf_R[:] += in_R[:] * factor
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))
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 ──────── # ── Studio bus: same as main, muted when any mic is on ────────
if any_mic_on: if any_mic_on:
@@ -418,54 +417,44 @@ class AudioEngine:
studio_buf_R[:] = master_buf_R[:] studio_buf_R[:] = master_buf_R[:]
# ── PFL / Headphone blend ─────────────────────────────────────── # ── PFL / Headphone blend ───────────────────────────────────────
blend = self.mixer['master'].pfl_hp_blend if any_pfl:
hp_gain = blend # 0..1 how much normal HP mix blend = self.mixer['master'].pfl_hp_blend
pfl_gain = 1.0 - blend # 0..1 how much PFL hp_gain = blend
any_pfl = any(ch.pfl for ch in self.mixer['channels']) pfl_gain = 1.0 - blend
if any_pfl and pfl_gain > 0.0: if pfl_gain > 0.0:
headphone_buf_L[:] = headphone_buf_L * hp_gain + pfl_buf_L * pfl_gain 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 headphone_buf_R[:] = headphone_buf_R * hp_gain + pfl_buf_R * pfl_gain
elif any_pfl and hp_gain == 0.0: else:
headphone_buf_L[:] = pfl_buf_L[:] headphone_buf_L[:] = pfl_buf_L[:]
headphone_buf_R[:] = pfl_buf_R[:] headphone_buf_R[:] = pfl_buf_R[:]
# else: no PFL active, headphone stays as-is
# ── PFL / Headphone blend ─────────────────────────────────────── # ── Master VU ──────────────────────────────────────────────────
blend = self.mixer['master'].pfl_hp_blend master = self.mixer['master']
hp_gain = blend # 0..1 how much normal HP mix peak = max(np.max(np.abs(master_buf_L)), np.max(np.abs(master_buf_R)))
pfl_gain = 1.0 - blend # 0..1 how much PFL master._vu_smooth = master._vu_smooth * vu_decay + peak * (1 - vu_decay)
any_pfl = any(ch.pfl for ch in self.mixer['channels']) master.vu_peak = master._vu_smooth
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
vol = self.mixer['master'].volume vol = self.mixer['master'].volume
if vol < 1.0: if vol < 1.0:
master_buf_L[:] *= vol master_buf_L[:] *= vol
master_buf_R[:] *= vol master_buf_R[:] *= vol
master = self.mixer['master'] # ── LUFS metering from external processing input ────────────────
master._lufs_accum += frames master._lufs_accum += frames
if master._lufs_accum >= 512: if master._lufs_accum >= 512:
lufs_dt = master._lufs_accum / self.sr
master._lufs_accum -= 512 master._lufs_accum -= 512
self._calculate_lufs(lufs_audio) lufs_L = self.lufs_in_L.get_array()
lufs_R = self.lufs_in_R.get_array()
def _calculate_lufs(self, audio: np.ndarray): ms = (np.mean(lufs_L ** 2) + np.mean(lufs_R ** 2)) / 2.0
mean_square = np.mean(audio ** 2) if ms < 1e-12:
if mean_square < 1e-12: master.lufs_current = -70.0
self.mixer['master'].lufs_current = -70.0 else:
else: master.lufs_current = -0.691 + 10 * np.log10(ms)
self.mixer['master'].lufs_current = -0.691 + 10 * np.log10(mean_square) 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_track(self, deck_key: str, filepath: str):
def _load(): def _load():
+3 -3
View File
@@ -5,7 +5,7 @@ import logging
import os import os
import sys import sys
__version__ = "0.5" __version__ = "0.6"
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QFileDialog, QApplication, QMainWindow, QWidget, QFileDialog,
@@ -248,7 +248,6 @@ class RadioPanelWindow(QMainWindow):
self.midi.mixer_mute.connect(self._on_midi_mixer_mute) self.midi.mixer_mute.connect(self._on_midi_mixer_mute)
self.midi.mixer_pfl.connect(self._on_midi_mixer_pfl) 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_mic_on.connect(self._on_midi_mixer_mic_on)
self.midi.mixer_gain.connect(self._on_midi_mixer_gain) self.midi.mixer_gain.connect(self._on_midi_mixer_gain)
self.midi.master_control.connect(self._on_midi_master_control) self.midi.master_control.connect(self._on_midi_master_control)
self.midi.master_volume.connect(self._on_midi_master_volume) self.midi.master_volume.connect(self._on_midi_master_volume)
@@ -297,7 +296,8 @@ class RadioPanelWindow(QMainWindow):
def _on_midi_mixer_mic_on(self, channel: int, on: bool): def _on_midi_mixer_mic_on(self, channel: int, on: bool):
if channel < 2: if channel < 2:
self.engine.set_channel_mic_on(channel, on) current = self.engine.get_channel_mic_on(channel)
self.engine.set_channel_mic_on(channel, not current)
def _on_midi_mixer_gain(self, channel: int, gain: float): def _on_midi_mixer_gain(self, channel: int, gain: float):
self.engine.set_channel_gain(channel, gain) self.engine.set_channel_gain(channel, gain)
+25 -12
View File
@@ -438,17 +438,29 @@ class LUFSExpandedSection(QFrame):
header.setStyleSheet("color: #556670; background: transparent; border: none;") header.setStyleSheet("color: #556670; background: transparent; border: none;")
root.addWidget(header) root.addWidget(header)
self.lufs_value = QLabel("-70.0") self.lufs_short = QLabel("-70.0")
self.lufs_value.setFont(QFont("Segoe UI", 32, QFont.Bold)) self.lufs_short.setFont(QFont("Segoe UI", 28, QFont.Bold))
self.lufs_value.setAlignment(Qt.AlignCenter) self.lufs_short.setAlignment(Qt.AlignCenter)
self.lufs_value.setStyleSheet("color: #00cc88; background: transparent; border: none;") self.lufs_short.setStyleSheet("color: #00cc88; background: transparent; border: none;")
root.addWidget(self.lufs_value, stretch=1) root.addWidget(self.lufs_short, stretch=1)
lufs_unit = QLabel("LUFS") short_hint = QLabel("4 s")
lufs_unit.setFont(QFont("Segoe UI", 9)) short_hint.setFont(QFont("Segoe UI", 7))
lufs_unit.setAlignment(Qt.AlignCenter) short_hint.setAlignment(Qt.AlignCenter)
lufs_unit.setStyleSheet("color: #556; background: transparent; border: none;") short_hint.setStyleSheet("color: #445; background: transparent; border: none;")
root.addWidget(lufs_unit) root.addWidget(short_hint)
self.lufs_long = QLabel("-70.0")
self.lufs_long.setFont(QFont("Segoe UI", 18, QFont.Bold))
self.lufs_long.setAlignment(Qt.AlignCenter)
self.lufs_long.setStyleSheet("color: #5588aa; background: transparent; border: none;")
root.addWidget(self.lufs_long)
long_hint = QLabel("10 min")
long_hint.setFont(QFont("Segoe UI", 7))
long_hint.setAlignment(Qt.AlignCenter)
long_hint.setStyleSheet("color: #445; background: transparent; border: none;")
root.addWidget(long_hint)
self.pfl_indicator = QLabel("PFL\n\u2014") self.pfl_indicator = QLabel("PFL\n\u2014")
self.pfl_indicator.setFont(QFont("Segoe UI", 8)) self.pfl_indicator.setFont(QFont("Segoe UI", 8))
@@ -457,7 +469,8 @@ class LUFSExpandedSection(QFrame):
root.addWidget(self.pfl_indicator) root.addWidget(self.pfl_indicator)
def _refresh(self): def _refresh(self):
self.lufs_value.setText(f"{self._master.lufs_current:.1f}") self.lufs_short.setText(f"{self._master.lufs_short:.1f}")
self.lufs_long.setText(f"{self._master.lufs_long:.1f}")
active_pfl = [ch for ch in self.engine.mixer['channels'] if ch.pfl] active_pfl = [ch for ch in self.engine.mixer['channels'] if ch.pfl]
if active_pfl: if active_pfl:
names = ", ".join(ch.name[:3] for ch in active_pfl) names = ", ".join(ch.name[:3] for ch in active_pfl)
@@ -547,7 +560,7 @@ class MixerWidget(QFrame):
self.master_pre.apply_theme(theme) self.master_pre.apply_theme(theme)
self.lufs_section.apply_theme(theme) self.lufs_section.apply_theme(theme)
def refresh_strips(self): def refresh_strips(self):
for strip in self._strips: for strip in self._strips:
ch = strip._channel ch = strip._channel
strip.name_btn.setText(ch.name) strip.name_btn.setText(ch.name)