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 -19
View File
@@ -1,6 +1,7 @@
"""Audio spectrum analyzer service using system loopback capture.""" """Audio spectrum analyzer service using system loopback capture."""
import logging import logging
import math
import platform import platform
import threading import threading
import time import time
@@ -71,6 +72,14 @@ class AudioAnalyzer:
self._lifecycle_lock = threading.Lock() self._lifecycle_lock = threading.Lock()
self._data: dict | None = None self._data: dict | None = None
self._current_device_name: str | None = None self._current_device_name: str | None = None
# Generation counter — bumped each time _data is refreshed.
# Lets the broadcast loop dedupe without comparing dict identity
# (which is fragile because we always allocate a new dict).
self._data_seq = 0
# Threading.Event signaled when new frame data is available.
# The broadcast loop awaits this instead of polling on a timer,
# so it wakes up exactly once per produced frame.
self._data_event = threading.Event()
# Slow AGC envelope so the spectrum reflects real dynamics # Slow AGC envelope so the spectrum reflects real dynamics
# instead of being renormalized to peak=1.0 every frame. # instead of being renormalized to peak=1.0 every frame.
# A loud transient (e.g. notification beep) lifts the reference # A loud transient (e.g. notification beep) lifts the reference
@@ -128,17 +137,30 @@ class AudioAnalyzer:
"""Stop audio capture and cleanup.""" """Stop audio capture and cleanup."""
with self._lifecycle_lock: with self._lifecycle_lock:
self._running = False self._running = False
# Wake any waiter so it can observe _running and exit cleanly.
self._data_event.set()
if self._thread: if self._thread:
self._thread.join(timeout=3.0) self._thread.join(timeout=3.0)
self._thread = None self._thread = None
with self._lock: with self._lock:
self._data = None self._data = None
self._data_event.clear()
def get_frequency_data(self) -> dict | None: def get_frequency_data(self) -> dict | None:
"""Return latest frequency data (thread-safe). None if not running.""" """Return latest frequency data (thread-safe). None if not running."""
with self._lock: with self._lock:
return self._data return self._data
def get_frequency_data_versioned(self) -> tuple[dict | None, int]:
"""Return (data, seq) so callers can dedupe without identity tricks."""
with self._lock:
return self._data, self._data_seq
@property
def data_event(self) -> threading.Event:
"""Event signaled when a fresh frame is ready. Caller must clear()."""
return self._data_event
@staticmethod @staticmethod
def list_loopback_devices() -> list[dict[str, str]]: def list_loopback_devices() -> list[dict[str, str]]:
"""List all available loopback audio devices.""" """List all available loopback audio devices."""
@@ -250,12 +272,24 @@ class AudioAnalyzer:
return return
interval = 1.0 / self.target_fps interval = 1.0 / self.target_fps
window = np.hanning(self.chunk_size) # Float32 window — matches soundcard's typical buffer dtype and
# halves FFT memory traffic vs. the default float64.
window = np.hanning(self.chunk_size).astype(np.float32)
# Pre-compute bin edge pairs for vectorized grouping # Pre-compute bin edge pairs for vectorized grouping
edges = self._bin_edges edges = self._bin_edges
bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp) bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp)
bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp) bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp)
# Counts are constant — compute once.
bin_counts = (bin_ends - bin_starts).astype(np.float32)
# Pre-allocate working buffers so the per-frame allocator churn
# on the capture thread (which runs at target_fps Hz, hours on
# end) drops to zero copies for these arrays.
fft_size = self.chunk_size // 2 + 1
windowed = np.empty(self.chunk_size, dtype=np.float32)
cumsum = np.empty(fft_size + 1, dtype=np.float32)
cumsum[0] = 0.0
try: try:
with device.recorder( with device.recorder(
@@ -284,21 +318,23 @@ class AudioAnalyzer:
time.sleep(interval) time.sleep(interval)
continue continue
# Apply window and compute FFT # Apply window in-place into the pre-allocated buffer.
windowed = mono[:self.chunk_size] * window np.multiply(mono[:self.chunk_size], window, out=windowed)
fft_mag = np.abs(np.fft.rfft(windowed)) fft_mag = np.abs(np.fft.rfft(windowed))
# Group into logarithmic bins (vectorized via cumsum) # Group into logarithmic bins (vectorized via cumsum).
cumsum = np.concatenate(([0.0], np.cumsum(fft_mag))) # Write into the pre-allocated [1:] slice so cumsum[0]
counts = bin_ends - bin_starts # stays 0.0 and we never allocate a new array.
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts np.cumsum(fft_mag, out=cumsum[1:])
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / bin_counts
# True loudness from time-domain RMS, mapped via dB # True loudness from time-domain RMS via single BLAS
# so the VU needle reflects actual program level — not # dot — avoids astype() and ** allocations.
# the per-frame-normalized spectrum. mono32 = mono if mono.dtype == np.float32 else mono.astype(np.float32, copy=False)
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2))) energy = float(np.dot(mono32, mono32))
if rms > 1e-6: if energy > 1e-12:
db = 20.0 * np.log10(rms) rms = (energy / mono32.size) ** 0.5
db = 20.0 * math.log10(rms)
# Map -60 dB..-6 dB to 0..1 (typical music range) # Map -60 dB..-6 dB to 0..1 (typical music range)
level = max(0.0, min(1.0, (db + 60.0) / 54.0)) level = max(0.0, min(1.0, (db + 60.0) / 54.0))
else: else:
@@ -314,18 +350,33 @@ class AudioAnalyzer:
else: else:
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005 self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005
ref = max(self._spectrum_ref, 1e-4) ref = max(self._spectrum_ref, 1e-4)
bins = np.clip(bins / ref, 0.0, 1.5) np.divide(bins, ref, out=bins)
np.clip(bins, 0.0, 1.5, out=bins)
# Bass energy: average of first 4 bins (~20-200Hz) # Bass energy: average of first 4 bins (~20-200Hz)
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0 bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
# Round for compact JSON # Quantize to 0..1000 ints — same wire fidelity as
frequencies = np.round(bins, 3).tolist() # 3-decimal floats but smaller GC churn on both ends
bass = round(bass, 3) # (frontend smooths anyway, so quantization is
level = round(level, 3) # invisible). JSON encodes ints faster than floats.
frequencies = (bins * 1000.0).astype(np.int16).tolist()
bass_i = int(bass * 1000.0)
level_i = int(level * 1000.0)
new_data = {
"frequencies": frequencies,
"bass": bass_i,
"level": level_i,
# Wire-format flag: clients that see this know
# values are 0..1000 ints, not 0..1 floats.
"scale": 1000,
}
with self._lock: with self._lock:
self._data = {"frequencies": frequencies, "bass": bass, "level": level} self._data = new_data
self._data_seq += 1
# Wake any broadcast loop waiting on fresh data.
self._data_event.set()
# Throttle to target FPS # Throttle to target FPS
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
+33 -13
View File
@@ -161,26 +161,48 @@ class ConnectionManager:
self._audio_task = None self._audio_task = None
async def _audio_broadcast_loop(self) -> None: async def _audio_broadcast_loop(self) -> None:
"""Background loop: read frequency data from analyzer and broadcast to subscribers.""" """Background loop: read frequency data from analyzer and broadcast to subscribers.
from ..config import settings
interval = 1.0 / settings.visualizer_fps
_last_data = None Event-driven: blocks on the analyzer's data_event so it wakes up
exactly once per produced frame, instead of polling on a timer.
Backstop sleep applies when capture is idle / has no subscribers.
"""
from ..config import settings
idle_interval = 1.0 / max(1, settings.visualizer_fps)
# Bounded wait so we still notice subscribe/unsubscribe transitions.
wake_timeout = max(0.05, idle_interval)
loop = asyncio.get_event_loop()
last_seq = -1
while True: while True:
try: try:
async with self._lock: async with self._lock:
subscribers = list(self._visualizer_subscribers) subscribers = list(self._visualizer_subscribers)
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running: analyzer = self._audio_analyzer
await asyncio.sleep(interval) if not subscribers or not analyzer or not analyzer.running:
await asyncio.sleep(idle_interval)
continue continue
data = self._audio_analyzer.get_frequency_data() # Wait off-loop for a fresh frame. The capture thread sets
if data is None or data is _last_data: # data_event after each FFT update; we clear it before the
await asyncio.sleep(interval) # next wait so we never burn a wake on stale data.
ev = analyzer.data_event
def _wait() -> bool:
return ev.wait(wake_timeout)
got = await loop.run_in_executor(None, _wait)
if not got:
# Timeout — loop around to re-check subscriber state.
continue continue
_last_data = data ev.clear()
data, seq = analyzer.get_frequency_data_versioned()
if data is None or seq == last_seq:
continue
last_seq = seq
# Pre-serialize once for all subscribers (avoids per-client JSON encoding) # Pre-serialize once for all subscribers (avoids per-client JSON encoding)
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':')) text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
@@ -198,13 +220,11 @@ class ConnectionManager:
for ws in failed: for ws in failed:
await self.disconnect(ws) await self.disconnect(ws)
await asyncio.sleep(interval)
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.error("Error in audio broadcast: %s", e) logger.error("Error in audio broadcast: %s", e)
await asyncio.sleep(interval) await asyncio.sleep(idle_interval)
def status_changed( def status_changed(
self, old: dict[str, Any] | None, new: dict[str, Any] self, old: dict[str, Any] | None, new: dict[str, Any]
+70 -23
View File
@@ -4599,23 +4599,31 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
metadata lives in the masthead beside the stage. 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 { .vinyl-stage > #album-art-glow {
position: absolute; position: absolute;
inset: 0; top: 50%;
width: 100%; left: 50%;
height: 100%; width: 25%;
height: 25%;
border-radius: 0; border-radius: 0;
object-fit: cover; object-fit: cover;
filter: blur(34px) saturate(1.6); filter: blur(9px) saturate(1.6);
opacity: 0.45; opacity: 0.45;
z-index: 0; z-index: 0;
pointer-events: none; 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 { :root[data-theme="light"] .vinyl-stage > #album-art-glow {
opacity: 0.26; opacity: 0.26;
filter: blur(40px) saturate(1.8); filter: blur(10px) saturate(1.8);
} }
/* Honour reduced-motion: kill breathing pulse */ /* Honour reduced-motion: kill breathing pulse */
@@ -4683,7 +4691,8 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
margin: 0; margin: 0;
background: transparent; background: transparent;
filter: contrast(0.96) saturate(0.92); 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 /* 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) { @media (max-width: 980px) {
.now-playing { grid-template-columns: 1fr; gap: 40px; } .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 /* 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; flex-direction: column;
justify-content: center; justify-content: center;
padding-top: 0; padding-top: 0;
padding-right: clamp(12px, 1.5vw, 24px);
gap: 0; gap: 0;
} }
@@ -6702,7 +6713,9 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
font-size: 9px; font-size: 9px;
letter-spacing: 0.22em; letter-spacing: 0.22em;
text-transform: uppercase; 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; margin-bottom: 8px;
} }
.now-playing .meta-cell .value { .now-playing .meta-cell .value {
@@ -6753,21 +6766,24 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
opacity: 0.92; opacity: 0.92;
transform-origin: bottom; transform-origin: bottom;
border-radius: 99px 99px 0 0; 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: sr-snap-bar 1.1s ease-in-out infinite;
animation-delay: var(--bar-delay, 0s); animation-delay: var(--bar-delay, 0s);
animation-play-state: paused; animation-play-state: paused;
transition: height 60ms linear; transition: transform 50ms linear;
will-change: height; will-change: transform;
} }
:root[data-playstate="playing"] .now-playing .spectrum span { :root[data-playstate="playing"] .now-playing .spectrum span {
animation-play-state: running; animation-play-state: running;
} }
/* When real audio data is driving heights, freeze the CSS animation /* When real audio data is driving the bars, freeze the synthetic CSS
so JS-set heights aren't overridden by the keyframe. */ animation so JS-set transforms aren't overridden by the keyframe. */
body.audio-spectrum-live .now-playing .spectrum span { body.audio-spectrum-live .now-playing .spectrum span {
animation: none !important; animation: none !important;
transition: height 50ms linear;
} }
@keyframes sr-snap-bar { @keyframes sr-snap-bar {
0%, 100% { transform: scaleY(0.4); } 0%, 100% { transform: scaleY(0.4); }
@@ -6949,7 +6965,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
.now-playing .vu-volume #volume-slider { .now-playing .vu-volume #volume-slider {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 80px; width: 64px;
height: 2px; height: 2px;
background: var(--rule-strong); background: var(--rule-strong);
border-radius: 0; border-radius: 0;
@@ -6985,7 +7001,7 @@ body.audio-spectrum-live .now-playing .spectrum span {
.now-playing .vu-meter { .now-playing .vu-meter {
position: relative; position: relative;
width: 140px; width: 120px;
height: 60px; height: 60px;
background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%); background: linear-gradient(180deg, #1a1610 0%, #0e0c08 100%);
border: 1px solid var(--rule-strong); border: 1px solid var(--rule-strong);
@@ -7044,6 +7060,28 @@ body.audio-spectrum-live .now-playing .spectrum span {
font-weight: 400; 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 */ /* Mobile VU cluster: stack below controls */
@media (max-width: 720px) { @media (max-width: 720px) {
.now-playing .controls { flex-wrap: wrap; } .now-playing .controls { flex-wrap: wrap; }
@@ -8771,18 +8809,27 @@ body.is-fullscreen-player .fs-bloom {
to { opacity: 0.22; transform: scale(1); } 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 { body.is-fullscreen-player .fs-bloom #fs-bloom-art {
width: 100%; position: absolute;
height: 100%; top: 50%;
left: 50%;
width: 20%;
height: 20%;
object-fit: cover; object-fit: cover;
filter: blur(110px) saturate(1.6); filter: blur(18px) saturate(1.6);
transform: scale(1.18); transform: translate(-50%, -50%) scale(5.9);
transform-origin: center;
animation: fs-bloom-drift 28s ease-in-out infinite alternate; animation: fs-bloom-drift 28s ease-in-out infinite alternate;
will-change: transform;
} }
@keyframes fs-bloom-drift { @keyframes fs-bloom-drift {
from { transform: scale(1.18) translate3d(-1.5%, -1%, 0); } from { transform: translate(-50%, -50%) scale(5.9) translate3d(-1.5%, -1%, 0); }
to { transform: scale(1.22) translate3d(2%, 1.5%, 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. */ /* Subtle paper-grain veil over the bloom — keeps it from looking flat. */
+4 -2
View File
@@ -180,8 +180,10 @@ window.addEventListener('DOMContentLoaded', async () => {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
for (let i = 0; i < SPECTRUM_BARS; i++) { for (let i = 0; i < SPECTRUM_BARS; i++) {
const s = document.createElement('span'); const s = document.createElement('span');
// Pseudo-random heights for the synthetic CSS animation phase // Pseudo-random initial scaleY for the synthetic CSS-only
s.style.setProperty('--bar-h', (25 + Math.abs(Math.sin(i * 0.7)) * 70).toFixed(0) + '%'); // animation (used while no real audio is flowing).
const scale = (0.25 + Math.abs(Math.sin(i * 0.7)) * 0.70).toFixed(2);
s.style.setProperty('--bar-h-scale', scale);
s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's'); s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's');
frag.appendChild(s); frag.appendChild(s);
} }
+34 -8
View File
@@ -236,27 +236,54 @@ export function updateBackgroundColors() {
// ---- Render loop ---- // ---- 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() { function renderBackgroundFrame() {
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame); bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
const gl = bgGL; const gl = bgGL;
if (!gl || !bgUniforms) return; if (!gl || !bgUniforms) return;
resizeBackgroundCanvas(); // Resize listener already keeps canvas dimensions in sync — only
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height); // 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; 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) { if (frequencyData && frequencyData.frequencies) {
const bins = 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++) { for (let i = 0; i < BG_BAND_COUNT; i++) {
const idx = Math.min(i * step, bins.length - 1); let idx = i * step;
const target = bins[idx] || 0; if (idx >= bgBinsLength) idx = bgBinsLength - 1;
const target = (bins[idx] || 0) * scale;
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING); 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); bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
} else { } else {
// Gentle decay when no audio // Gentle decay when no audio
@@ -267,7 +294,6 @@ function renderBackgroundFrame() {
} }
// Set uniforms (locations cached at init, colors cached on change) // 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.time, time);
gl.uniform1f(bgUniforms.bass, bgSmoothedBass); gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands); gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
+4 -1
View File
@@ -140,7 +140,10 @@ export function cacheDom() {
// Timing constants // Timing constants
export const VOLUME_THROTTLE_MS = 16; export const VOLUME_THROTTLE_MS = 16;
export const POSITION_INTERPOLATION_MS = 100; // 250ms is plenty for sub-second progress; the inline updateProgress
// also short-circuits when the rounded second hasn't moved, so there's
// no visible difference for the user.
export const POSITION_INTERPOLATION_MS = 250;
export const SEARCH_DEBOUNCE_MS = 200; export const SEARCH_DEBOUNCE_MS = 200;
export const TOAST_DURATION_MS = 3000; export const TOAST_DURATION_MS = 3000;
export const WS_BACKOFF_BASE_MS = 3000; export const WS_BACKOFF_BASE_MS = 3000;
+303 -123
View File
@@ -165,6 +165,9 @@ export function applyAccentColor(color, hover) {
const dot = document.getElementById('accentDot'); const dot = document.getElementById('accentDot');
if (dot) dot.style.background = color; if (dot) dot.style.background = color;
updateBackgroundColors(); updateBackgroundColors();
// Refresh the cached accent in the visualizer so the gradient
// rebuilds on its next frame instead of querying CSS every frame.
refreshVisualizerAccent();
} }
export function renderAccentSwatches() { export function renderAccentSwatches() {
@@ -215,12 +218,49 @@ export function setVisualizerEnabled(value) {
visualizerEnabled = !!value; visualizerEnabled = !!value;
localStorage.setItem('visualizerEnabled', visualizerEnabled); localStorage.setItem('visualizerEnabled', visualizerEnabled);
} }
let visualizerCanvas = null; // Cached canvas DOM ref
let visualizerCtx = null; let visualizerCtx = null;
let visualizerGradient = null; // Pre-built gradient (rebuilt on accent change / resize)
let visualizerAnimFrame = null; let visualizerAnimFrame = null;
export let frequencyData = null; export let frequencyData = null; // Latest payload from backend (int-scaled or float-scaled)
export function setFrequencyData(value) { frequencyData = value; } let frequencyDataVersion = 0; // Bumped on every setFrequencyData
let lastRenderedVersion = -1; // Last version rendered in renderVisualizerFrame
let frequenciesScale = 1.0; // Backend scale factor (1000 → ints, 1 → floats)
export function setFrequencyData(value) {
frequencyData = value;
frequencyDataVersion++;
// Backend may send integer-quantized bins (scale=1000) or legacy floats (no scale).
if (value && typeof value.scale === 'number' && value.scale > 0) {
frequenciesScale = 1.0 / value.scale;
} else {
frequenciesScale = 1.0;
}
}
let smoothedFrequencies = null; let smoothedFrequencies = null;
const VISUALIZER_SMOOTHING = 0.15; const VISUALIZER_SMOOTHING = 0.15;
// Cached accent — refreshed by applyAccentColor() rather than on every frame.
let cachedAccentHex = '#1db954';
let cachedAccentRGB = '29,185,84';
function parseAccentHex(hex) {
const h = (hex || '').trim().replace('#', '');
if (h.length < 6) return null;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
return `${r},${g},${b}`;
}
export function refreshVisualizerAccent() {
const accentHex = getComputedStyle(document.documentElement)
.getPropertyValue('--accent').trim();
if (accentHex) {
cachedAccentHex = accentHex;
const rgb = parseAccentHex(accentHex);
if (rgb) cachedAccentRGB = rgb;
}
// Force gradient rebuild on next frame.
visualizerGradient = null;
}
export async function checkVisualizerAvailability() { export async function checkVisualizerAvailability() {
try { try {
@@ -274,15 +314,28 @@ export function applyVisualizerMode() {
} }
function initVisualizerCanvas() { function initVisualizerCanvas() {
const canvas = document.getElementById('spectrogram-canvas'); visualizerCanvas = document.getElementById('spectrogram-canvas');
if (!canvas) return; if (!visualizerCanvas) return;
visualizerCtx = canvas.getContext('2d'); visualizerCtx = visualizerCanvas.getContext('2d');
canvas.width = 300; visualizerCanvas.width = 300;
canvas.height = 64; visualizerCanvas.height = 64;
visualizerGradient = null; // Force rebuild
refreshVisualizerAccent();
}
function buildVisualizerGradient() {
if (!visualizerCtx || !visualizerCanvas) return null;
const h = visualizerCanvas.height;
const grad = visualizerCtx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, `rgba(${cachedAccentRGB},1)`);
grad.addColorStop(1, `rgba(${cachedAccentRGB},0.19)`);
return grad;
} }
function startVisualizerRender() { function startVisualizerRender() {
if (visualizerAnimFrame) return; if (visualizerAnimFrame) return;
// Cache editorial spectrum bar refs once per start.
cacheEditorialSpectrumBars();
renderVisualizerFrame(); renderVisualizerFrame();
} }
@@ -291,62 +344,70 @@ export function stopVisualizerRender() {
cancelAnimationFrame(visualizerAnimFrame); cancelAnimationFrame(visualizerAnimFrame);
visualizerAnimFrame = null; visualizerAnimFrame = null;
} }
const canvas = document.getElementById('spectrogram-canvas'); if (visualizerCtx && visualizerCanvas) {
if (visualizerCtx && canvas) { visualizerCtx.clearRect(0, 0, visualizerCanvas.width, visualizerCanvas.height);
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
} }
frequencyData = null; frequencyData = null;
frequencyDataVersion++; // Force next render to redraw cleared state
lastRenderedVersion = -1;
smoothedFrequencies = null; smoothedFrequencies = null;
document.body.classList.remove('audio-spectrum-live'); document.body.classList.remove('audio-spectrum-live');
// Reset spectrum bar heights so the synthetic CSS animation takes back over // Reset spectrum bar transforms so the synthetic CSS animation takes back over.
document.querySelectorAll('.now-playing .spectrum > span').forEach(s => { if (editorialSpectrumBars) {
s.style.height = ''; for (let i = 0; i < editorialSpectrumBars.length; i++) {
}); editorialSpectrumBars[i].style.transform = '';
}
}
// Drop cached bars so next start re-queries.
editorialSpectrumBars = null;
editorialSpectrumLastScale = null;
} }
function renderVisualizerFrame() { function renderVisualizerFrame() {
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame); visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
const canvas = document.getElementById('spectrogram-canvas'); // VU needle + position progress always tick — they read live state
if (!frequencyData || !visualizerCtx || !canvas) return; // not bound to spectrum payloads. Keeping them in this single rAF
// is cheaper than running a second rAF loop just for the needle.
tickVuNeedle();
if (!frequencyData || !visualizerCtx || !visualizerCanvas) return;
// FPS gate: backend pushes ~visualizer_fps Hz; the monitor refreshes
// at 60-144 Hz. Re-rendering an unchanged frame is wasted work, so
// bail when no new payload has arrived since the last draw.
if (frequencyDataVersion === lastRenderedVersion) return;
lastRenderedVersion = frequencyDataVersion;
const bins = frequencyData.frequencies; const bins = frequencyData.frequencies;
const numBins = bins.length; const numBins = bins.length;
const w = canvas.width; const w = visualizerCanvas.width;
const h = canvas.height; const h = visualizerCanvas.height;
const gap = 2; const gap = 2;
const barWidth = (w / numBins) - gap; const barWidth = (w / numBins) - gap;
const accent = getComputedStyle(document.documentElement) const scale = frequenciesScale;
.getPropertyValue('--accent').trim();
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) { if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
smoothedFrequencies = new Array(numBins).fill(0); smoothedFrequencies = new Float32Array(numBins);
} }
for (let i = 0; i < numBins; i++) { for (let i = 0; i < numBins; i++) {
const v = bins[i] * scale;
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
+ bins[i] * (1 - VISUALIZER_SMOOTHING); + v * (1 - VISUALIZER_SMOOTHING);
} }
visualizerCtx.clearRect(0, 0, w, h); if (!visualizerGradient) visualizerGradient = buildVisualizerGradient();
visualizerCtx.clearRect(0, 0, w, h);
visualizerCtx.fillStyle = visualizerGradient;
visualizerCtx.beginPath();
for (let i = 0; i < numBins; i++) { for (let i = 0; i < numBins; i++) {
const barHeight = Math.max(1, smoothedFrequencies[i] * h); const barHeight = Math.max(1, smoothedFrequencies[i] * h);
const x = i * (barWidth + gap) + gap / 2; const x = i * (barWidth + gap) + gap / 2;
const y = h - barHeight; const y = h - barHeight;
const grad = visualizerCtx.createLinearGradient(x, y, x, h);
grad.addColorStop(0, accent);
grad.addColorStop(1, accent + '30');
visualizerCtx.fillStyle = grad;
visualizerCtx.beginPath();
visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5); visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5);
visualizerCtx.fill();
} }
visualizerCtx.fill();
// Bass-driven album-art scale + glow pulse removed — the
// "burst" looked unnatural on the sleeve. Spectrum bars +
// VU needle remain the audio-reactive elements.
// Drive the editorial .spectrum bars from the same frequency data. // Drive the editorial .spectrum bars from the same frequency data.
updateEditorialSpectrum(smoothedFrequencies, numBins); updateEditorialSpectrum(smoothedFrequencies, numBins);
@@ -357,36 +418,79 @@ function renderVisualizerFrame() {
// dominate); a linear mapping leaves the right half of the spectrum // dominate); a linear mapping leaves the right half of the spectrum
// looking dead. Use a logarithmic frequency-to-bar mapping plus a // looking dead. Use a logarithmic frequency-to-bar mapping plus a
// per-bar high-end gain so all bars carry visible motion. // per-bar high-end gain so all bars carry visible motion.
function updateEditorialSpectrum(bins, numBins) { let editorialSpectrumBars = null; // Live HTMLCollection cached at start
const root = document.querySelector('.now-playing .spectrum'); let editorialSpectrumBarCount = 0;
if (!root) return; let editorialSpectrumLastScale = null; // Float32Array of last applied scaleY × 1000 (int rounded)
const bars = root.children; let editorialBarRanges = null; // Pre-computed [startIdx,endIdx] pairs per bar
const barCount = bars.length; let editorialBarGains = null; // Pre-computed per-bar gain
if (!barCount) return; let editorialBarRangesForBins = -1; // numBins last used to compute ranges
document.body.classList.add('audio-spectrum-live');
// Skip the very lowest bin (DC + sub-rumble) which often dominates. function cacheEditorialSpectrumBars() {
const root = document.querySelector('.now-playing .spectrum');
if (!root) {
editorialSpectrumBars = null;
editorialSpectrumBarCount = 0;
return;
}
editorialSpectrumBars = root.children;
editorialSpectrumBarCount = editorialSpectrumBars.length;
editorialSpectrumLastScale = new Int16Array(editorialSpectrumBarCount);
editorialSpectrumLastScale.fill(-1);
// Pre-compute per-bar gain (constant for the lifetime of the bar list).
editorialBarGains = new Float32Array(editorialSpectrumBarCount);
for (let i = 0; i < editorialSpectrumBarCount; i++) {
editorialBarGains[i] = 1 + (i / editorialSpectrumBarCount) * 0.8;
}
editorialBarRangesForBins = -1; // Force range recompute on next call
}
function recomputeEditorialBarRanges(numBins) {
const barCount = editorialSpectrumBarCount;
editorialBarRanges = new Int16Array(barCount * 2);
const lowBin = 1; const lowBin = 1;
const highBin = numBins - 1; const highBin = numBins - 1;
const span = highBin - lowBin;
for (let i = 0; i < barCount; i++) { for (let i = 0; i < barCount; i++) {
// Logarithmic mapping: equal-area slices of the audible spectrum
// map to equal numbers of bars. Each bar covers a wider bin range
// toward the highs so they get amplified naturally.
const t0 = i / barCount; const t0 = i / barCount;
const t1 = (i + 1) / barCount; const t1 = (i + 1) / barCount;
const startIdx = Math.max(lowBin, Math.floor(lowBin + Math.pow(t0, 2.0) * (highBin - lowBin))); const startIdx = Math.max(lowBin, Math.floor(lowBin + t0 * t0 * span));
const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + Math.pow(t1, 2.0) * (highBin - lowBin))); const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + t1 * t1 * span));
let peak = 0; editorialBarRanges[i * 2] = startIdx;
for (let j = startIdx; j < endIdx && j < numBins; j++) { editorialBarRanges[i * 2 + 1] = Math.min(endIdx, numBins);
if (bins[j] > peak) peak = bins[j];
} }
// Per-bar high-end gain: 1.0 at the lowest bar, ~1.8 at the highest. editorialBarRangesForBins = numBins;
// Backend now ships AGC-normalized bins (peak ~1, transients up to 1.5) }
// so the master multiplier stays modest to avoid perma-clipping.
const gain = 1 + (i / barCount) * 0.8; function updateEditorialSpectrum(bins, numBins) {
// Floor at 12% so silent bars are still visually present. if (!editorialSpectrumBars) cacheEditorialSpectrumBars();
const pct = Math.max(12, Math.min(100, peak * 65 * gain)); const barCount = editorialSpectrumBarCount;
bars[i].style.height = pct + '%'; if (!barCount) return;
if (editorialBarRangesForBins !== numBins) recomputeEditorialBarRanges(numBins);
document.body.classList.add('audio-spectrum-live');
const ranges = editorialBarRanges;
const gains = editorialBarGains;
const lastScale = editorialSpectrumLastScale;
const bars = editorialSpectrumBars;
for (let i = 0; i < barCount; i++) {
const startIdx = ranges[i * 2];
const endIdx = ranges[i * 2 + 1];
let peak = 0;
for (let j = startIdx; j < endIdx; j++) {
const v = bins[j];
if (v > peak) peak = v;
}
// Backend ships AGC-normalized bins (peak ~1, transients up to ~1.5).
// Map to a 0.12..1.0 scaleY, with 0.12 floor so silent bars stay visible.
const raw = peak * 0.65 * gains[i];
const scaleY = raw < 0.12 ? 0.12 : (raw > 1 ? 1 : raw);
// Quantize to 1/1000 — anything finer is invisible. Skip the DOM
// write when the bar hasn't moved.
const q = (scaleY * 1000) | 0;
if (q === lastScale[i]) continue;
lastScale[i] = q;
// transform: scaleY runs on the compositor — no layout/paint.
bars[i].style.transform = `scaleY(${scaleY.toFixed(3)})`;
} }
} }
@@ -616,18 +720,46 @@ export function setupProgressDrag(bar, fill) {
// Replace the album-art src and replay the .is-swapping CSS animation // Replace the album-art src and replay the .is-swapping CSS animation
// so the new artwork crossfades in instead of popping. Re-toggling the // so the new artwork crossfades in instead of popping. Re-toggling the
// class across rAF restarts the keyframes even if it was already on. // class across rAF restarts the keyframes even if it was already on.
//
// `forceAnim=false` skips the keyframe-restart reflow when the element
// has never run the swap animation before — saves a synchronous layout
// flush on first paint. The reflow IS still required when the class
// is currently applied; otherwise the browser coalesces add+remove and
// the keyframes don't replay.
function swapArtworkSrc(imgEl, newSrc) { function swapArtworkSrc(imgEl, newSrc) {
if (!imgEl) return; if (!imgEl) return;
if (imgEl.src === newSrc) return; if (imgEl.src === newSrc) return;
const wasSwapping = imgEl.classList.contains('is-swapping');
if (wasSwapping) {
imgEl.classList.remove('is-swapping'); imgEl.classList.remove('is-swapping');
// Forced reflow restarts the keyframes — only needed when we have
// to interrupt an in-flight animation.
void imgEl.offsetWidth; void imgEl.offsetWidth;
}
imgEl.src = newSrc; imgEl.src = newSrc;
imgEl.classList.add('is-swapping'); imgEl.classList.add('is-swapping');
} }
// Hash of the last fully-rendered status payload — lets us skip
// updateUI altogether when the backend re-broadcasts the same state.
let lastStatusFingerprint = null;
function statusFingerprint(s) {
return [
s.state, s.title, s.artist, s.album, s.volume, s.muted,
s.duration, s.source, s.album_art_url, s.position
].join('|');
}
export function updateUI(status) { export function updateUI(status) {
setLastStatus(status); setLastStatus(status);
// Idempotence: if nothing meaningful changed, skip the entire DOM
// pass. Track switches arrive as 1-3 status_update broadcasts in
// quick succession; this gates the redundant ones.
const fingerprint = statusFingerprint(status);
if (fingerprint === lastStatusFingerprint) return;
lastStatusFingerprint = fingerprint;
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable'); const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
dom.trackTitle.textContent = status.title || fallbackTitle; dom.trackTitle.textContent = status.title || fallbackTitle;
dom.artist.textContent = status.artist || ''; dom.artist.textContent = status.artist || '';
@@ -654,7 +786,10 @@ export function updateUI(status) {
const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E"; const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E"; const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E";
if (artworkSource) { if (artworkSource) {
fetch(`/api/media/artwork?_=${Date.now()}`, { // No cache-buster: when album_art_url is unchanged the
// browser can reuse the decoded bitmap. The artworkKey gate
// already skips fetches when the user hasn't switched tracks.
fetch('/api/media/artwork', {
headers: getAuthHeaders() headers: getAuthHeaders()
}) })
.then(r => r.ok ? r.blob() : null) .then(r => r.ok ? r.blob() : null)
@@ -664,8 +799,11 @@ export function updateUI(status) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
currentArtworkBlobUrl = url; currentArtworkBlobUrl = url;
swapArtworkSrc(dom.albumArt, url); swapArtworkSrc(dom.albumArt, url);
dom.miniAlbumArt.src = url; if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
if (dom.albumArtGlow) dom.albumArtGlow.src = url; if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
// Mirror to fullscreen bloom directly — drops the
// MutationObserver fan-out path.
syncFullscreenBloomArt(url);
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000); if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
}) })
.catch(err => console.error('Artwork fetch failed:', err)); .catch(err => console.error('Artwork fetch failed:', err));
@@ -675,17 +813,22 @@ export function updateUI(status) {
currentArtworkBlobUrl = null; currentArtworkBlobUrl = null;
} }
swapArtworkSrc(dom.albumArt, placeholderArt); swapArtworkSrc(dom.albumArt, placeholderArt);
dom.miniAlbumArt.src = placeholderArt; if (dom.miniAlbumArt.src !== placeholderArt) dom.miniAlbumArt.src = placeholderArt;
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow; if (dom.albumArtGlow && dom.albumArtGlow.src !== placeholderGlow) dom.albumArtGlow.src = placeholderGlow;
syncFullscreenBloomArt(placeholderGlow);
} }
} }
if (status.duration && status.position !== null) { if (status.duration && status.position !== null) {
// Only redo the progress DOM when position actually changed.
const positionChanged =
status.duration !== currentDuration ||
Math.abs((status.position || 0) - (lastPositionValue || 0)) > 0.05;
setCurrentDuration(status.duration); setCurrentDuration(status.duration);
setCurrentPosition(status.position); setCurrentPosition(status.position);
lastPositionUpdate = Date.now(); lastPositionUpdate = Date.now();
lastPositionValue = status.position; lastPositionValue = status.position;
updateProgress(status.position, status.duration); if (positionChanged) updateProgress(status.position, status.duration);
} }
if (!isUserAdjustingVolume) { if (!isUserAdjustingVolume) {
@@ -730,17 +873,24 @@ export function updateUI(status) {
// FFT data the visualizer feeds in). When audio capture isn't // FFT data the visualizer feeds in). When audio capture isn't
// running, fall back to a synthetic wobble bounded by the volume // running, fall back to a synthetic wobble bounded by the volume
// slider position so the needle still looks alive. // slider position so the needle still looks alive.
let vuWobbleHandle = null; //
// One unified rAF drives both the spectrum and the VU needle (see
// renderVisualizerFrame → tickVuNeedle). If the visualizer isn't
// rendering, a separate rAF takes over solely for the needle.
let vuStandaloneHandle = null;
let vuWobbleStart = 0; let vuWobbleStart = 0;
let vuLevelSmoothed = 0; let vuLevelSmoothed = 0;
let vuNeedleEl = null; // Cached needle element
let vuVolumeSliderEl = null; // Cached slider element
let vuLastAppliedDeg = -999; // Skip DOM writes when angle unchanged
const VU_LEVEL_ATTACK = 0.7; // Fast climb so the needle catches musical hits 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 const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins
function readAudioLevel() { function readAudioLevel() {
if (!frequencyData) return null; if (!frequencyData) return null;
// Backend sends a true loudness signal (RMS-derived dB, 0..1). // 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. // either as float (legacy) or scaled int (new format).
if (typeof frequencyData.level === 'number') return frequencyData.level; if (typeof frequencyData.level === 'number') return frequencyData.level * frequenciesScale;
if (!frequencyData.frequencies) return null; if (!frequencyData.frequencies) return null;
const bins = frequencyData.frequencies; const bins = frequencyData.frequencies;
if (!bins.length) return null; if (!bins.length) return null;
@@ -748,29 +898,21 @@ function readAudioLevel() {
for (let i = 1; i < bins.length; i++) { for (let i = 1; i < bins.length; i++) {
if (bins[i] > peak) peak = bins[i]; if (bins[i] > peak) peak = bins[i];
} }
return Math.min(1, peak * 1.4); return Math.min(1, peak * frequenciesScale * 1.4);
} }
function startVuWobble() { function tickVuNeedle() {
if (vuWobbleHandle) return; if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
vuWobbleStart = performance.now(); if (!vuNeedleEl) return;
const tick = () => {
const needle = document.getElementById('vuNeedle');
if (needle) {
// 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(); const audioLevel = readAudioLevel();
let target; let target;
if (audioLevel != null) { if (audioLevel != null) {
// Real audio: apply attack/release smoothing for
// analog-feeling ballistics.
const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE; const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k; vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k;
target = -22 + vuLevelSmoothed * 44; target = -22 + vuLevelSmoothed * 44;
} else { } else {
const slider = document.getElementById('volume-slider'); if (!vuVolumeSliderEl) vuVolumeSliderEl = document.getElementById('volume-slider');
const vol = slider ? Number(slider.value) || 0 : 0; const vol = vuVolumeSliderEl ? Number(vuVolumeSliderEl.value) || 0 : 0;
const base = -22 + (vol / 100) * 44; const base = -22 + (vol / 100) * 44;
const mag = Math.max(2, Math.min(14, vol * 0.16)); const mag = Math.max(2, Math.min(14, vol * 0.16));
const t = (performance.now() - vuWobbleStart) / 1000; const t = (performance.now() - vuWobbleStart) / 1000;
@@ -779,21 +921,39 @@ function startVuWobble() {
+ Math.sin(t * 11.7 + 1.3) * mag * 0.30 + Math.sin(t * 11.7 + 1.3) * mag * 0.30
+ (Math.random() - 0.5) * mag * 0.30; + (Math.random() - 0.5) * mag * 0.30;
} }
needle.style.transform = `rotate(${target}deg)`; // Quantize to 0.1° — finer is invisible. Skip when unchanged.
const q = Math.round(target * 10) / 10;
if (q === vuLastAppliedDeg) return;
vuLastAppliedDeg = q;
vuNeedleEl.style.transform = `rotate(${q}deg)`;
} }
vuWobbleHandle = requestAnimationFrame(tick);
function startVuWobble() {
vuWobbleStart = performance.now();
// If the visualizer rAF is already running, it ticks the needle for us.
if (visualizerAnimFrame) return;
if (vuStandaloneHandle) return;
const standalone = () => {
tickVuNeedle();
// Stop ourselves once the unified visualizer loop is up.
if (visualizerAnimFrame) {
vuStandaloneHandle = null;
return;
}
vuStandaloneHandle = requestAnimationFrame(standalone);
}; };
vuWobbleHandle = requestAnimationFrame(tick); vuStandaloneHandle = requestAnimationFrame(standalone);
} }
function stopVuWobble() { function stopVuWobble() {
if (vuWobbleHandle) { if (vuStandaloneHandle) {
cancelAnimationFrame(vuWobbleHandle); cancelAnimationFrame(vuStandaloneHandle);
vuWobbleHandle = null; vuStandaloneHandle = null;
} }
vuLevelSmoothed = 0; vuLevelSmoothed = 0;
const needle = document.getElementById('vuNeedle'); vuLastAppliedDeg = -999;
if (needle) needle.style.transform = 'rotate(-22deg)'; if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
if (vuNeedleEl) vuNeedleEl.style.transform = 'rotate(-22deg)';
} }
export function updatePlaybackState(state) { export function updatePlaybackState(state) {
@@ -830,30 +990,58 @@ export function updatePlaybackState(state) {
} }
} }
// Cache last applied progress values so we can skip DOM writes when the
// rounded second hasn't moved. Width is quantized to 0.1% — finer is
// invisible but would still trigger compositor work.
let lastProgressTenths = -1; // 0..1000 (0.1% increments)
let lastProgressSec = -1;
let lastDurationSec = -1;
let cachedMiniBar = null;
function updateProgress(position, duration) { function updateProgress(position, duration) {
const percent = (position / duration) * 100; const percent = (position / duration) * 100;
const widthStr = `${percent}%`; const tenths = Math.round(percent * 10); // 0..1000
const currentStr = formatTime(position);
const totalStr = formatTime(duration);
const posRound = Math.round(position); const posRound = Math.round(position);
const durRound = Math.round(duration); const durRound = Math.round(duration);
dom.progressFill.style.width = widthStr; const widthChanged = tenths !== lastProgressTenths;
dom.currentTime.textContent = currentStr; const posChanged = posRound !== lastProgressSec;
dom.totalTime.textContent = totalStr; const durChanged = durRound !== lastDurationSec;
if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr;
if (dom.metaLength) dom.metaLength.textContent = totalStr;
dom.progressBar.dataset.duration = duration;
dom.progressBar.setAttribute('aria-valuenow', posRound);
dom.progressBar.setAttribute('aria-valuemax', durRound);
if (widthChanged) {
lastProgressTenths = tenths;
const widthStr = (tenths / 10) + '%';
dom.progressFill.style.width = widthStr;
dom.miniProgressFill.style.width = widthStr; dom.miniProgressFill.style.width = widthStr;
dom.miniCurrentTime.textContent = currentStr;
dom.miniTotalTime.textContent = totalStr;
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr); if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
const miniBar = document.getElementById('mini-progress-bar'); }
miniBar.setAttribute('aria-valuenow', posRound);
miniBar.setAttribute('aria-valuemax', durRound); if (posChanged) {
lastProgressSec = posRound;
const currentStr = formatTime(position);
dom.currentTime.textContent = currentStr;
if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr;
dom.miniCurrentTime.textContent = currentStr;
dom.progressBar.setAttribute('aria-valuenow', posRound);
}
if (durChanged) {
lastDurationSec = durRound;
const totalStr = formatTime(duration);
dom.totalTime.textContent = totalStr;
if (dom.metaLength) dom.metaLength.textContent = totalStr;
dom.miniTotalTime.textContent = totalStr;
dom.progressBar.dataset.duration = duration;
dom.progressBar.setAttribute('aria-valuemax', durRound);
}
if (posChanged || durChanged) {
if (!cachedMiniBar) cachedMiniBar = document.getElementById('mini-progress-bar');
if (cachedMiniBar) {
if (posChanged) cachedMiniBar.setAttribute('aria-valuenow', posRound);
if (durChanged) cachedMiniBar.setAttribute('aria-valuemax', durRound);
}
}
} }
export function startPositionInterpolation() { export function startPositionInterpolation() {
@@ -901,13 +1089,15 @@ function updateMuteIcon(muted) {
let fsChromeIdleTimer = null; let fsChromeIdleTimer = null;
const FS_CHROME_IDLE_MS = 2500; const FS_CHROME_IDLE_MS = 2500;
let fsLastFocusedElement = null; let fsLastFocusedElement = null;
let fsBloomSyncObserver = null;
function syncFullscreenBloomArt() { // Mirror the album-art onto #fs-bloom-art (the fullscreen ambient
const src = document.getElementById('album-art'); // bloom). Called directly from the artwork-swap path — no
// MutationObserver, so we never repaint the 110px-radius blur twice.
function syncFullscreenBloomArt(url) {
const bloom = document.getElementById('fs-bloom-art'); const bloom = document.getElementById('fs-bloom-art');
if (!src || !bloom) return; if (!bloom) return;
if (src.src && src.src !== bloom.src) bloom.src = src.src; const target = url || (dom && dom.albumArt && dom.albumArt.src) || '';
if (target && bloom.src !== target) bloom.src = target;
} }
function showFsChrome() { function showFsChrome() {
@@ -978,16 +1168,10 @@ export function enterPlayerFullscreen() {
document.body.classList.add('is-fullscreen-player'); document.body.classList.add('is-fullscreen-player');
setMiniPlayerVisible(false); setMiniPlayerVisible(false);
updateFullscreenButtonIcons(true); updateFullscreenButtonIcons(true);
// Initial mirror — subsequent swaps are pushed by updateUI directly,
// so there is no MutationObserver in the hot path.
syncFullscreenBloomArt(); syncFullscreenBloomArt();
// Watch for album-art swaps so the bloom keeps up.
const src = document.getElementById('album-art');
if (src && 'MutationObserver' in window) {
if (fsBloomSyncObserver) fsBloomSyncObserver.disconnect();
fsBloomSyncObserver = new MutationObserver(syncFullscreenBloomArt);
fsBloomSyncObserver.observe(src, { attributes: true, attributeFilter: ['src'] });
}
document.addEventListener('mousemove', onFsMouseMove, { passive: true }); document.addEventListener('mousemove', onFsMouseMove, { passive: true });
document.addEventListener('keydown', onFsKeyDown); document.addEventListener('keydown', onFsKeyDown);
showFsChrome(); showFsChrome();
@@ -1017,10 +1201,6 @@ export function exitPlayerFullscreen({ skipNativeExit = false } = {}) {
clearTimeout(fsChromeIdleTimer); clearTimeout(fsChromeIdleTimer);
fsChromeIdleTimer = null; fsChromeIdleTimer = null;
} }
if (fsBloomSyncObserver) {
fsBloomSyncObserver.disconnect();
fsBloomSyncObserver = null;
}
document.removeEventListener('mousemove', onFsMouseMove); document.removeEventListener('mousemove', onFsMouseMove);
document.removeEventListener('keydown', onFsKeyDown); document.removeEventListener('keydown', onFsKeyDown);