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.
Production-readiness pass before merging the Studio Reference redesign
to master.
Audio (backend):
- Reset AGC `_spectrum_ref` envelope on `start()` so a long silent gap
between sessions doesn't make the first new transients clip at the
ceiling. Annotated the trade-off (loud transient lifts reference for
a few seconds afterwards — the price of real loudness).
- Add `tests/test_audio_analyzer.py` with 10 cases: bin-edge layout,
AGC attack/release asymmetry, lifecycle reset. Skips numpy-dependent
cases when numpy isn't installed; CI has it.
Vinyl mode dead code removed:
- The toggle button was dropped during the sleeve refactor but the JS
state, 2 s `setInterval`, `beforeunload` handler, and `applyVinylMode`
call (commented out in app.js) all stayed. Now properly excised from
player.js + app.js + window.* exports.
- Stripped the matching `.album-art-container.vinyl*` CSS block and its
`vinylSpin` keyframes (~95 LoC).
Sleeve + tonearm fixes:
- Removed the duplicate `.now-playing .vinyl-stage` / `.vinyl-label` /
`.tonearm` block that was overriding the new `.vinyl-stage` rules by
source order — the uncommitted tonearm geometry never took effect
because the stale clone won the cascade.
- Tightened tonearm to 36% × 36% at right:-6%, top:26% so the SVG
bounding box stays right of the sleeve (sleeve right edge ~68%).
Needle now lands on the visible disc grooves at both rest and
playing rotations and never overlaps the cover.
- Removed sleeve `transform: rotate(-2.5deg)` + the matching mobile
`-1.8deg` override; sleeve now sits flat and squared-off.
- Removed the 1px inset hairline on the sleeve and the 1px outline +
inset highlight on the album art — cleaner, no semitransparent
border noise.
- Album art inset 5% to expose a cardstock margin around the print
(using explicit width/height — `inset` shorthand triggered the CSS
replaced-element rule that uses the image's intrinsic size and blew
out the grid track).
Mobile + misc:
- Removed mobile tonearm overrides at 720px and 420px — they were
calibrated for the pre-sleeve geometry and put the needle back over
the cover on phones; desktop geometry is proportional and works.
- Added `<meta name="mobile-web-app-capable">` alongside the legacy
Apple variant to silence the deprecation warning in Chromium.
- Replaced the "PRIMARY" badge on display cards with a copper star
icon (translation key still drives title + aria-label).
- `.gitattributes` with `* text=auto eol=lf` so Windows checkouts stop
nagging "LF will be replaced by CRLF".
Annotations:
- "REF · 24" record-label catalogue mark marked as intentional non-i18n
decoration in index.html.
CI: ruff clean, pytest 7 passed + 3 numpy-skipped (all 10 run on CI).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Backend computes time-domain RMS, maps -60..-6 dB to 0..1, sends as
`level` alongside the per-frame-normalized frequency bins.
- Frontend prefers `level` directly; drops the peak-of-bins fallback
and the redundant volume-slider attenuation (loopback capture is
already post-volume on Windows/macOS).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Generate numpy/_distributor_init_local.py during build so libopenblas
can be located when running from the Windows installer
- Add os.add_dll_directory() call at runtime as a fallback for embedded Python
- Broaden audio import errors from ImportError to Exception, log at warning
- Move visualizer WS re-subscription into loadAudioDevices() so it runs
after availability is confirmed from the API
- Show/hide the visualizer toggle button based on fetched availability
- Visualizer: FPS 25→30, chunk_size 2048→1024, smoothing 0.65→0.15
- Beat effect: scale 0.03→0.04, glow range 0.5-0.8→0.4-0.8
- UI: reduce container/section paddings from 2rem to 1rem
- Source name: add ellipsis overflow for long names
- Mobile browser toolbar: use flex-wrap instead of column stack,
hide "Items per page" label text on small screens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Audio capture starts only when first client subscribes,
stops when last client unsubscribes (saves CPU/battery)
- Add lifecycle lock to AudioAnalyzer for thread-safe start/stop
- Status badge uses local visualizer state instead of server flag
- Fix script name vertical text break on narrow screens
- Fix script grid minimum column width on small viewports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New audio_analyzer service: loopback capture via soundcard + numpy FFT
- Real-time spectrogram bars below album art with accent color gradient
- Album art and vinyl pulse to bass energy beats
- WebSocket subscriber pattern for opt-in audio data streaming
- Audio device selection in Settings tab with auto-detect fallback
- Optimized FFT pipeline: vectorized cumsum bin grouping, pre-serialized JSON broadcast
- Visualizer config: enabled/fps/bins/device in config.yaml
- Optional deps: soundcard + numpy (graceful degradation if missing)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>