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:
77
tests/cover-art.test.ts
Normal file
77
tests/cover-art.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Cover-art disk-cache contract tests.
|
||||
*
|
||||
* fetchCoverArt downloads each cover ONCE into a persistent per-URL cache;
|
||||
* playback never waits on the network for art it has already fetched. Pins:
|
||||
*
|
||||
* 1. A fetch stores the bytes on disk and returns the cache path.
|
||||
* 2. A second fetch of the same URL returns the cached path WITHOUT hitting
|
||||
* the server again (request count stays 1).
|
||||
* 3. Concurrent fetches of the same URL share one download (single-flight).
|
||||
* 4. A failed fetch (404) resolves null instead of throwing.
|
||||
*
|
||||
* Served from a local Bun server — no external network dependence. Cache
|
||||
* entries created here are removed afterwards.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { unlinkSync } from "fs";
|
||||
import { cachedCoverPath, fetchCoverArt } from "../src/utils/cover-art";
|
||||
|
||||
const FAKE_JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, ...new Array(256).fill(7)]);
|
||||
|
||||
test("cover art is fetched once, cached on disk, and shared", async () => {
|
||||
let requests = 0;
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
requests++;
|
||||
if (new URL(req.url).pathname === "/missing.jpg") {
|
||||
return new Response("nope", { status: 404 });
|
||||
}
|
||||
return new Response(FAKE_JPEG, {
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const url = `http://127.0.0.1:${server.port}/cover.jpg`;
|
||||
const missing = `http://127.0.0.1:${server.port}/missing.jpg`;
|
||||
let cachedPath: string | null = null;
|
||||
try {
|
||||
expect(cachedCoverPath(url)).toBeNull();
|
||||
|
||||
// First fetch: downloads and caches.
|
||||
cachedPath = await fetchCoverArt(url);
|
||||
expect(cachedPath).not.toBeNull();
|
||||
expect(requests).toBe(1);
|
||||
expect(Bun.file(cachedPath!).size).toBe(FAKE_JPEG.byteLength);
|
||||
|
||||
// Second fetch: disk hit, server untouched.
|
||||
expect(await fetchCoverArt(url)).toBe(cachedPath);
|
||||
expect(requests).toBe(1);
|
||||
|
||||
// Single-flight: parallel misses of a fresh URL make ONE request.
|
||||
const shared = `http://127.0.0.1:${server.port}/shared.jpg`;
|
||||
const [a, b, c] = await Promise.all([
|
||||
fetchCoverArt(shared),
|
||||
fetchCoverArt(shared),
|
||||
fetchCoverArt(shared),
|
||||
]);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a).toBe(b);
|
||||
expect(b).toBe(c);
|
||||
if (a) unlinkSync(a);
|
||||
|
||||
// 404 resolves null, never throws.
|
||||
expect(await fetchCoverArt(missing)).toBeNull();
|
||||
} finally {
|
||||
server.stop(true);
|
||||
if (cachedPath) {
|
||||
try {
|
||||
unlinkSync(cachedPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user