4.9 KiB
Design: @suaveplan/audio-ir-loader
Cross-cutting model: see openspec/changes/add-audio-ir-core/design.md. This file covers loader-specific decisions only.
1. The pipeline
load(id)
│
▼
registry.get(id) → IrManifestEntry (AUDIO_IR.NOT_REGISTERED)
│
▼
cache.get(id) → ImpulseResponse | undefined
│ hit ──────────────────────────────────────────────┐
▼ miss │
resolver.read(entry.uri, signal) → Uint8Array │ (STORAGE_URI.* | DRIVE_URI.* | BUNDLE_URI.* | FALLBACK_URI.* pass through)
│ │
▼ │
decoders[selectDecoder(entry.uri)] → DecodedAudio │ (AUDIO_DECODE.<FMT>.* pass through)
│ │
▼ │
validateAgainstManifest() → ImpulseResponse │ (AUDIO_IR_LOADER.MANIFEST_MISMATCH)
│ │
▼ │
cache.put(id, response) │
│ │
└────────────────────────────────────────────────────┘
▼
return ImpulseResponse
The loader is deliberately thin. Every layer it talks to has its own error namespace; the loader's job is to compose them and only wrap when it has something to add (manifest mismatch). It does not re-wrap STORAGE_URI.*, BUNDLE_URI.*, FALLBACK_URI.*, or AUDIO_DECODE.<FMT>.* errors — those pass through verbatim per the same rule that storage-uri and drive-uri already follow for SuaveplanError subclasses.
2. Decoder dispatch
options.decoders is Record<string, AudioDecoder>. Keys are normalized lowercase extensions ("wav", "flac", "opus") OR mime types ("audio/wav", "audio/flac"). The loader extracts the extension from entry.uri's path component (after URI parse via the resolver — for fallback:// URIs, the extension is taken from the first candidate's path since every candidate must share a decoder). If no decoder is registered for the extension, the loader throws AUDIO_IR_LOADER.DECODER_UNAVAILABLE carrying the extension discriminant.
For fallback:// URIs containing candidates with different extensions, the loader throws DECODER_UNAVAILABLE during load() because cross-format candidates make decode dispatch ambiguous. The IR content packages must keep every candidate's filename extension consistent (in practice they always will — a fallback chain is "same IR, different host").
3. Manifest validation
After decode the loader compares the DecodedAudio against the manifest declaration:
| Field | Tolerance |
|---|---|
channels |
exact (mismatch → MANIFEST_MISMATCH) |
sampleRate |
exact (mismatch → MANIFEST_MISMATCH; resampling is out-of-band) |
gainDb |
applied (multiply each sample by 10^(gainDb/20)) — no comparison |
This catches stale manifests against re-uploaded files immediately, which matters as IR collections grow and people swap files on the CDN without bumping the package.
4. Cache
In-process LRU keyed by id, bounded by total decoded bytes (channelData.byteLength summed across channels). Default off (options.cache absent → no cache); when present, maxBytes is enforced strictly. A single IR larger than maxBytes throws CACHE_OVERFLOW instead of evicting everything else — better to fail loud than to thrash. Eviction is oldest-first by insertion time (not access time — IRs are typically loaded once and held; access-time LRU adds complexity without obvious benefit at this scale).
5. Preload concurrency
preload(ids, { concurrency }) uses a bounded queue (default 4). Failures don't abort the rest — the helper returns readonly ImpulseResponse[] only if every load succeeds; otherwise it throws an aggregate carrying every per-id error. Callers that want best-effort use Promise.allSettled(ids.map(id => loader.load(id))) directly.
6. OTel
Counters are partitioned by family (read off the manifest entry) so dashboards can split mic / speaker / room load behavior. Span attributes include audio_ir.id and audio_ir.uri_kind (bundle | fallback | https | other — derived from URI scheme). The loader does NOT add a audio_ir.uri attribute — the underlying resolver/bundle/fallback plugins already attach their own URI attributes (sometimes with sensitive query strings stripped per their own rules), and we'd rather rely on those than duplicate the stripping logic.