71 lines
5.0 KiB
Markdown
71 lines
5.0 KiB
Markdown
# 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:
|
|
|
|
```text
|
|
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:
|
|
|
|
- `packageSlug` is the npm package name after `@suaveplan/audio-ir-<family>-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=<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-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<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.
|