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:
192
tests/audio-backend.test.ts
Normal file
192
tests/audio-backend.test.ts
Normal 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 },
|
||||
);
|
||||
231
tests/audio-pcm-cache.test.ts
Normal file
231
tests/audio-pcm-cache.test.ts
Normal 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 },
|
||||
);
|
||||
@@ -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
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user