Files
2026-05-27 23:29:53 +01:00

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.