From 968eb156bc8dc5d3859d18556539a36168cc2278 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 02:27:56 +0300 Subject: [PATCH] fix(player): real audio level on VU; full-width spectrum; hide canvas under vinyl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VU needle reflects actual audio output - Was just a synthetic wobble bounded by the volume slider value. Now reads RMS of the FFT bins (skipping bin 0 / DC) the visualizer feeds in, multiplies by current volume, and applies attack/release smoothing for analog-feeling ballistics. - Falls back to the synthetic wobble when audio capture isn't running so the needle still looks alive on the static fallback. - When playback stops, needle settles to the bottom of the swing (-45deg) instead of holding the volume position. Spectrum width — actually fixed this time - Root cause: CSS repeat() does NOT accept a CSS variable for its count argument, so my `repeat(var(--spectrum-bars), 1fr)` rule was invalid and silently dropped, leaving the legacy/auto sizing behavior. Set grid-template-columns directly from JS to `repeat(40, minmax(0, 1fr))`. - CSS retains a `repeat(40, minmax(0, 1fr))` literal as a default so the row renders sane even before JS executes. Spectrogram canvas under vinyl - Hidden via display: none — the editorial .spectrum row already shows the audio spectrum; the canvas was redundant and ugly. Element stays in DOM so the visualizer JS keeps rendering (drives album-art bass-pulse + dynamic background bands). --- media_server/static/css/styles.css | 28 +++++------- media_server/static/js/app.js | 8 ++-- media_server/static/js/player.js | 71 +++++++++++++++++++++--------- 3 files changed, 64 insertions(+), 43 deletions(-) diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 3fa38d6..03e628b 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -6711,21 +6711,12 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; } @keyframes sr-snap-spin { to { transform: rotate(360deg); } } -/* Spectrogram canvas hidden by default; toggle reveals it. */ +/* Spectrogram canvas: hidden — the editorial .spectrum row in the + track-masthead already shows the audio spectrum. The canvas + element stays in the DOM so the visualizer JS keeps rendering + (drives the album-art bass-pulse + dynamic background). */ .now-playing .spectrogram-canvas { - position: absolute; - bottom: -52px; - left: 7%; right: 7%; - width: 86%; - height: 38px; - border-radius: 0; - opacity: 0; - transition: opacity 240ms var(--ease); - pointer-events: none; -} -.now-playing.visualizer-active .spectrogram-canvas, -body.visualizer-active .now-playing .spectrogram-canvas { - opacity: 0.6; + display: none !important; } /* ─── Track masthead ──────────────────────────────────────── */ @@ -6867,11 +6858,12 @@ body.visualizer-active .now-playing .spectrogram-canvas { /* ─── Spectrum bars (JS-injected; real audio from backend FFT) ── */ .now-playing .spectrum { - /* Explicit equal-width columns. The CSS variable --spectrum-bars - is set by JS so adding/removing bars stays in sync. */ + /* grid-template-columns is set from JS to repeat(N, minmax(0, 1fr)) + because CSS repeat() does NOT accept a CSS variable as its count. + Falling back to a 40-column default ensures something sane renders + even if JS hasn't executed yet. */ display: grid !important; - grid-template-columns: repeat(var(--spectrum-bars, 40), minmax(0, 1fr)) !important; - grid-auto-flow: column; + grid-template-columns: repeat(40, minmax(0, 1fr)); align-items: end; column-gap: 4px; height: 70px; diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js index d9e31ce..019af6c 100644 --- a/media_server/static/js/app.js +++ b/media_server/static/js/app.js @@ -172,9 +172,11 @@ window.addEventListener('DOMContentLoaded', async () => { const spectrumRoot = document.getElementById('player-spectrum'); if (spectrumRoot && !spectrumRoot.children.length) { const SPECTRUM_BARS = 40; - // Sync the grid column count to the bar count so the row truly - // fills the column even if the bar count changes later. - spectrumRoot.style.setProperty('--spectrum-bars', SPECTRUM_BARS); + // CSS repeat() doesn't accept a var() for its count — set the + // grid column template from JS so it always matches the bar + // count and stretches each bar to claim 1fr of the row. + spectrumRoot.style.gridTemplateColumns = + `repeat(${SPECTRUM_BARS}, minmax(0, 1fr))`; const frag = document.createDocumentFragment(); for (let i = 0; i < SPECTRUM_BARS; i++) { const s = document.createElement('span'); diff --git a/media_server/static/js/player.js b/media_server/static/js/player.js index 80ad74f..d791ae3 100644 --- a/media_server/static/js/player.js +++ b/media_server/static/js/player.js @@ -795,13 +795,30 @@ export function updateUI(status) { } } -// ─── VU needle synthetic wobble ────────────────────────────── -// Real audio-level analysis is only available when the visualizer -// is enabled. For the common case (visualizer off), drive the needle -// with a low-frequency pseudo-random walk that's bounded by current -// volume, so it looks alive without being noisy. +// ─── VU needle ─────────────────────────────────────────────── +// The needle reflects ACTUAL audio output level (computed from the +// FFT data the visualizer feeds in). When audio capture isn't +// running, fall back to a synthetic wobble bounded by the volume +// slider position so the needle still looks alive. let vuWobbleHandle = null; let vuWobbleStart = 0; +let vuLevelSmoothed = 0; // Smoothed RMS of recent frequency frames +const VU_LEVEL_ATTACK = 0.55; // How fast needle climbs to a peak +const VU_LEVEL_RELEASE = 0.12; // How fast it falls back + +function readAudioLevel() { + // frequencyData is the WS-driven FFT payload from player.js scope. + if (!frequencyData || !frequencyData.frequencies) return null; + const bins = frequencyData.frequencies; + if (!bins.length) return null; + let sumSq = 0; + // Skip the very lowest bin (DC + sub-rumble) for cleaner level. + for (let i = 1; i < bins.length; i++) sumSq += bins[i] * bins[i]; + const rms = Math.sqrt(sumSq / (bins.length - 1)); + // The values are in 0..1 from the backend; gain a touch so quieter + // tracks still swing the needle. + return Math.min(1, rms * 1.6); +} function startVuWobble() { if (vuWobbleHandle) return; @@ -811,16 +828,30 @@ function startVuWobble() { if (needle) { const slider = document.getElementById('volume-slider'); const vol = slider ? Number(slider.value) || 0 : 0; - const base = -45 + (vol / 100) * 90; - // Wobble magnitude scales with volume, capped at ~12deg either way. - const mag = Math.max(2, Math.min(14, vol * 0.16)); - const t = (performance.now() - vuWobbleStart) / 1000; - // Two combined sines + a tiny random component for organic motion. - const wobble = - Math.sin(t * 6.3) * mag * 0.55 + - Math.sin(t * 11.7 + 1.3) * mag * 0.30 + - (Math.random() - 0.5) * mag * 0.30; - needle.style.transform = `rotate(${base + wobble}deg)`; + // 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. + 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; + // Map 0..1 to -45deg..+45deg. + target = -45 + vuLevelSmoothed * 90; + } else { + // Synthetic fallback: volume-mapped + sine wobble. + const base = -45 + (vol / 100) * 90; + const mag = Math.max(2, Math.min(14, vol * 0.16)); + const t = (performance.now() - vuWobbleStart) / 1000; + target = base + + Math.sin(t * 6.3) * mag * 0.55 + + Math.sin(t * 11.7 + 1.3) * mag * 0.30 + + (Math.random() - 0.5) * mag * 0.30; + } + needle.style.transform = `rotate(${target}deg)`; } vuWobbleHandle = requestAnimationFrame(tick); }; @@ -832,14 +863,10 @@ function stopVuWobble() { cancelAnimationFrame(vuWobbleHandle); vuWobbleHandle = null; } - // Settle needle back to the static volume-mapped position. + vuLevelSmoothed = 0; + // Settle needle back to the bottom of the swing. const needle = document.getElementById('vuNeedle'); - const slider = document.getElementById('volume-slider'); - if (needle && slider) { - const vol = Number(slider.value) || 0; - const base = -45 + (vol / 100) * 90; - needle.style.transform = `rotate(${base}deg)`; - } + if (needle) needle.style.transform = 'rotate(-45deg)'; } export function updatePlaybackState(state) {