diff --git a/media_server/services/audio_analyzer.py b/media_server/services/audio_analyzer.py index 3b6afbf..1ce6df7 100644 --- a/media_server/services/audio_analyzer.py +++ b/media_server/services/audio_analyzer.py @@ -284,7 +284,18 @@ class AudioAnalyzer: counts = bin_ends - bin_starts 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() if max_val > 0: bins *= (1.0 / max_val) @@ -295,9 +306,10 @@ class AudioAnalyzer: # Round for compact JSON frequencies = np.round(bins, 3).tolist() bass = round(bass, 3) + level = round(level, 3) with self._lock: - self._data = {"frequencies": frequencies, "bass": bass} + self._data = {"frequencies": frequencies, "bass": bass, "level": level} # Throttle to target FPS elapsed = time.monotonic() - t0 diff --git a/media_server/static/js/player.js b/media_server/static/js/player.js index 8ab91d2..5df8de4 100644 --- a/media_server/static/js/player.js +++ b/media_server/static/js/player.js @@ -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 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; if (!bins.length) return null; let peak = 0; @@ -823,21 +827,20 @@ function startVuWobble() { const tick = () => { const needle = document.getElementById('vuNeedle'); if (needle) { - const slider = document.getElementById('volume-slider'); - const vol = slider ? Number(slider.value) || 0 : 0; - // Volume slider modulates how loud the audio could be; - // multiply against the captured level so muting drops the - // needle even though the source is still playing. + // Loopback capture is post-volume on Windows/macOS, so the + // measured level already reflects the output knob — no extra + // (vol/100) attenuation needed. const audioLevel = readAudioLevel(); let target; if (audioLevel != null) { - // Real audio: scale by output volume & apply attack/release - // smoothing for analog-feeling ballistics. - const wanted = audioLevel * (vol / 100); - const k = wanted > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE; - vuLevelSmoothed = vuLevelSmoothed * (1 - k) + wanted * k; + // Real audio: apply attack/release smoothing for + // analog-feeling ballistics. + const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE; + vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k; target = -22 + vuLevelSmoothed * 44; } else { + const slider = document.getElementById('volume-slider'); + const vol = slider ? Number(slider.value) || 0 : 0; const base = -22 + (vol / 100) * 44; const mag = Math.max(2, Math.min(14, vol * 0.16)); const t = (performance.now() - vuWobbleStart) / 1000;