# 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 ```text 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..* 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..*` 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`. 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.