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

192
tests/audio-backend.test.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* MpvBackend resident-daemon contract tests (real mpv process).
*
* Pins the IPC contract the app's playback depends on:
*
* 1. play() loads a file and position advances (observed, no polling).
* 2. pause()/resume() flip the player-reported pause state through IPC.
* 3. seek() lands where asked.
* 4. stop() unloads the file but keeps the daemon alive (isAlive stays
* true — the daemon model's whole point: no process churn per episode).
* 5. preload() parks an episode paused; play() of the SAME url then starts
* it by unpausing — the boot-restore fast path with no second load.
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
* resume() afterwards replays from the top.
*
* All playback runs silent (volume 0). Requires a real mpv on PATH;
* tests skip where it is missing.
*/
import { test, expect } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import { MpvBackend } from "../src/utils/audio-player";
const SAMPLE_RATE = 22050;
const FREQ = 440;
const AMP = 20000;
/** Write a WAV file containing `seconds` of a sine at AMP amplitude. */
function writeSineWav(path: string, seconds: number): void {
const total = Math.round(seconds * SAMPLE_RATE);
const dataSize = total * 2;
const buf = new Uint8Array(44 + dataSize);
const dv = new DataView(buf.buffer);
const ascii = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
};
ascii(0, "RIFF");
dv.setUint32(4, 36 + dataSize, true);
ascii(8, "WAVE");
ascii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true);
dv.setUint16(22, 1, true);
dv.setUint32(24, SAMPLE_RATE, true);
dv.setUint32(28, SAMPLE_RATE * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
ascii(36, "data");
dv.setUint32(40, dataSize, true);
for (let i = 0; i < total; i++) {
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
dv.setInt16(44 + i * 2, v, true);
}
Bun.write(path, buf);
}
/** Poll a predicate until true or the deadline expires. */
async function waitFor(
label: string,
pred: () => boolean | Promise<boolean>,
timeoutMs = 8000,
): Promise<void> {
const start = Date.now();
for (;;) {
if (await pred()) return;
if (Date.now() - start > timeoutMs) {
throw new Error(`${label}: not true within ${timeoutMs}ms`);
}
await Bun.sleep(50);
}
}
const hasMpv = !!Bun.which("mpv");
const wavA = join(tmpdir(), `podtui-backend-${process.pid}-a.wav`);
const wavB = join(tmpdir(), `podtui-backend-${process.pid}-b.wav`);
function fixtureWavs(): void {
writeSineWav(wavA, 8);
writeSineWav(wavB, 8);
}
async function cleanup(backend: MpvBackend): Promise<void> {
backend.dispose();
await Bun.$`rm -f ${wavA} ${wavB}`.quiet();
}
test.skipIf(!hasMpv)(
"play / pause / resume / seek over the resident daemon",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
expect(backend.isAlive()).toBe(true);
expect(backend.isPlaying()).toBe(true);
// Observed position advances without any polling from us.
await waitFor("position advances", async () => (await backend.getPosition()) > 1.3);
expect(await backend.getPauseState()).toBe(false);
expect(await backend.getDuration()).toBeGreaterThan(7.5);
// Pause: reported by the player's own state, position stalls.
await backend.pause();
await waitFor("paused state observed", async () => (await backend.getPauseState()) === true);
const posAtPause = await backend.getPosition();
await Bun.sleep(400);
expect(Math.abs((await backend.getPosition()) - posAtPause)).toBeLessThan(0.3);
// Resume: clock advances again.
await backend.resume();
await waitFor("resumed state observed", async () => (await backend.getPauseState()) === false);
await waitFor(
"position advances after resume",
async () => (await backend.getPosition()) > posAtPause + 0.3,
);
// Seek lands where asked.
await backend.seek(6);
await waitFor(
"seek observed",
async () => Math.abs((await backend.getPosition()) - 6) < 0.5,
);
// Stop unloads the file — but the daemon stays resident.
await backend.stop();
expect(backend.isPlaying()).toBe(false);
expect(backend.isAlive()).toBe(true);
expect(await backend.getPosition()).toBe(0);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"preload parks the episode paused; play() of the same url starts it by unpausing",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.preload(wavB, { volume: 0, speed: 1, startPosition: 2 });
// Parked: paused, at the requested offset, nothing advancing.
await waitFor(
"preload observed paused",
async () => (await backend.getPauseState()) === true,
);
const parkedPos = await backend.getPosition();
expect(parkedPos).toBeGreaterThan(1.5);
expect(backend.isPlaying()).toBe(false);
await Bun.sleep(400);
expect(Math.abs((await backend.getPosition()) - parkedPos)).toBeLessThan(0.3);
// The boot-restore fast path: play() unpauses instead of re-loading.
await backend.play(wavB, { volume: 0, speed: 1, startPosition: parkedPos });
expect(backend.isPlaying()).toBe(true);
await waitFor(
"preload fast path plays",
async () => (await backend.getPosition()) > parkedPos + 0.3,
);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"EOF marks playback ended; resume() then replays from the top",
async () => {
const wavShort = join(tmpdir(), `podtui-backend-${process.pid}-short.wav`);
writeSineWav(wavShort, 2);
const backend = new MpvBackend();
try {
await backend.play(wavShort, { volume: 0, speed: 2 });
// 2s at 2x ends in ~1s+startup. isPlaying() must drop on its own.
await waitFor("episode ended", async () => !backend.isPlaying());
// Play pressed on a finished episode replays from the top.
await backend.resume();
await waitFor("replay started", async () => backend.isPlaying());
await waitFor(
"replay position near start",
async () => (await backend.getPosition()) < 3 && backend.isPlaying(),
);
} finally {
backend.dispose();
await Bun.$`rm -f ${wavShort}`.quiet();
}
},
{ timeout: 20000 },
);

