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.
193 lines
6.3 KiB
TypeScript
193 lines
6.3 KiB
TypeScript
/**
|
|
* 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 },
|
|
);
|