The waveform's ffmpeg decode + cavacore FFT pipeline lived inside
RealtimeWaveform, so switching away from the Player tab unmounted it and
killed the pipeline instantly — respawning ffmpeg on every return.
Move the pipeline into a module-level store (stores/visualizer.ts) that
outlives the page:
- losing Player-tab focus keeps the pipeline warm for
VISUALIZER_UNLOAD_DELAY_MS (30s), then tears it down (kills ffmpeg,
destroys the cava plan); regaining focus within the delay resumes the
warm pipeline with no restart churn; after an unload it restarts from
the current playback position.
- playback signals move to utils/audio-signals.ts (module-level, no
useAudio() owner needed) so the store reacts to play/pause/seek/speed
while no Player page is mounted; useAudio re-imports them.
- render a braille spinner as the loading state for the visualizer
(first play / after unload); stale bars stay on screen during warmup
restarts so the waveform never blanks out for the network-bound cold
start.
- seed the smooth position clock at pipeline start: with the position
still frozen at 0 while mpv opens the stream, the reader sampled a
1-sample window that could never fill, starving the bars until mpv's
first poll.
Pins the store contract in tests/visualizer-store.test.ts: loading→bars,
warm resume without restart, 30s unload, and bars while position is
frozen at 0.
The decode head lagged the player by a constant ε (ffmpeg startup
latency) because ffmpeg paced at -readrate <speed> started behind mpv
and, advancing at the same rate, never caught up — bars were a few
seconds behind for the entire playback.
Add -readrate_initial_burst LEAD_SECONDS so ffmpeg emits 3s of audio
immediately on start, then paces at realtime after. The decode head
leads the player by a stable ~3s from the very first frame; read()
samples at the exact player position and always finds fresh samples.
Add a sustained render-loop test that simulates ~5s of real playback,
asserting ffmpeg stays alive, the decode head maintains a positive
lead, and read() returns full windows. Uses real wall-clock time
(documented exception) since ffmpeg's decode pacing can't be tested
deterministically.
- ProgressBar: full-width played/remaining bar in the player pane, click-to-seek;
waveform no longer handles seeking or the played/future color split (pure visual)
- bars: 2 terminal rows per bar (16 levels) via barChars, partial block in the
top row so the column renders continuously
- fix bars maxing at audio start: disable cava autosens (silence gain-ramp),
pre-warm the malloc'd FFT window with zeros, skip partial FFT windows
- createBarScaler peak follower + power curve replaces cava autosens for
level-to-height mapping (src/utils/bar-mapping.ts, unit-tested)