The player used a single <audio> element and advanced by swapping .src, which
re-fetches + re-buffers the next file — an audible gap between tracks. For a
continuous DJ mix (silence already trimmed at the edges) that's the whole
problem. Replace the <audio> element with a Web Audio engine that decodes each
track to an AudioBuffer and schedules the NEXT track's source to start() at the
exact AudioContext time the current track ends → sample-accurate, zero gap on
auto-advance.
- gapless-engine.ts (new): AudioContext + gain, LRU-capped decoded-buffer cache,
fetch-whole-file → decodeAudioData, boundary scheduling, seek/skip/play-pause
(pause = ctx.suspend so the clock + scheduled next freeze together), a
generation counter to invalidate stale onended/async, and a gesture unlock for
autoplay policy. Callbacks: onIndex/onTime/onEndOfQueue/onLoadingChange.
- MusicPlayerHost.tsx: drives the engine instead of an <audio> element. React
keeps the queue/index (useMusicPlayer); user actions (new album, jump, prev/
next) command the engine, and the engine's own natural advance mirrors back
via syncIndex WITHOUT restarting playback (that's what keeps the seam gapless).
Preserves restore/persist/heartbeat/album-nav/volume/heart; adds a decode
spinner on the play button (startup/skip has fetch+decode latency by nature).
- useMusicPlayer.ts: syncIndex() — set index without touching `playing`.
Trade-off (chosen deliberately over near-gapless preloading): true gapless
needs the whole next file decoded to PCM ahead of time (~200MB per 10-min
track), so the buffer cache is capped at 3. Verified the scheduler state
machine with a mocked AudioContext: next track scheduled at the current's exact
end sample, advance/promote/seek/skip/end-of-queue all correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>