Initial Commit
This commit is contained in:
132
packages/media/audio-ir/audio-ir-loader/src/cache/cache.ts
vendored
Normal file
132
packages/media/audio-ir/audio-ir-loader/src/cache/cache.ts
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user