perf(visualizer): cut spectrum + track-switch CPU significantly
Lint & Test / test (push) Successful in 10s

Frontend hot path (player.js, background.js):
- visualizer rAF: drop per-frame getComputedStyle('--accent') (cached on
  applyAccentColor), build canvas LinearGradient once per accent change
  instead of 32× per frame, batch all bars into a single beginPath/fill
- FPS-gate canvas redraw via frequencyDataVersion so 60-144 Hz monitors
  stop re-rendering identical frames produced at 30 Hz on the backend
- editorial spectrum bars: replace style.height (layout) with
  transform: scaleY (compositor-only); cache bar refs, pre-compute
  per-bar gain/range, dedup writes at 1/1000 quantization
- coalesce VU needle into the visualizer rAF; cache vuNeedle ref;
  dedup angle writes at 0.1°
- updateUI: status-payload fingerprint short-circuits the redundant
  status_update broadcasts that fire during a track change
- swapArtworkSrc: only force layout reflow when keyframe is in flight;
  drop the ?_=Date.now() cache-buster so identical artwork URLs reuse
  the decoded bitmap; mini/glow imgs only re-set src when changed
- drop the fullscreen MutationObserver — fs-bloom-art is mirrored
  directly from the artwork-swap path, eliminating the second blur paint
- updateProgress: skip text writes when the rounded second hasn't moved;
  POSITION_INTERPOLATION_MS 100 → 250
- background.js: lift resizeBackgroundCanvas out of the rAF body, cache
  step, accept new int-scaled wire format

CSS:
- spectrum bars use transform: scaleY(var(--bar-h-scale)) + transition
  on transform; will-change updated to transform
- album-art-glow and fs-bloom-art switched to small-source-blur trick
  (render at 20-25% size, scale 4-6×, lower blur radius) — visually
  equivalent, ~10-25× cheaper repaint on track change
- drop unused transition: filter on .vinyl-stage #album-art

Backend (audio_analyzer.py, websocket_manager.py):
- pre-allocate windowed and cumsum buffers; replace
  np.concatenate(([0.0], np.cumsum(...))) with cumsum[0]=0 +
  np.cumsum(out=cumsum[1:]); float32 hanning window
- RMS via np.dot(mono, mono) — no astype copy, no ** temp
- int16 wire format (scale=1000) — smaller JSON, no Python float boxing
- versioned data + threading.Event so _audio_broadcast_loop is event-
  driven (ev.wait + monotonic seq dedup) instead of polling on a timer
  with the always-false `data is _last_data` identity check

ruff clean, pytest 7 passed / 3 numpy-skipped, esbuild bundle 113.6 kB.
This commit is contained in:
2026-04-25 18:05:57 +03:00
parent 08c3c80df4
commit 51ec1503f4
7 changed files with 534 additions and 205 deletions
+34 -8
View File
@@ -236,27 +236,54 @@ export function updateBackgroundColors() {
// ---- Render loop ----
// Cached step into the bins array; recomputed only when bins.length
// changes (which happens at most once after the first audio frame
// arrives or when num_bins is reconfigured).
let bgBinsLength = -1;
let bgBinsStep = 1;
// Last applied resolution — drawing with stale viewport is harmless,
// but we still need to refresh the uniform after the resize listener
// has updated the canvas.
let bgLastResW = -1;
let bgLastResH = -1;
function renderBackgroundFrame() {
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
const gl = bgGL;
if (!gl || !bgUniforms) return;
resizeBackgroundCanvas();
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
// Resize listener already keeps canvas dimensions in sync — only
// touch the viewport when the canvas actually changed size, so the
// per-frame path doesn't read window.innerWidth (a layout-flushing
// property).
if (bgCanvas.width !== bgLastResW || bgCanvas.height !== bgLastResH) {
bgLastResW = bgCanvas.width;
bgLastResH = bgCanvas.height;
gl.viewport(0, 0, bgLastResW, bgLastResH);
gl.uniform2f(bgUniforms.resolution, bgLastResW, bgLastResH);
}
const time = performance.now() / 1000 - bgStartTime;
// Smooth audio data from the imported frequencyData (shared with visualizer)
// Smooth audio data from the imported frequencyData (shared with visualizer).
// Backend may send float bins (legacy) or int×1000 (new); .scale tells us which.
if (frequencyData && frequencyData.frequencies) {
const bins = frequencyData.frequencies;
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
const scale = frequencyData.scale && frequencyData.scale > 0
? 1.0 / frequencyData.scale : 1.0;
if (bins.length !== bgBinsLength) {
bgBinsLength = bins.length;
bgBinsStep = Math.max(1, Math.floor(bgBinsLength / BG_BAND_COUNT));
}
const step = bgBinsStep;
for (let i = 0; i < BG_BAND_COUNT; i++) {
const idx = Math.min(i * step, bins.length - 1);
const target = bins[idx] || 0;
let idx = i * step;
if (idx >= bgBinsLength) idx = bgBinsLength - 1;
const target = (bins[idx] || 0) * scale;
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
}
const targetBass = frequencyData.bass || 0;
const targetBass = (frequencyData.bass || 0) * scale;
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
} else {
// Gentle decay when no audio
@@ -267,7 +294,6 @@ function renderBackgroundFrame() {
}
// Set uniforms (locations cached at init, colors cached on change)
gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height);
gl.uniform1f(bgUniforms.time, time);
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);