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:
2026-04-25 12:16:41 +03:00
parent f2c82164e8
commit b09569f390
2 changed files with 28 additions and 13 deletions
+14 -11
View File
@@ -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;