Files
suaveplan/packages/media/audio-ir/audio-ir-loader/src/cache/cache.ts
2026-05-27 23:47:53 +01:00

133 lines
4.3 KiB
TypeScript

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<string, CacheEntry>();
#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,
};
}
}