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

@@ -62,7 +62,6 @@ const defaultState: AppState = {
// ── App State (config.json) ─────────────────────────────────────────────────
/** Load app state from config.json */
export async function loadAppStateFromFile(): Promise<AppState> {
try {
const cfg = await loadConfig();
@@ -88,7 +87,6 @@ export async function loadAppStateFromFile(): Promise<AppState> {
}
}
/** Save app state to config.json */
export function saveAppStateToFile(state: AppState): void {
updateConfig({
settings: state.settings,
@@ -109,7 +107,6 @@ interface ProgressEntry {
playbackSpeed?: number;
}
/** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry>
> {
@@ -145,7 +142,6 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
const SEARCH_HISTORY_FILE = "search-history.json";
/** Load search history from JSON file */
export async function loadSearchHistoryFromFile(): Promise<string[]> {
try {
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
@@ -159,7 +155,6 @@ export async function loadSearchHistoryFromFile(): Promise<string[]> {
}
}
/** Save search history to JSON file (overwrite, no backup) */
export function saveSearchHistoryToFile(history: string[]): void {
(async () => {
try {
@@ -178,7 +173,6 @@ export function saveSearchHistoryToFile(history: string[]): void {
const AUDIO_NAV_FILE = "audio-nav.json";
/** Load audio navigation state from JSON file */
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
try {
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));

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) {

View File

@@ -52,7 +52,6 @@ const DEFAULTS: Required<CavaCoreConfig> = {
scalingMode: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CavaLib = {
symbols: Record<string, (...args: any[]) => any>;
close(): void;
@@ -102,7 +101,6 @@ export class CavaCore {
this.lib = lib;
}
/** Number of frequency bars configured. */
get bars(): number {
return this._bars;
}

View File

@@ -43,7 +43,6 @@ function createEventBus(): EventBusInstance {
}
handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function
return () => {
this.off(event, handler);
};

View File

@@ -134,7 +134,6 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
}
})().catch(() => {});
}
/** Load sources from config.json */
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
try {
const cfg = await loadConfig();
@@ -144,7 +143,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
return null;
}
}
/** Save sources to config.json */
export function saveSourcesToFile<T>(sources: T[]): void {
updateConfig({ sources: sources as unknown as PodcastSource[] });
}

View File

@@ -5,9 +5,6 @@
* and multi-line comments, which is useful for configuration files.
*/
/**
* Remove JSONC comments from a string
*/
function stripComments(jsonString: string): string {
const comments = [
{ pattern: /\/\/.*$/gm, replacement: "" },
@@ -23,9 +20,6 @@ function stripComments(jsonString: string): string {
return result;
}
/**
* Parse JSONC string into a JavaScript object
*/
export function parseJSONC(jsonString: string): unknown {
const stripped = stripComments(jsonString);
return JSON.parse(stripped);

View File

@@ -95,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
}
}
/** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try {
const filePath = getConfigFilePath(KEYBINDS_FILE);

View File

@@ -9,23 +9,14 @@
import { emit } from "./event-bus"
/**
* Emit a theme reload event.
*/
function emitThemeReload(): void {
emit("theme.reload", {})
}
/**
* Emit a theme changed event.
*/
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
emit("theme.changed", { theme, mode })
}
/**
* Emit a theme mode changed event.
*/
export function emitThemeModeChanged(mode: "dark" | "light"): void {
emit("theme.mode.changed", { mode })
}

View File

@@ -1,28 +1,13 @@
/**
* Theme CSS Variable Manager
* Handles dynamic theme switching by updating CSS custom properties
* Terminal Theme Resolver
* Resolves the active theme (built-in, custom, or system-derived) to colors.
*/
import type { TerminalColors } from "@opentui/core";
import type { ThemeJson } from "../types/theme-schema";
import { THEME_JSON } from "../constants/themes";
import { getCustomThemes } from "./custom-themes";
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
import { generateSystemTheme } from "./system-theme";
/**
* Apply CSS variable data-theme attribute
*/
export function setThemeAttribute(themeName: string) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.setAttribute("data-theme", themeName);
}
export async function loadThemes() {
return await getCustomThemes();
}
export function resolveTerminalTheme(
themes: Record<string, ThemeJson>,
name: string,
@@ -32,9 +17,5 @@ export function resolveTerminalTheme(
if (name === "system" && system) {
return resolveThemeJson(generateSystemTheme(system, mode), mode);
}
const theme = themes[name] ?? themes.catppuccin;
if (!theme) {
return resolveThemeJson(THEME_JSON.catppuccin, mode);
}
return resolveThemeJson(theme, mode);
return resolveThemeJson(themes[name] ?? themes.catppuccin, mode);
}