5.0 KiB
Design: @suaveplan/audio-ir-core
This file holds the cross-cutting design for the IR family (AGENTS §193). Sibling packages (audio-ir-loader, audio-ir-{mic,speaker,room}-plugin-<name>) reference this file rather than duplicating its model.
1. Family architecture
Three layers, strictly one-way dependencies:
audio-ir-{mic,speaker,room}-plugin-<name> ← content packages
↓ depends on
audio-ir-loader ← URI walk + decode pipeline
↓ depends on
audio-ir-core ← contract + registry + codes
audio-ir-core is universal-tier with zero @suaveplan/audio-ir-* deps. It declares the contract; audio-ir-loader declares the I/O pipeline; content packages declare data + a thin register(registry, resolver) entrypoint.
The contract is manifest-first. A content package contributes data exclusively through its IrManifest. No package gets to extend the runtime contract — keeping the registry homogeneous keeps the loader, UIs, and OTel attributes stable as the IR collection grows.
2. ID derivation rule
Every IrManifestEntry.id is <packageSlug>-<filename-stem> where:
packageSlugis the npm package name after@suaveplan/audio-ir-<family>-plugin-(club,pool,sm57,cathedral, …).filename-stemis the source file's basename without extension, lowercased, with non-[a-z0-9-]characters replaced by-.
Examples:
| Package | File | ID |
|---|---|---|
audio-ir-room-plugin-club |
club1.wav |
club-club1 |
audio-ir-room-plugin-pool |
outdoor-swimming-pool.wav |
pool-outdoor-swimming-pool |
audio-ir-mic-plugin-sm57 |
axis-on.wav |
sm57-axis-on |
The IrManifest Zod schema enforces the prefix at validation time (refine(...)). Collisions are caught at registry.register(entry) time with AUDIO_IR.DUPLICATE_ID.
The family segment (mic/speaker/room) is NOT in the ID — it lives on manifest.family and is denormalized onto each entry by the package's register() call. This keeps IDs short and lets UIs filter by family by reading the manifest entry, not by parsing strings.
3. URI strategy
Every entry has exactly one uri: string. Multi-source semantics are encoded as a fallback://?u=<uri1>&u=<uri2> URI rather than a manifest array — this leans on the shipped @suaveplan/storage-uri-plugin-fallback semantics (ordered walk, transient-vs-semantic classification, ALL_CANDIDATES_FAILED per-candidate cause chain) without duplicating them at the IR layer.
For bundled local files, the URI is bundle://<packageSlug>-<family>-plugin-<name>/<filename> resolved by the shipped @suaveplan/storage-uri-plugin-bundle plugin against new URL("../files/", import.meta.url). The package's register(registry, resolver) call constructs the bundleUriPlugin and feeds it into the supplied StorageUriResolver.
4. Registry semantics
IrRegistry is a thin Map<string, IrManifestEntry> wrapper with:
register(entry)— Zod-validatesentry, throwsAUDIO_IR.DUPLICATE_IDon collision (case-sensitive onid),AUDIO_IR.ID_PREFIX_MISMATCHwhen the id does not start with the manifest-derived prefix,AUDIO_IR.FAMILY_MISMATCHwhenentry.familydisagrees with the manifest the entry came from (caller is expected to pass a coherent entry).has(id) / get(id)— O(1) lookup.getthrowsAUDIO_IR.NOT_REGISTERED(callers that prefer optional semantics usehasfirst).list({ family? })— filtered enumeration. Stable insertion order.clear()— wipes the registry. Provided for tests; production callers don't need it.
The registry holds manifest entries only, not decoded ImpulseResponse values. Decoding happens lazily in audio-ir-loader.load(id) — keeping the registry data-only means content packages can be tree-shaken at the manifest level and decoders are pulled in only on demand.
5. OTel surface
audio-ir-core is the layer that knows about the registry's shape, so it owns:
audio_ir.registry_size{family}— observable gauge sampled on read.audio_ir.registrations_total{family, outcome}— counter incremented inregister().audio_ir.lookups_total{family, outcome}— counter incremented inget()/has().audio_ir.errors_total{code}— counter incremented on everyAUDIO_IR.*throw.
The loader owns its own audio_ir_loader.* metrics (decode duration, fallback hop, cache hit). The two surfaces do not overlap.
6. Why not pluggable contracts
A future Q would be "can I store extra fields per IR (mic position, room volume, ITU loudness measurement)?". The answer is yes, on the manifest entry, but only as opaque metadata — IrManifestEntry will grow a metadata?: Record<string, unknown> field in a future MODIFIED-Requirements change. Today's spec keeps the shape minimal so we don't ship a flexibility budget we don't use. AGENTS §0a's "no cut-down replacements" rule applies to migrated packages — this is a genesis-original, and YAGNI applies until a real consumer needs the extension.