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

@@ -23,9 +23,16 @@
* pass over just that region) — earlier segments stay valid, mp3 decode of
* the same file is deterministic so abutting segments agree.
*
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min (~80 MB per 30 min),
* freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz
* high-cutoff of the visualizer's FFT config.
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a
* SLIDING WINDOW around the playback position — the decode pass stops
* once it is maxAheadSec ahead of the cursor and segments entirely older
* than keepBehindSec behind it are dropped (both re-filled/restarted on
* demand). Steady state is bounded by (maxAheadSec + keepBehindSec) of
* audio (~40 MB at the defaults) INDEPENDENT of episode length; the old
* whole-episode cache grew ~160 MB per hour of audio and hit 2.5 GB on
* long-form episodes. Fully freed on stop(). 22050 Hz covers Nyquist
* 11 kHz, above the default 10 kHz high-cutoff of the visualizer's FFT
* config.
*
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
* old reader; local files skip them (ffmpeg rejects http-only options for
@@ -49,6 +56,24 @@ const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
*/
const CLOSE_IN_PLACE_GAP_SEC = 15;
/**
* Default decode-head budget: the ffmpeg pass pauses once it is this far
* ahead of the playback cursor. Bounds RAM (~26 MB of s16 at 22050 Hz) AND
* the network pull — the old cache decoded the whole episode at 4x, so a
* 3h show pinned ~500 MB (2.5 GB+ for long-form) and dragged the entire
* remote file even when only the first 10 minutes were listened to. At 4x
* pacing a refill costs ~150s of background decode, one ffmpeg spawn per
* ~10 min of playback.
*/
const DEFAULT_DECODE_AHEAD_SEC = 600;
/**
* Default retention behind the cursor: decoded audio entirely older than
* this is dropped. Keeps pause/resume and small backward seeks instant
* without letting the window grow with playback time.
*/
const DEFAULT_KEEP_BEHIND_SEC = 300;
/**
* Monotonically increasing generation counter.
* Each startDecode() increments this; the read loop checks it to know
@@ -73,6 +98,10 @@ export interface EpisodePcmCacheOptions {
url: string;
/** Sample rate (default: 22050) */
sampleRate?: number;
/** Decode-head budget in seconds ahead of the cursor (default: 600). */
maxAheadSec?: number;
/** Retention in seconds behind the cursor (default: 300). */
keepBehindSec?: number;
}
export class EpisodePcmCache {
@@ -84,10 +113,15 @@ export class EpisodePcmCache {
private activeSegment: Segment | null = null;
readonly url: string;
readonly sampleRate: number;
/** Sliding-window budgets (see maintainWindow). */
readonly maxAheadSec: number;
readonly keepBehindSec: number;
constructor(options: EpisodePcmCacheOptions) {
this.url = options.url;
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
this.maxAheadSec = options.maxAheadSec ?? DEFAULT_DECODE_AHEAD_SEC;
this.keepBehindSec = options.keepBehindSec ?? DEFAULT_KEEP_BEHIND_SEC;
}
/** Whether an ffmpeg decode pass is currently running. */
@@ -238,11 +272,20 @@ export class EpisodePcmCache {
* new segment at `sec` (seek into a hole / resume past cached audio).
*/
ensureDecodeAround(sec: number): void {
// Enforce the sliding-window budget first (head cap, prune, refill)
// so a resume or seek never leaves stale segments behind the cursor.
this.maintainWindow(sec);
// Data already on hand: nothing needed here; only keep the tail
// filling if the decode is idle and the episode is unfinished.
// filling if the decode is idle, the episode is unfinished, AND the
// head is inside its budget. A head-capped cache ("we're maxAheadSec
// ahead, enough decoded") is NOT a stalled decode — restarting it
// here would fight maintainWindow's cap on every resume call.
if (this.covers(sec)) {
if (this._decoding || this.decodeFinished) return;
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec);
const end = this.coverageEndSec;
if (end >= sec + this.maxAheadSec) return;
this.startDecode(end > sec ? end : sec);
return;
}
@@ -265,6 +308,48 @@ export class EpisodePcmCache {
this.startDecode(Math.max(0, sec));
}
/**
* Sliding-window budget for the in-memory cache, driven by the live
* playback position. Runs on every read (the render loop is the only
* consumer that knows the cursor continuously) and on resume/seek:
* - capHead: the decode pass pauses once it is maxAheadSec ahead of the
* cursor (pauseDecode keeps the decoded data — a plain startDecode
* from the frontier refills it later).
* - prune: segments entirely keepBehindSec behind the cursor are
* dropped. A backward seek past the window restarts a segment there —
* the same mechanism as a seek into an undecoded hole, so no new
* failure mode.
* - topUp: when the cursor has outrun the head, restart the tail decode
* from the frontier (one ffmpeg spawn per maxAheadSec of playback).
* Together these bound memory to (maxAheadSec + keepBehindSec) of audio
* regardless of episode length.
*/
private maintainWindow(atSec: number): void {
const pos = Math.max(0, atSec);
if (this._decoding && this.coverageEndSec >= pos + this.maxAheadSec) {
this.pauseDecode();
}
const keepFromSec = pos - this.keepBehindSec;
if (
this.segments.some(
(seg) => seg.baseSec + seg.written / this.sampleRate < keepFromSec,
)
) {
this.segments = this.segments.filter(
(seg) => seg.baseSec + seg.written / this.sampleRate >= keepFromSec,
);
}
if (!this._decoding && !this.decodeFinished) {
const end = this.coverageEndSec;
if (end < pos + this.maxAheadSec) {
this.startDecode(Math.max(end, pos));
}
}
}
/**
* Read the PCM window ENDING at `atSec` of playback into `out`
* (Int16 magnitudes widened to f64, the scale cavacore expects).
@@ -275,6 +360,7 @@ export class EpisodePcmCache {
*/
readWindow(out: Float64Array, atSec: number): number {
if (out.length === 0) return 0;
this.maintainWindow(atSec);
const endIdx = Math.round(atSec * this.sampleRate);
const startIdx = endIdx - out.length + 1;
for (const seg of this.segments) {