133 lines
4.3 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|