The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled the whole remote file even when only minutes were listened to. - audio-pcm-cache: sliding window around the playback position — the decode head caps at maxAheadSec (600s) ahead of the cursor, segments older than keepBehindSec (300s) are pruned, and the tail refills as playback advances. Steady state ~40 MB regardless of episode length; a backward seek past the window restarts a segment there (the existing seek-hole mechanism, no new failure mode). - feed: cap the full-parse episode cache at 1000 episodes/feed so archive-heavy subscriptions can't pin their entire history in RAM; the visible list stays bounded by the user's cache preference and fetch-more keeps working within the ceiling. - tests: pin the new head-cap and prune contracts (8/8 in audio-pcm-cache.test.ts; full suite 193 pass). Also includes the in-flight cleanup/refactor pass (cover-art resolve helper, page and comment tightening, ESLint config removal).
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
/**
|
|
* 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");
|
|
|
|
test.skipIf(!hasFfmpeg)(
|
|
"far-forward seek into undecoded territory restarts decode AT the target (bars recover in seconds, not minutes)",
|
|
async () => {
|
|
const wav = tmpWav();
|
|
writeSineWav(wav, 60);
|
|
const cache = new EpisodePcmCache({ url: wav });
|
|
try {
|
|
cache.startDecode(0);
|
|
await waitForCoverage(cache, 1);
|
|
|
|
// Skipping 45s ahead while the pass still crawls at 4x must restart
|
|
// the segment at the target — waiting for the frontier to chew
|
|
// through the skipped region is minutes of frozen bars.
|
|
cache.ensureDecodeAround(45);
|
|
expect(cache.decoding).toBe(true);
|
|
expect(cache.activeDecodeBaseSec).toBe(45);
|
|
await waitForCoverage(cache, 45.1);
|
|
} finally {
|
|
cache.stop();
|
|
await Bun.$`rm -f ${wav}`.quiet();
|
|
}
|
|
},
|
|
{ timeout: 20000 },
|
|
);
|
|
|
|
test.skipIf(!hasFfmpeg)(
|
|
"small forward gap closes in place — no needless reconnect",
|
|
async () => {
|
|
const wav = tmpWav();
|
|
writeSineWav(wav, 60);
|
|
const cache = new EpisodePcmCache({ url: wav });
|
|
try {
|
|
cache.startDecode(0);
|
|
await waitForCoverage(cache, 2);
|
|
|
|
// ~5s past the running frontier: at 4x pacing this closes in ~1.5s,
|
|
// cheaper than a reconnect — the pass must NOT restart.
|
|
const target = cache.coverageEndSec + 5;
|
|
cache.ensureDecodeAround(target);
|
|
expect(cache.activeDecodeBaseSec).toBe(0);
|
|
await waitForCoverage(cache, target);
|
|
} finally {
|
|
cache.stop();
|
|
await Bun.$`rm -f ${wav}`.quiet();
|
|
}
|
|
},
|
|
{ timeout: 20000 },
|
|
);
|
|
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 },
|
|
);
|
|
|
|
test.skipIf(!hasFfmpeg)(
|
|
"decode head caps at maxAheadSec ahead of the cursor — the cache is a window, not a whole-episode dump",
|
|
async () => {
|
|
const wav = tmpWav();
|
|
writeSineWav(wav, 30);
|
|
const cache = new EpisodePcmCache({
|
|
url: wav,
|
|
maxAheadSec: 4,
|
|
keepBehindSec: 2,
|
|
});
|
|
try {
|
|
cache.startDecode(0);
|
|
// The 8s initial burst delivers the front of the file instantly.
|
|
await waitForCoverage(cache, 5);
|
|
|
|
// Park the cursor at 0 and drive the cap (the render loop reads
|
|
// every frame; the cap applies on the first read past the head).
|
|
const out = new Float64Array(512);
|
|
for (let i = 0; i < 30 && cache.decoding; i++) {
|
|
cache.readWindow(out, 0);
|
|
await Bun.sleep(20);
|
|
}
|
|
|
|
// Paused at the head budget (4s) + one 8s burst of slack — NOT
|
|
// decoded to the 30s EOF.
|
|
expect(cache.decoding).toBe(false);
|
|
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(4);
|
|
expect(cache.coverageEndSec).toBeLessThanOrEqual(4 + 8 + 1);
|
|
expect(cache.decodeFinished).toBe(false);
|
|
|
|
// A parked cursor keeps the cap: more reads must not restart
|
|
// the pass or grow the cache.
|
|
const cappedAt = cache.coverageEndSec;
|
|
for (let i = 0; i < 10; i++) {
|
|
cache.readWindow(out, 0);
|
|
await Bun.sleep(20);
|
|
}
|
|
expect(cache.decoding).toBe(false);
|
|
expect(cache.coverageEndSec).toBeLessThanOrEqual(cappedAt + 1);
|
|
} finally {
|
|
cache.stop();
|
|
await Bun.$`rm -f ${wav}`.quiet();
|
|
}
|
|
},
|
|
{ timeout: 20000 },
|
|
);
|
|
|
|
test.skipIf(!hasFfmpeg)(
|
|
"the window prunes segments behind the cursor as playback advances",
|
|
async () => {
|
|
const wav = tmpWav();
|
|
writeSineWav(wav, 30);
|
|
const cache = new EpisodePcmCache({
|
|
url: wav,
|
|
maxAheadSec: 4,
|
|
keepBehindSec: 2,
|
|
});
|
|
try {
|
|
// Two segments: the back half [10, ~18] and, after the seek,
|
|
// the front [2, ~10].
|
|
cache.startDecode(10);
|
|
await waitForCoverage(cache, 11);
|
|
cache.ensureDecodeAround(2);
|
|
await waitForCoverage(cache, 2.2);
|
|
expect(cache.covers(2.5)).toBe(true);
|
|
expect(cache.covers(10.5)).toBe(true);
|
|
|
|
// Cursor advances past the front segment's end + keepBehind:
|
|
// the front must fall out of the window, the back must survive.
|
|
const out = new Float64Array(512);
|
|
for (let i = 0; i < 40 && cache.covers(2.5); i++) {
|
|
cache.readWindow(out, 13);
|
|
await Bun.sleep(25);
|
|
}
|
|
expect(cache.covers(2.5)).toBe(false);
|
|
expect(cache.covers(10.5)).toBe(true);
|
|
} finally {
|
|
cache.stop();
|
|
await Bun.$`rm -f ${wav}`.quiet();
|
|
}
|
|
},
|
|
{ timeout: 20000 },
|
|
);
|