From 90d5ac26ec8c4f4f076e6d63c468618f2edb78de Mon Sep 17 00:00:00 2001 From: njmb Date: Sat, 16 May 2026 22:41:25 +1000 Subject: [PATCH] =?UTF-8?q?v0.6=20=E2=80=94=20smooth=20VU=20metering=20dec?= =?UTF-8?q?ay,=20short/long-term=20LUFS=20display,=20deduplicate=20PFL=20b?= =?UTF-8?q?lend=20and=20MIDI=20mic=20toggle=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- audio_engine.py | 119 ++++++++++++++++++++++-------------------------- main.py | 6 +-- mixer_widget.py | 37 ++++++++++----- 3 files changed, 82 insertions(+), 80 deletions(-) diff --git a/audio_engine.py b/audio_engine.py index 369f45e..385d9f8 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -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(): diff --git a/main.py b/main.py index e179911..6ee68c8 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ import logging import os import sys -__version__ = "0.5" +__version__ = "0.6" from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QFileDialog, @@ -248,7 +248,6 @@ class RadioPanelWindow(QMainWindow): self.midi.mixer_mute.connect(self._on_midi_mixer_mute) 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) @@ -297,7 +296,8 @@ class RadioPanelWindow(QMainWindow): def _on_midi_mixer_mic_on(self, channel: int, on: bool): 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): self.engine.set_channel_gain(channel, gain) diff --git a/mixer_widget.py b/mixer_widget.py index 6367df2..50ba2a3 100644 --- a/mixer_widget.py +++ b/mixer_widget.py @@ -438,17 +438,29 @@ class LUFSExpandedSection(QFrame): header.setStyleSheet("color: #556670; background: transparent; border: none;") root.addWidget(header) - self.lufs_value = QLabel("-70.0") - 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;") - root.addWidget(self.lufs_value, stretch=1) + self.lufs_short = QLabel("-70.0") + self.lufs_short.setFont(QFont("Segoe UI", 28, QFont.Bold)) + self.lufs_short.setAlignment(Qt.AlignCenter) + self.lufs_short.setStyleSheet("color: #00cc88; background: transparent; border: none;") + root.addWidget(self.lufs_short, stretch=1) - lufs_unit = QLabel("LUFS") - lufs_unit.setFont(QFont("Segoe UI", 9)) - lufs_unit.setAlignment(Qt.AlignCenter) - lufs_unit.setStyleSheet("color: #556; background: transparent; border: none;") - root.addWidget(lufs_unit) + short_hint = QLabel("4 s") + short_hint.setFont(QFont("Segoe UI", 7)) + short_hint.setAlignment(Qt.AlignCenter) + short_hint.setStyleSheet("color: #445; background: transparent; border: none;") + 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.setFont(QFont("Segoe UI", 8)) @@ -457,7 +469,8 @@ class LUFSExpandedSection(QFrame): root.addWidget(self.pfl_indicator) 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] if 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.lufs_section.apply_theme(theme) -def refresh_strips(self): + def refresh_strips(self): for strip in self._strips: ch = strip._channel strip.name_btn.setText(ch.name)