# 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-`) reference this file rather than duplicating its model. ## 1. Family architecture Three layers, strictly one-way dependencies: ```text audio-ir-{mic,speaker,room}-plugin- ← 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 `-` where: - `packageSlug` is the npm package name after `@suaveplan/audio-ir--plugin-` (`club`, `pool`, `sm57`, `cathedral`, …). - `filename-stem` is 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=&u=` 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://--plugin-/` 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` wrapper with: - `register(entry)` — Zod-validates `entry`, throws `AUDIO_IR.DUPLICATE_ID` on collision (case-sensitive on `id`), `AUDIO_IR.ID_PREFIX_MISMATCH` when the id does not start with the manifest-derived prefix, `AUDIO_IR.FAMILY_MISMATCH` when `entry.family` disagrees with the manifest the entry came from (caller is expected to pass a coherent entry). - `has(id) / get(id)` — O(1) lookup. `get` throws `AUDIO_IR.NOT_REGISTERED` (callers that prefer optional semantics use `has` first). - `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 in `register()`. - `audio_ir.lookups_total{family, outcome}` — counter incremented in `get()` / `has()`. - `audio_ir.errors_total{code}` — counter incremented on every `AUDIO_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` 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.