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:
@@ -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 },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user