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

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:

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