4.2 KiB
Add @suaveplan/audio-ir-loader
Package: @suaveplan/audio-ir-loader
Status: PROPOSED
Type: new-package
Why
audio-ir-core owns the contract + registry. Content packages ship manifest entries + bytes. Between the two we need the pipeline: resolve the URI through StorageUriResolver, decode the bytes through the right AudioDecoder, normalize against the manifest's declared channels/sampleRate/gainDb, and hand back a frozen ImpulseResponse. That pipeline is audio-ir-loader. Hard prereq: @suaveplan/audio-ir-core (registry shape), @suaveplan/storage-uri (resolver), @suaveplan/audio-decode-wav (initial decoder). Future decoders plug into the same dispatch table.
What changes
- ADDED:
createIrLoader(options): IrLoader— factory. Options:{ registry: IrRegistry; resolver: StorageUriResolver; decoders: Record<string, AudioDecoder>; cache?: { maxBytes: number } }.decodersmaps MIME or extension to a decoder; the loader picks the right one for each entry. - ADDED:
IrLoader.load(id: string, options?: { signal?: AbortSignal }): Promise<ImpulseResponse>— resolves the entry from the registry, reads bytes viaresolver.read(entry.uri, signal), dispatches to the right decoder, validates against manifest declarations, freezes, returns. - ADDED:
IrLoader.preload(ids: readonly string[], options?: { signal?: AbortSignal; concurrency?: number }): Promise<readonly ImpulseResponse[]>— bulk load with bounded concurrency (default 4). - ADDED:
IrLoader.evict(id: string): boolean— drops a cached ImpulseResponse. Returns whether it was cached. - ADDED:
IrLoader.cacheStats(): { entries: number; bytes: number; hits: number; misses: number }— observability for callers that care. - ADDED:
AUDIO_IR_LOADER.*codes viadefineCodes:DECODER_UNAVAILABLE,MANIFEST_MISMATCH,ABORTED,CACHE_OVERFLOW,DECODE_FAILED,RESOLVE_FAILED. - ADDED: OTel —
audio_ir_loader.load_duration_ms{family, outcome},audio_ir_loader.bytes_read_total{family},audio_ir_loader.cache_hits_total{family},audio_ir_loader.cache_misses_total{family},audio_ir_loader.errors_total{code}.
Out of scope
- New decoder packages — each sibling decoder (
audio-decode-flac,audio-decode-opus, …) lands in its own change. - Cross-process / network cache — in-process LRU only; future Redis-backed cache is a separate change.
- Sample-rate conversion — manifest declares the expected rate; mismatch throws
MANIFEST_MISMATCH. Future@suaveplan/audio-resampleintegration is a separate change. - Per-package
register()calls — each content package owns its own registration; this loader consumes a populated registry, it does not populate it.
Source mapping
Genesis-original. The cross-cutting design lives at openspec/changes/add-audio-ir-core/design.md.
Requirements with no source line
| Requirement | Justification |
|---|---|
| every spec requirement | Genesis-original I/O pipeline |
Verification approach
src/loader/loader.test.ts— happy path against a fake registry + fake resolver returning canned WAV bytes.src/loader/loader.spec-conformance.test.ts— manifest-declaration mismatch tests; resolver error pass-through; decoder error pass-through; signal threading; cache hit/miss invariants.src/loader/preload.test.ts— bulk-load concurrency bound; partial-failure aggregation.src/cache/cache.test.ts— LRU eviction,CACHE_OVERFLOWwhen a single IR exceedsmaxBytes.src/otel/otel.test.ts— counter + histogram assertions with real in-memoryMeterProvider.src/error-codes/error-codes.test.ts— exhaustion.tests/integration/with-bundle-fallback.test.ts— end-to-end: registry holds apool-outdoor-swimming-poolentry whose URI is afallback://URI; first candidate (bundle://) is missing from the temp dir; second candidate (https://) is served by anode:httpfixture; loader resolves through the fallback walk and decodes successfully.
Impact
Affected dependents
No dependents yet. Verified by bunx turbo ls --filter=...@suaveplan/audio-ir-loader returning only the package itself.
First consumers will be application packages that compose audio-ir-loader with one or more audio-ir-{family}-plugin-<name> packages; each consumer lands in its own change.