import type { ImpulseResponse } from "@suaveplan/audio-ir-core"; import { AUDIO_IR_LOADER_CODES, AudioIrLoaderError, } from "../error-codes/error-codes.js"; /** Snapshot of cache state surfaced via `IrLoader.cacheStats()`. */ export interface CacheStats { readonly entries: number; readonly bytes: number; readonly hits: number; readonly misses: number; } /** * Compute the total decoded byte-length of an `ImpulseResponse`'s * channelData. Used both for cache accounting and for the `CACHE_OVERFLOW` * pre-check. */ export function impulseResponseBytes(ir: ImpulseResponse): number { let total = 0; for (const channel of ir.channelData) { total += channel.byteLength; } return total; } interface CacheEntry { readonly ir: ImpulseResponse; readonly bytes: number; } /** * In-process LRU keyed by IR id, bounded by total decoded bytes. * * Eviction is oldest-by-insertion (not access-time): IRs are typically * loaded once and held, so insertion-time eviction is both simpler and * better-behaved than access-time LRU at this scale. A single IR larger * than the configured `maxBytes` throws `CACHE_OVERFLOW` instead of * evicting every other entry — better to fail loud than to thrash. */ export class ImpulseResponseCache { readonly #maxBytes: number; readonly #entries = new Map(); #bytes = 0; #hits = 0; #misses = 0; public constructor(maxBytes: number) { if (!Number.isInteger(maxBytes) || maxBytes <= 0) { throw new AudioIrLoaderError( AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW, "cache maxBytes must be a positive integer", { maxBytes } ); } this.#maxBytes = maxBytes; } /** Configured byte budget. */ public get maxBytes(): number { return this.#maxBytes; } /** `get(id)` — increments hits on success, misses on absence. */ public get(id: string): ImpulseResponse | undefined { const entry = this.#entries.get(id); if (entry === undefined) { this.#misses += 1; return undefined; } this.#hits += 1; return entry.ir; } /** * Insert `ir` into the cache. Throws `CACHE_OVERFLOW` if `ir` alone * exceeds `maxBytes`. Otherwise evicts oldest-by-insertion entries * until total bytes ≤ `maxBytes - irBytes`, then inserts. */ public put(id: string, ir: ImpulseResponse): void { const irBytes = impulseResponseBytes(ir); if (irBytes > this.#maxBytes) { throw new AudioIrLoaderError( AUDIO_IR_LOADER_CODES.CACHE_OVERFLOW, `IR ${id} decodes to ${irBytes} bytes; cache budget is ${this.#maxBytes}`, { id, maxBytes: this.#maxBytes, decodedBytes: irBytes, } ); } const existing = this.#entries.get(id); if (existing !== undefined) { this.#bytes -= existing.bytes; this.#entries.delete(id); } // Evict oldest until insertion fits. The constructor enforces // `irBytes <= maxBytes`, so the loop is guaranteed to terminate // before draining every entry — the `Map.keys()` iterator and // its corresponding `get()` are always defined for live entries. const iter = this.#entries.keys(); while (this.#bytes + irBytes > this.#maxBytes) { const evictId = iter.next().value as string; const evictEntry = this.#entries.get(evictId) as CacheEntry; this.#bytes -= evictEntry.bytes; this.#entries.delete(evictId); } this.#entries.set(id, { ir, bytes: irBytes }); this.#bytes += irBytes; } /** Drop the entry for `id`. Returns whether anything was removed. */ public evict(id: string): boolean { const entry = this.#entries.get(id); if (entry === undefined) return false; this.#bytes -= entry.bytes; this.#entries.delete(id); return true; } /** Snapshot of current cache state. */ public stats(): CacheStats { return { entries: this.#entries.size, bytes: this.#bytes, hits: this.#hits, misses: this.#misses, }; } }