feat(audio): rebuild playback + visualization on resident daemon and PCM cache

Two fragility points, rebuilt at the root:

Playback: one resident mpv daemon (--idle --keep-open) with a persistent
IPC connection and observe_property state instead of spawn-per-episode and
connect-per-poll. Play/pause/seek are sub-ms commands; time-pos pushes at
~20Hz; external pauses arrive as events. Boot session restore preloads the
episode paused (loadfile + paused time-pos seek, since mpv defers --start
stream work until playback) so first Play is a ~400ms unpause instead of a
cold 4.3s open+seek. Load ops are mutex-serialized so a raced preload
cannot clobber an in-flight play.

Data throttling: mpv demuxer cache capped (cache-secs=90, max-bytes=40MiB)
so a paused preload no longer races to its 150MiB default (measured
45.7MB/12s); decoder paced at 4x realtime instead of 84x so playback start
isn't starved by the visualizer ripping the whole episode.

Visualization: replaced the paced-ring reader (AudioStreamReader) with a
position-indexed PCM cache (audio-pcm-cache). ffmpeg fills a cache indexed
by absolute playback time; reads at the player position are always exact.
Pause freezes the render loop, resume re-arms it — no coverage guessing,
no clamped-buffer freeze (the pause->broken-waveform->freeze bug). Seeks
and speed changes need no pipeline restarts; uncovered reads return empty
and the last frame holds.

Cover art: persistent per-URL disk cache under XDG cache dir; play() no
longer awaits a curl subprocess (up to 8s). Cache hit = one stat; misses
apply late via mpv video-add.

Test suite: 161 pass. New tests pin the position-index contract (sample-
exact window reads, hold-on-uncovered, pause-keeps-cache, seek segments),
the daemon contract (play/pause/resume/seek/stop, preload fast path,
EOF->replay), and cover cache/single-flight/404.
This commit is contained in:
2026-08-11 19:54:05 -04:00
parent 8b7b38276e
commit 20336ea716
12 changed files with 1757 additions and 882 deletions

View File

