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
+70 -23
View File
@@ -4599,23 +4599,31 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
metadata lives in the masthead beside the stage.
════════════════════════════════════════════════════════════════ */
/* Glow: soft ambient halo behind the sleeve */
/* Glow: soft ambient halo behind the sleeve.
Performance trick: render the image at 25% × 25% of the stage and
stretch it via transform: scale(4). Filter runs on the smaller
element (16× less area), and scale just upsamples the already-blurred
result. Visually identical to a blur(34px) over the full-size image
but ~10-16× cheaper on track-switch repaints. */
.vinyl-stage > #album-art-glow {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
top: 50%;
left: 50%;
width: 25%;
height: 25%;
border-radius: 0;
object-fit: cover;
filter: blur(34px) saturate(1.6);
filter: blur(9px) saturate(1.6);
opacity: 0.45;
z-index: 0;
pointer-events: none;
transform: scale(1.05);
transform: translate(-50%, -50%) scale(4.2);
transform-origin: center;
will-change: transform, opacity;
}
:root[data-theme="light"] .vinyl-stage > #album-art-glow {
opacity: 0.26;
filter: blur(40px) saturate(1.8);
filter: blur(10px) saturate(1.8);
}
/* Honour reduced-motion: kill breathing pulse */
@@ -4683,7 +4691,8 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
margin: 0;
background: transparent;
filter: contrast(0.96) saturate(0.92);
transition: filter 0.6s ease;
/* No transition on filter: the values are static and a 600ms ease
on a track-switch swap would force a long compositor pass. */
}
/* Crossfade on artwork swap. Class toggled by player.js right before
@@ -6576,6 +6585,7 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
}
@media (max-width: 980px) {
.now-playing { grid-template-columns: 1fr; gap: 40px; }
.now-playing .track-masthead { padding-right: 0; }
}
/* Vinyl/disc/tonearm rules live in the SLEEVE FRAME section under
@@ -6598,6 +6608,7 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
flex-direction: column;
justify-content: center;
padding-top: 0;
padding-right: clamp(12px, 1.5vw, 24px);
gap: 0;
}
@@ -6702,7 +6713,9 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-faint);
/* Match the .kicker (NOW PLAYING) color so the metadata grid reads
as part of the same typographic system. */
color: var(--copper);
margin-bottom: 8px;
}
.now-playing .meta-cell .value {
@@ -6753,21 +6766,24 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
opacity: 0.92;
transform-origin: bottom;
border-radius: 99px 99px 0 0;
height: var(--bar-h, 40%);
/* Bars are full-height boxes; the visible height is driven by
transform: scaleY. Transforms are GPU-composited, so per-frame
updates skip layout/paint entirely. */
height: 100%;
transform: scaleY(var(--bar-h-scale, 0.4));
animation: sr-snap-bar 1.1s ease-in-out infinite;
animation-delay: var(--bar-delay, 0s);
animation-play-state: paused;
transition: height 60ms linear;
will-change: height;
transition: transform 50ms linear;
will-change: transform;
}
:root[data-playstate="playing"] .now-playing .spectrum span {
animation-play-state: running;
}
/* When real audio data is driving heights, freeze the CSS animation
so JS-set heights aren't overridden by the keyframe. */
/* When real audio data is driving the bars, freeze the synthetic CSS
animation so JS-set transforms aren't overridden by the keyframe. */
body.audio-spectrum-live .now-playing .spectrum span {
animation: none !important;
transition: height 50ms linear;
}
@keyframes sr-snap-bar {
0%, 100% { transform: scaleY(0.4); }
@@ -6949,7 +6965,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
.now-playing .vu-volume #volume-slider {
-webkit-appearance: none;
appearance: none;
width: 80px;
width: 64px;
height: 2px;
background: var(--rule-strong);
border-radius: 0;
@@ -6985,7 +7001,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
.now-playing .vu-meter {
position: relative;
width: 140px;
width: 120px;
height: 60px;
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
border: 1px solid var(--rule-strong);
@@ -7044,6 +7060,28 @@ body.audio-spectrum-live .now-playing .spectrum span {
font-weight: 400;
}
/* Light theme — paper-faced VU meter (vintage cream gauge instead of black) */
:root[data-theme="light"] .now-playing .vu-meter {
background: linear-gradient(180deg, #FAF6EE 0%, #E8E0CE 100%);
border-color: var(--rule-strong);
box-shadow:
inset 0 1px 2px rgba(26, 23, 21, 0.08),
inset 0 0 24px rgba(31, 78, 61, 0.05);
}
:root[data-theme="light"] .now-playing .vu-meter::before {
background: repeating-conic-gradient(from 195deg at 50% 100%,
transparent 0deg 4deg,
rgba(26, 23, 21, 0.18) 4deg 5deg,
transparent 5deg 9deg);
}
:root[data-theme="light"] .now-playing .vu-meter::after {
color: var(--ink-mute);
}
:root[data-theme="light"] .now-playing .vu-needle {
background: linear-gradient(to top, var(--copper) 0%, var(--copper-lo) 70%, var(--ink) 100%);
box-shadow: 0 0 6px rgba(31, 78, 61, 0.25);
}
/* Mobile VU cluster: stack below controls */
@media (max-width: 720px) {
.now-playing .controls { flex-wrap: wrap; }
@@ -8771,18 +8809,27 @@ body.is-fullscreen-player .fs-bloom {
to { opacity: 0.22; transform: scale(1); }
}
/* Performance trick (matches the vinyl-stage glow): render the bloom
image at 20% of the viewport and stretch it via scale(~6). Blur runs
over a 25× smaller area, so a track-switch repaint of the bloom
collapses from O(viewport-pixels × 110² ) to O(viewport/25 × 18²). */
body.is-fullscreen-player .fs-bloom #fs-bloom-art {
width: 100%;
height: 100%;
position: absolute;
top: 50%;
left: 50%;
width: 20%;
height: 20%;
object-fit: cover;
filter: blur(110px) saturate(1.6);
transform: scale(1.18);
filter: blur(18px) saturate(1.6);
transform: translate(-50%, -50%) scale(5.9);
transform-origin: center;
animation: fs-bloom-drift 28s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes fs-bloom-drift {
from { transform: scale(1.18) translate3d(-1.5%, -1%, 0); }
to { transform: scale(1.22) translate3d(2%, 1.5%, 0); }
from { transform: translate(-50%, -50%) scale(5.9) translate3d(-1.5%, -1%, 0); }
to { transform: translate(-50%, -50%) scale(6.1) translate3d(2%, 1.5%, 0); }
}
/* Subtle paper-grain veil over the bloom — keeps it from looking flat. */