64 lines
4.9 KiB
Markdown
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.
|