Files
PodTui/tests/audio-stream-reader.test.ts

173 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
},
);