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

64 lines
4.9 KiB
Markdown

# 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.<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.