@@ -2,20 +2,29 @@
* visualizer-store — module-level singleton owning the realtime waveform
* pipeline (ffmpeg decode + cavacore FFT), shared across PlayerPage mounts.
*
* The pipeline lives here rather than in the Player page component so it can
* outlive the page: Shell unmounts a tab's page the moment the tab loses
* focus, which would otherwise kill the ffmpeg decode + FFT loop instantly.
* Instead the store keeps the visualization warm for UNLOAD_DELAY_MS after
* the Player tab stops being focused, then tears it down (kills ffmpeg,
* destroys the cava plan). Returning to the tab within the delay resumes
* seamlessly; after an unload, regaining focus restarts the pipeline from
* the current playback position.
* Pipeline shape (see utils/audio-pcm-cache.ts for the rationale):
* an ffmpeg process decodes the episode at full speed into a
* position-indexed PCM cache; the render loop reads the window ending at
* the player's current position from that cache. Because reads are
* indexed by playback time, PAUSE/RESUME/SEEK/SPEED need no pipeline
* choreography at all — and cannot desync:
*
* The store subscribes to the module-level playback signals in
* `utils/audio-signals.ts` (`audioPlaybackSignals`), so it reacts to
* play/pause/seek/speed even while no Player page is mounted. `focused` is
* fed by PlayerPage (mounted ⇔ Player tab visible), `barCount` by
* RealtimeWaveform (terminal width).
* - Pause: stop the render loop and the decode pass; the PCM cache stays
* resident. Bars freeze on the last rendered frame.
* - Resume: re-arm the render loop — bars render instantly from the cache
* — and continue the tail decode in the background. No cold start, no
* coverage guessing, no clamped-buffer freeze (the old bug: resume
* re-armed the loop over a DEAD ffmpeg and the bars exhausted the ring
* buffer, then froze on a repeated stale window forever).
* - Seek into decoded audio: nothing to do. Seek into a hole: kick off a
* decode segment there; the last frame holds until data arrives.
* - Speed changes: nothing. The cache is position-indexed raw PCM.
*
* Focus lifecycle: Shell unmounts a tab's page when it loses focus, but the
* pipeline outlives the page so playback keeps visualizing; UNLOAD_DELAY_MS
* after the Player tab stops being focused it tears down. Reads outside
* decoded coverage return empty — the renderer simply holds the last frame
* until the decode frontier arrives.
*/
import {
@@ -30,7 +39,7 @@ import {
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { EpisodePcmCache, PCM_SAMPLE_RATE } from "@/utils/audio-pcm-cache";
import { createBarScaler } from "@/utils/bar-mapping";
import { audioPlaybackSignals } from "@/utils/audio-signals";
import { useAppStore } from "@/stores/app";
@@ -83,7 +92,10 @@ function createVisualizerStore(): VisualizerStore {
const scaler = createBarScaler();
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
// Position-indexed PCM cache for the current episode. Kept across
// pause/resume (segments survive; only the ffmpeg pass is killed) and
// dropped only on episode change, stop, disable, or unload.
let pcm: EpisodePcmCache | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null;
let unloadTimer: ReturnType<typeof setTimeout> | null = null;
@@ -91,7 +103,6 @@ function createVisualizerStore(): VisualizerStore {
// What the running pipeline was started with — lets the playback effect
// tell "nothing changed, stay warm" from "must restart".
let activeUrl = "";
let activeSpeed = 1;
let activeBars = 64;
// ── Lifecycle helpers ──────────────────────────────────────────────
@@ -139,7 +150,7 @@ function createVisualizerStore(): VisualizerStore {
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => {
const startVisualization = (url: string, position: number) => {
stopVisualization();
if (!url || !initCava() || !cava) return;
@@ -152,7 +163,7 @@ function createVisualizerStore(): VisualizerStore {
const viz = useAppStore().state().settings.visualizer;
const config: CavaCoreConfig = {
bars: barCount(),
sampleRate: 44100,
sampleRate: PCM_SAMPLE_RATE,
channels: 1,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
@@ -164,32 +175,29 @@ function createVisualizerStore(): VisualizerStore {
// Pre-warm the FFT window: libcavacore's window is malloc'd
// uninitialized, so the first real frame would FFT garbage and
// render full-scale bars. One zero frame the size of the whole
// input buffer clears it (at 44.1kHz mono the window is 8192
// samples — FFTbassbufferSize × channels; a 512-sample frame would
// leave the tail garbage).
// input buffer clears it.
cava.execute(new Float64Array(8192));
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
if (!reader || reader.url !== url) {
if (reader) reader.stop();
reader = new AudioStreamReader({ url });
// PCM cache per episode (reuse when the episode is unchanged)
if (!pcm || pcm.url !== url) {
if (pcm) pcm.stop();
pcm = new EpisodePcmCache({ url });
}
reader.start(position, speed);
// Decode from 1s before the position so the window ENDING at the
// position is covered as soon as the first PCM lands.
pcm.startDecode(Math.max(0, position - 1));
// Seed the smooth position clock with the start position. Without
// this, a fresh play at position 0 would sample the window ending at
// exactly 0 — a 1-sample slice the reader can never fill — so the
// bars would be starved until the first mpv poll advanced the
// position clock. Seeding makes the interpolated target advance
// immediately, so bars render as soon as ffmpeg has any audio.
// exactly 0 — a 1-sample slice — so bars would be starved until the
// first mpv poll advanced the position clock.
lastPolledPosition = position;
lastPolledAt = performance.now();
activeUrl = url;
activeSpeed = speed;
activeBars = barCount();
setIsLoading(true);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
@@ -201,9 +209,10 @@ function createVisualizerStore(): VisualizerStore {
clearInterval(frameTimer);
frameTimer = null;
}
if (reader) {
reader.stop();
// Don't null reader — we reuse it across start/stop cycles
if (pcm) {
pcm.stop();
// Keep the (now cache-less, url-tagged) object: a re-start of the
// same episode reuses it; segments re-decode in seconds at 80x.
}
if (cava?.isReady) {
cava.destroy();
@@ -212,17 +221,59 @@ function createVisualizerStore(): VisualizerStore {
setIsLoading(false);
};
// ── Pause: freeze the loop, keep the cache ──────────────────────────
//
// The render loop stops (bars hold their last frame) and the ffmpeg
// pass dies (no background CPU), but the decoded PCM stays: resume
// serves it instantly.
const suspendVisualization = () => {
clearUnloadTimer();
if (frameTimer) {
clearInterval(frameTimer);
frameTimer = null;
}
if (pcm) pcm.pauseDecode();
// Cava plan + sampleBuffer stay alive — cheap to reuse on resume.
// Clear the loading spinner: if the pipeline never produced bars
// (still cold-starting when paused), the component should fall back
// to the placeholder, not freeze on a spinner.
setIsLoading(false);
};
// ── Resume: re-arm the render loop, top up the cache ───────────────
//
// Returns true if the pipeline resumed, false if there was nothing to
// resume (no prior pipeline).
const resumeVisualization = (): boolean => {
// Already running — nothing to do.
if (frameTimer !== null) return true;
if (!pcm || !cava?.isReady || !sampleBuffer) return false;
const pos = untrack(audioPlaybackSignals.position);
// Bars come from the cache on the next frame tick (~33ms) whenever
// the position is covered; any gap (uncached region) restarts the
// decode pass in the background with the last frame holding.
pcm.ensureDecodeAround(pos);
lastPolledPosition = pos;
lastPolledAt = performance.now();
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
return true;
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
if (!cava?.isReady || !sampleBuffer || !pcm) return;
// Sample the FFT window at the player's position, not the decode
// head — the reader decodes independently (paced at the player's
// clock rate with a LEAD_SECONDS burst head start) and only the
// position clock ties the bars to what's actually playing.
// Sample the FFT window at the player's position. Outside decoded
// coverage (decode cold start, seek into a hole) the read is empty
// and the LAST FRAME simply holds — never clamped/repeated junk.
const target = smoothPosition();
const count = reader.read(sampleBuffer, target);
const count = pcm.readWindow(sampleBuffer, target);
// Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return;
@@ -235,44 +286,53 @@ function createVisualizerStore(): VisualizerStore {
// ── Playback subscription ──────────────────────────────────────────
//
// Keeps the pipeline matched to playback. `focused` is a dep so focus
// regain re-evaluates (and can restart an unloaded pipeline), but the
// guard below makes a focus flip on an already-correct warm pipeline a
// no-op — no churn when flipping back to the Player tab within the
// unload delay. A real change (url/speed/barCount, stop/start, or a
// stale pipeline after an unload) restarts from the current position.
// Keeps the pipeline matched to playback. Pause suspends (render loop +
// decode pass die, cache survives) so resume is instant. Stop/track-end/
// disable fully tears down. `focused` is a dep so focus regain
// re-evaluates; the guards make a focus flip on an already-correct warm
// pipeline a no-op. Speed is deliberately NOT a dep — the PCM cache is
// position-indexed, so playback-rate changes need no pipeline restart.
createEffect(
on(
[
audioPlaybackSignals.isPlaying,
() => audioPlaybackSignals.currentEpisode()?.audioUrl ?? "",
audioPlaybackSignals.speed,
barCount,
focused,
() => useAppStore().state().settings.visualizer.enabled,
],
([playing, url, speed, , , enabled]) => {
if (!playing || !url || !enabled) {
([playing, url, , , enabled]) => {
if (!url || !enabled) {
stopVisualization();
return;
}
// Warm and already correct — nothing to do (e.g. focus
// regained within the unload delay).
if (!playing) {
// Pause: freeze the loop, keep the cache. Only if the
// pipeline is actually running — otherwise no-op.
if (frameTimer !== null) suspendVisualization();
return;
}
// Playing — try a fast resume first. If it succeeds and the
// pipeline matches, done.
if (
frameTimer !== null &&
frameTimer === null &&
pcm &&
cava?.isReady &&
url === activeUrl &&
speed === activeSpeed &&
barCount() === activeBars
) {
if (resumeVisualization()) return;
}
// Warm and already correct — nothing to do (e.g. focus
// regained within the unload delay while still playing).
if (frameTimer !== null && url === activeUrl && barCount() === activeBars) {
return;
}
if (!focused()) return; // playing away: stay warm; unload timer decides
startVisualization(
url,
untrack(audioPlaybackSignals.position),
speed,
);
startVisualization(url, untrack(audioPlaybackSignals.position));
},
),
);
@@ -296,7 +356,6 @@ function createVisualizerStore(): VisualizerStore {
startVisualization(
audioPlaybackSignals.currentEpisode()!.audioUrl,
untrack(audioPlaybackSignals.position),
audioPlaybackSignals.speed() ?? 1,
);
}
} else if (frameTimer !== null) {
@@ -308,17 +367,17 @@ function createVisualizerStore(): VisualizerStore {
}),
);
// ── Seek detection: lightweight effect for position jumps ──────────
// ── Seek detection: jump coverage, not pipeline restarts ──────────
//
// Watches position and restarts the reader (not the whole pipeline)
// only on significant jumps (>2s), which indicate a user seek.
// This is intentionally a separate effect — it should NOT trigger a
// full pipeline restart, just restart the ffmpeg stream at the new pos.
// Watches position for significant jumps (>2s = user seek). Decoded
// audio at the new position is served instantly with zero action; a
// jump into an undecoded hole kicks a background segment decode there
// while the last frame holds.
let lastSyncPosition = 0;
createEffect(
on(audioPlaybackSignals.position, (pos) => {
if (!audioPlaybackSignals.isPlaying() || !reader?.running) {
if (!audioPlaybackSignals.isPlaying() || !pcm) {
lastSyncPosition = pos;
return;
}
@@ -327,10 +386,21 @@ function createVisualizerStore(): VisualizerStore {
lastSyncPosition = pos;
if (delta > 2) {
reader.restart(pos, audioPlaybackSignals.speed() ?? 1);
pcm.ensureDecodeAround(pos);
}
}),
);
// ── Process-exit teardown ──────────────────────────────────────────
//
// The pipeline lives in a detached createRoot that is never disposed,
// so Solid's onCleanup never runs. `q`/`:quit` call process.exit(0)
// (bypassing onCleanup); SIGINT/TERM/HUP are caught by useAudio's
// handler. This handler runs synchronously on `exit` and kills the
// ffmpeg child + destroys the cava plan so they don't outlive the host.
// Without it, a warm pipeline leaks an orphaned ffmpeg process on quit.
process.on("exit", () => {
stopVisualization();
});
return {
// state