fix(vu): drive needle from RMS-dB loudness instead of peak-of-bins
- Backend computes time-domain RMS, maps -60..-6 dB to 0..1, sends as `level` alongside the per-frame-normalized frequency bins. - Frontend prefers `level` directly; drops the peak-of-bins fallback and the redundant volume-slider attenuation (loopback capture is already post-volume on Windows/macOS). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -284,7 +284,18 @@ class AudioAnalyzer:
|
|||||||
counts = bin_ends - bin_starts
|
counts = bin_ends - bin_starts
|
||||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
|
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
|
||||||
|
|
||||||
# Normalize to 0-1
|
# True loudness from time-domain RMS, mapped via dB
|
||||||
|
# so the VU needle reflects actual program level — not
|
||||||
|
# the per-frame-normalized spectrum.
|
||||||
|
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
|
||||||
|
if rms > 1e-6:
|
||||||
|
db = 20.0 * np.log10(rms)
|
||||||
|
# Map -60 dB..-6 dB to 0..1 (typical music range)
|
||||||
|
level = max(0.0, min(1.0, (db + 60.0) / 54.0))
|
||||||
|
else:
|
||||||
|
level = 0.0
|
||||||
|
|
||||||
|
# Normalize bins to 0-1 for spectrum display
|
||||||
max_val = bins.max()
|
max_val = bins.max()
|
||||||
if max_val > 0:
|
if max_val > 0:
|
||||||
bins *= (1.0 / max_val)
|
bins *= (1.0 / max_val)
|
||||||
@@ -295,9 +306,10 @@ class AudioAnalyzer:
|
|||||||
# Round for compact JSON
|
# Round for compact JSON
|
||||||
frequencies = np.round(bins, 3).tolist()
|
frequencies = np.round(bins, 3).tolist()
|
||||||
bass = round(bass, 3)
|
bass = round(bass, 3)
|
||||||
|
level = round(level, 3)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._data = {"frequencies": frequencies, "bass": bass}
|
self._data = {"frequencies": frequencies, "bass": bass, "level": level}
|
||||||
|
|
||||||
# Throttle to target FPS
|
# Throttle to target FPS
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
|
|||||||
@@ -807,7 +807,11 @@ const VU_LEVEL_ATTACK = 0.7; // Fast climb so the needle catches musical hits
|
|||||||
const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins
|
const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins
|
||||||
|
|
||||||
function readAudioLevel() {
|
function readAudioLevel() {
|
||||||
if (!frequencyData || !frequencyData.frequencies) return null;
|
if (!frequencyData) return null;
|
||||||
|
// Backend sends a true loudness signal (RMS-derived dB, 0..1).
|
||||||
|
// The bins are renormalized per frame so peak-of-bins is useless for level.
|
||||||
|
if (typeof frequencyData.level === 'number') return frequencyData.level;
|
||||||
|
if (!frequencyData.frequencies) return null;
|
||||||
const bins = frequencyData.frequencies;
|
const bins = frequencyData.frequencies;
|
||||||
if (!bins.length) return null;
|
if (!bins.length) return null;
|
||||||
let peak = 0;
|
let peak = 0;
|
||||||
@@ -823,21 +827,20 @@ function startVuWobble() {
|
|||||||
const tick = () => {
|
const tick = () => {
|
||||||
const needle = document.getElementById('vuNeedle');
|
const needle = document.getElementById('vuNeedle');
|
||||||
if (needle) {
|
if (needle) {
|
||||||
const slider = document.getElementById('volume-slider');
|
// Loopback capture is post-volume on Windows/macOS, so the
|
||||||
const vol = slider ? Number(slider.value) || 0 : 0;
|
// measured level already reflects the output knob — no extra
|
||||||
// Volume slider modulates how loud the audio could be;
|
// (vol/100) attenuation needed.
|
||||||
// multiply against the captured level so muting drops the
|
|
||||||
// needle even though the source is still playing.
|
|
||||||
const audioLevel = readAudioLevel();
|
const audioLevel = readAudioLevel();
|
||||||
let target;
|
let target;
|
||||||
if (audioLevel != null) {
|
if (audioLevel != null) {
|
||||||
// Real audio: scale by output volume & apply attack/release
|
// Real audio: apply attack/release smoothing for
|
||||||
// smoothing for analog-feeling ballistics.
|
// analog-feeling ballistics.
|
||||||
const wanted = audioLevel * (vol / 100);
|
const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
|
||||||
const k = wanted > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
|
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k;
|
||||||
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + wanted * k;
|
|
||||||
target = -22 + vuLevelSmoothed * 44;
|
target = -22 + vuLevelSmoothed * 44;
|
||||||
} else {
|
} else {
|
||||||
|
const slider = document.getElementById('volume-slider');
|
||||||
|
const vol = slider ? Number(slider.value) || 0 : 0;
|
||||||
const base = -22 + (vol / 100) * 44;
|
const base = -22 + (vol / 100) * 44;
|
||||||
const mag = Math.max(2, Math.min(14, vol * 0.16));
|
const mag = Math.max(2, Math.min(14, vol * 0.16));
|
||||||
const t = (performance.now() - vuWobbleStart) / 1000;
|
const t = (performance.now() - vuWobbleStart) / 1000;
|
||||||
|
|||||||
Reference in New Issue
Block a user