v0.6 — smooth VU metering decay, short/long-term LUFS display, deduplicate PFL blend and MIDI mic toggle logic
This commit is contained in:
+54
-65
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
+25
-12
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user