Files
suaveplan/openspec/archive/2026-05-19-add-audio-ir-loader/proposal.md
2026-05-27 23:29:53 +01:00

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 } }. decoders maps 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 via resolver.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 via defineCodes: 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-resample integration 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_OVERFLOW when a single IR exceeds maxBytes.
  • src/otel/otel.test.ts — counter + histogram assertions with real in-memory MeterProvider.
  • src/error-codes/error-codes.test.ts — exhaustion.
  • tests/integration/with-bundle-fallback.test.ts — end-to-end: registry holds a pool-outdoor-swimming-pool entry whose URI is a fallback:// URI; first candidate (bundle://) is missing from the temp dir; second candidate (https://) is served by a node:http fixture; 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.