fix(player): real audio level on VU; full-width spectrum; hide canvas under vinyl
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).
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user