View File

@@ -0,0 +1,231 @@
/**
* EpisodePcmCache position-index contract tests.
*
* The visualizer's bars are served from a position-indexed PCM cache that
* ffmpeg fills at full speed. These tests pin the observable contracts the
* fragile paced-ring design kept breaking:
*
* 1. readWindow(out, at) serves the EXACT window ending at playback time
* `at` — position mapping is sample-precise, independent of how fast or
* far the decode has run.
* 2. Reads outside decoded coverage return 0 — the renderer HOLDS the last
* frame. (The old reader CLAMPED to a stale buffer; re-rendering the
* same window decayed cava into a frozen junk pattern after pause.)
* 3. pauseDecode kills ffmpeg but keeps the cache: resume serves bars
* instantly, ensureDecodeAround restarts the tail decode.
* 4. Seeking into an undecoded region starts a new segment there WITHOUT
* invalidating the previously decoded coverage.
*
* Uses a self-generated WAV (440Hz sine, mono, 22050Hz s16le — the cache's
* native rate) so expected samples are computed analytically with no
* resampler tolerance.
*/
import { test, expect } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import { EpisodePcmCache } from "../src/utils/audio-pcm-cache";
const SAMPLE_RATE = 22050;
const FREQ = 440;
const AMP = 30000;
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
function writeSineWav(path: string, seconds: number): void {
const total = Math.round(seconds * SAMPLE_RATE);
const dataSize = total * 2;
const buf = new Uint8Array(44 + dataSize);
const dv = new DataView(buf.buffer);
const ascii = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
};
ascii(0, "RIFF");
dv.setUint32(4, 36 + dataSize, true);
ascii(8, "WAVE");
ascii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true); // PCM
dv.setUint16(22, 1, true); // mono
dv.setUint32(24, SAMPLE_RATE, true);
dv.setUint32(28, SAMPLE_RATE * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
ascii(36, "data");
dv.setUint32(40, dataSize, true);
for (let i = 0; i < total; i++) {
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
dv.setInt16(44 + i * 2, v, true);
}
Bun.write(path, buf);
}
/** Analytic sample value at a file index, matching the writer's formula. */
function expectedAt(fileIndex: number): number {
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
}
/** Block until the cache covers playback time `sec`. */
async function waitForCoverage(
cache: EpisodePcmCache,
sec: number,
timeoutMs = 10000,
): Promise<void> {
const start = Date.now();
while (!cache.covers(sec)) {
if (Date.now() - start > timeoutMs) {
throw new Error(`cache did not cover ${sec}s in time`);
}
await Bun.sleep(25);
}
}
/** Block until the furthest decode pass has hit stream EOF. */
async function waitForFinished(
cache: EpisodePcmCache,
timeoutMs = 10000,
): Promise<void> {
const start = Date.now();
while (!cache.decodeFinished) {
if (Date.now() - start > timeoutMs) {
throw new Error("decode did not finish in time");
}
await Bun.sleep(25);
}
}
function tmpWav(): string {
return join(tmpdir(), `podtui-pcm-${process.pid}-${Math.floor(Math.random() * 1e9)}.wav`);
}
const hasFfmpeg = !!Bun.which("ffmpeg");
const FIVE_SEC_BASE = 5 * SAMPLE_RATE; // decode offset for position-mapping tests
test.skipIf(!hasFfmpeg)(
"readWindow serves the exact window ending at the requested position",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({ url: wav });
try {
cache.startDecode(5);
await waitForCoverage(cache, 6.5);
const out = new Float64Array(512);
expect(cache.readWindow(out, 5.1)).toBe(512);
// Window ENDS at the target: out[i] is the sample at
// round(5.1*SR) - (len-1) + i (5s offset + 0.1s).
const endIdx = Math.round(5.1 * SAMPLE_RATE);
for (let i = 0; i < 512; i++) {
const idx = endIdx - (out.length - 1) + i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
// A 5ms later window is the same stream shifted by exactly
// round(0.005*SR)=110 samples — pins position mapping precision.
const later = new Float64Array(512);
expect(cache.readWindow(later, 5.105)).toBe(512);
for (let i = 0; i <= 512 - 111; i++) {
expect(later[i]).toBe(out[i + 110]);
}
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"reads outside decoded coverage return 0 (renderer holds last frame, never stale junk)",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({ url: wav });
try {
cache.startDecode(5);
await waitForCoverage(cache, 5.5);
const out = new Float64Array(512);
out.fill(-999);
// Beyond the decode frontier.
expect(cache.readWindow(out, 999)).toBe(0);
// Before the segment base (decode started at 5s).
expect(cache.readWindow(out, 4.0)).toBe(0);
// Buffer untouched — no partial/stale samples leak through.
for (let i = 0; i < 16; i++) expect(out[i]).toBe(-999);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"pauseDecode keeps the cache: resume serves instantly, tail decode continues",
async () => {
const wav = tmpWav();
writeSineWav(wav, 12); // short: full tail decode lands well under a second
const cache = new EpisodePcmCache({ url: wav });
try {
cache.startDecode(0);
await waitForCoverage(cache, 1.5);
// Pause: decode dies, cache must survive.
cache.pauseDecode();
expect(cache.decoding).toBe(false);
expect(cache.covers(1)).toBe(true);
// Serve from cache immediately after pause — this is the resume
// fast path: zero ffmpeg cold start.
const out = new Float64Array(512);
expect(cache.readWindow(out, 1.0)).toBe(512);
const endIdx = Math.round(1.0 * SAMPLE_RATE);
for (let i = 0; i < 512; i++) {
const idx = endIdx - (out.length - 1) + i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
// Resume: tail decode restarts and eventually covers the file.
cache.ensureDecodeAround(1.0);
await waitForFinished(cache);
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(11.9);
expect(cache.readWindow(out, 11.5)).toBe(512);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"seek into an undecoded region starts a new segment without losing earlier coverage",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({ url: wav });
try {
// Decoded the back half only...
cache.startDecode(10);
await waitForCoverage(cache, 11);
expect(cache.covers(2)).toBe(false);
// ...then the user seeks to 2s: a new segment decodes the front,
// and the back-half coverage stays valid throughout.
cache.ensureDecodeAround(2);
await waitForCoverage(cache, 2.2);
expect(cache.covers(10.5)).toBe(true);
const out = new Float64Array(512);
expect(cache.readWindow(out, 10.5)).toBe(512);
const endIdx = Math.round(10.5 * SAMPLE_RATE);
for (let i = 0; i < 512; i++) {
const idx = endIdx - (out.length - 1) + i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);

View File

@@ -1,239 +0,0 @@
/**
* AudioStreamReader sync contract tests.
*
* The visualizer's bars must track the player's position in real time even
* though the reader is an independent ffmpeg process. These tests pin the
* two mechanisms that make that true:
*
* 1. `read(out, target)` serves the FFT window *at* the requested playback
* position — not at the decode head, which drifts from the player
* (startup skew, stalls).
* 2. Decode is paced at the player's clock rate (`-readrate <speed>`), so
* the decode head keeps up with the position at any playback speed —
* native-rate pacing falls behind by (speed-1)s per second.
*
* Uses a self-generated WAV (440Hz sine, mono, 44.1kHz s16le) so the
* expected samples can be computed analytically and compared exactly.
*/
import { test, expect } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import { AudioStreamReader } from "../src/utils/audio-stream-reader";
const SAMPLE_RATE = 44100;
const FREQ = 440;
const AMP = 30000;
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
function writeSineWav(path: string, seconds: number): void {
const total = Math.round(seconds * SAMPLE_RATE);
const dataSize = total * 2;
const buf = new Uint8Array(44 + dataSize);
const dv = new DataView(buf.buffer);
const ascii = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
};
ascii(0, "RIFF");
dv.setUint32(4, 36 + dataSize, true);
ascii(8, "WAVE");
ascii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true); // PCM
dv.setUint16(22, 1, true); // mono
dv.setUint32(24, SAMPLE_RATE, true);
dv.setUint32(28, SAMPLE_RATE * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
ascii(36, "data");
dv.setUint32(40, dataSize, true);
for (let i = 0; i < total; i++) {
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
dv.setInt16(44 + i * 2, v, true);
}
Bun.write(path, buf);
}
/** Analytic sample value at a file index, matching the writer's formula. */
function expectedAt(fileIndex: number): number {
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
}
/**
* Block until the reader's decode head has advanced past `samples` samples.
* The head advances at readrate × real time, so this bounds how long we wait.
*/
async function waitForHead(
reader: AudioStreamReader,
samples: number,
timeoutMs = 8000,
): Promise<void> {
const start = Date.now();
while (reader.samplesWritten < samples) {
if (Date.now() - start > timeoutMs) {
throw new Error("reader decode head did not advance in time");
}
await Bun.sleep(25);
}
}
const hasFfmpeg = !!Bun.which("ffmpeg");
test.skipIf(!hasFfmpeg)(
"read() serves the exact window at the requested position",
async () => {
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
writeSineWav(wav, 20);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(5, 1);
// Cover targets up to ~5.6s (head must pass the read target).
await waitForHead(reader, Math.round(0.6 * SAMPLE_RATE));
const out = new Float64Array(512);
// Window at 5.1s: the window ENDS at the target, so out[i] is at
// file index 5*SR + round((5.1-5)*SR) - (len-1) + i.
expect(reader.read(out, 5.1)).toBe(512);
for (let i = 0; i < 512; i++) {
const idx =
Math.round(5 * SAMPLE_RATE) +
Math.round((5.1 - 5) * SAMPLE_RATE) -
(out.length - 1) +
i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
// Window at 5.105s is the same stream shifted by exactly
// round(0.005*SR)=221 samples — pins that the target maps to a
// precise offset, not "whatever the decode head is at".
const later = new Float64Array(512);
expect(reader.read(later, 5.105)).toBe(512);
for (let i = 0; i <= 512 - 222; i++) {
expect(later[i]).toBe(out[i + 221]);
}
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"decode keeps up with the player clock at 2x speed",
async () => {
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
writeSineWav(wav, 20);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(0, 2);
// At 2x pacing the head reaches 2.5s after ~1.25s of wall time.
// With native-rate pacing it would only be at ~1.25s, and the
// window at 2.5s would clamp to the head — content mismatch.
await waitForHead(reader, Math.round(2.5 * SAMPLE_RATE));
const out = new Float64Array(512);
expect(reader.read(out, 2.5)).toBe(512);
for (let i = 0; i < 512; i++) {
const idx =
Math.round(2.5 * SAMPLE_RATE) - (out.length - 1) + i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"read() clamps to the nearest samples when the target is beyond the head",
async () => {
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
writeSineWav(wav, 20);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(0, 1);
await waitForHead(reader, Math.round(0.3 * SAMPLE_RATE));
// Target far beyond the decode head: serve the newest available
// window (real sine samples, never zeros or garbage).
const out = new Float64Array(512);
expect(reader.read(out, 999)).toBe(512);
const maxAbs = Math.max(...Array.from(out, Math.abs));
expect(maxAbs).toBeGreaterThan(10000);
for (const v of out) {
expect(Math.abs(v)).toBeLessThanOrEqual(AMP + 1);
}
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"sustained render loop: ffmpeg stays alive and decode head maintains a lead over the player",
async () => {
// Real wall-clock time is required here: this test validates ffmpeg's
// actual decode pacing (-readrate + -readrate_initial_burst) against
// the platform clock. Deterministic time control cannot reproduce the
// race where ffmpeg exits early and the bars freeze — that only
// surfaces when a real process writes to a real pipe.
//
// Simulates the actual render loop: for ~5s of wall time, advance a
// simulated player position at 1× realtime and call read() each frame.
// The decode head must stay ahead of the player position so read()
// always returns 512 samples, and ffmpeg must not exit early (which
// would freeze the bars). This test would have caught the
// backpressure-pacing failure where ffmpeg decoded all data into the
// pipe buffer instantly, exited, and the readLoop stopped.
const wav = join(
tmpdir(),
`podtui-reader-${process.pid}-${Date.now()}.wav`,
);
writeSineWav(wav, 30);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(0, 1);
const FRAME_MS = 33;
const DURATION_MS = 5000;
const out = new Float64Array(512);
let successes = 0;
let failures = 0;
let minLead = Infinity;
const start = Date.now();
for (let frame = 0; Date.now() - start < DURATION_MS; frame++) {
const playerPos = (Date.now() - start) / 1000;
const count = reader.read(out, playerPos);
if (count === 512) successes++;
else failures++;
// The decode head should stay ahead of the player position.
const headPos = reader.samplesWritten / SAMPLE_RATE;
const lead = headPos - playerPos;
if (frame > 3) minLead = Math.min(minLead, lead);
await Bun.sleep(FRAME_MS);
}
// ffmpeg must still be running — it must not have exited early.
expect(reader.running).toBe(true);
// The vast majority of frames should return a full window.
// A few early failures during ffmpeg startup are acceptable.
expect(failures).toBeLessThan(5);
expect(successes).toBeGreaterThan(100);
// The decode head must maintain a positive lead over the player.
// Without -readrate_initial_burst, the head would lag behind by
// the ffmpeg startup latency and never catch up.
expect(minLead).toBeGreaterThan(0);
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 15000 },
);

77
tests/cover-art.test.ts Normal file
View 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 */
}
}
}
});

View File

@@ -28,7 +28,14 @@
* identity bun loads from disk, bypassing the leaked mock.
*/
import { test, expect, afterAll } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import {
mkdirSync,
mkdtempSync,
writeFileSync,
rmSync,
readdirSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -102,9 +109,30 @@ const wavPath = join(tmpdir(), `podtui-extpause-${process.pid}.wav`);
// loads the real file instead of a leaked mock.module from another test file.
const { useAudio } = await import("../src/hooks/useAudio?external-pause-test");
/** The pid-derived socket path the backend tells mpv to bind. */
function mpvSocket(): string {
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
/**
* The socket path of the LIVE backend daemon in this process. The backend
* names sockets per-instance (`podtui-mpv-<pid>-<instance>.sock`), so scan
* tmpdir for this pid's sockets and take the newest (the one mpv actually
* bound — earlier instances may have been orphaned by a re-spawn).
*/
function mpvSocket(): string | null {
let newest: string | null = null;
let newestMtime = 0;
for (const name of readdirSync(tmpdir())) {
if (
!name.startsWith(`podtui-mpv-${process.pid}-`) ||
!name.endsWith(".sock")
) {
continue;
}
const candidate = join(tmpdir(), name);
const mtime = statSync(candidate).mtimeMs;
if (mtime > newestMtime) {
newest = candidate;
newestMtime = mtime;
}
}
return newest;
}
/**
@@ -112,6 +140,8 @@ function mpvSocket(): string {
* media session pauses/resumes mpv without PodTUI's involvement.
*/
async function mpvCommand(command: unknown[]): Promise<void> {
const socket = mpvSocket();
if (!socket) throw new Error("backend mpv socket not found");
const { promise, resolve, reject } = Promise.withResolvers<void>();
let settled = false;
const settle = (err: Error | null): void => {
@@ -121,7 +151,7 @@ async function mpvCommand(command: unknown[]): Promise<void> {
else resolve();
};
Bun.connect({
unix: mpvSocket(),
unix: socket,
socket: {
open(s) {
s.write(JSON.stringify({ command }) + "\n");
@@ -213,8 +243,16 @@ afterAll(async () => {
} catch {
/* best-effort */
}
// The resident daemon survives stop() by design — quit it so test
// workers don't leak idle mpv processes.
try {
rmSync(mpvSocket(), { force: true });
await mpvCommand(["quit"]);
} catch {
/* best-effort */
}
try {
const socket = mpvSocket();
if (socket) rmSync(socket, { force: true });
} catch {
/* best-effort */
}

View File

@@ -15,7 +15,7 @@
*
* Uses a self-generated local WAV (a frequency chirp, so different playback
* positions produce measurably different bar output) and the real ffmpeg +
* native cavacore pipeline, mirroring audio-stream-reader.test.ts.
* native cavacore pipeline, mirroring audio-pcm-cache.test.ts.
*
* Timing note: this is an integration test of the store's real timers — the
* unload path is a genuine `setTimeout` in the store, and bun 1.3.8 ships no