fix(memory): bound visualizer PCM cache and feed episode cache

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).
This commit is contained in:
2026-08-12 21:02:19 -04:00
parent 77531ce41d
commit d7aec4e810
33 changed files with 941 additions and 774 deletions

View File

@@ -278,3 +278,87 @@ test.skipIf(!hasFfmpeg)(
},
{ 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 },
);