14 KiB
Capability Spec: audio-ir-core
Package: @suaveplan/audio-ir-core
Status: SHIPPED
Every requirement carries a
**Verified by:**line.
Requirements
Requirement: ImpulseResponse and IrManifestEntry types are frozen and Zod-validated
The package SHALL export ImpulseResponse and IrManifestEntry TypeScript types AND companion Zod schemas (ImpulseResponseSchema, IrManifestEntrySchema) that exhaustively validate every field. Validated objects SHALL be frozen with Object.freeze before being returned by any factory or registry method. IrManifestEntry.id SHALL match /^[a-z][a-z0-9-]*$/, SHALL NOT contain consecutive - characters, AND SHALL be 1..128 bytes long.
Scenario: valid entry round-trips through Zod
- WHEN
IrManifestEntrySchema.parse({ id: "club-club1", family: "room", uri: "bundle://x/y.wav", channels: 2, sampleRate: 44100, gainDb: 0, license: "MIT", source: "demo" })is called - THEN the result deep-equals the input AND is frozen
Scenario: malformed id rejected
- WHEN any of
IrManifestEntrySchema.parse({ id: "Club1", ... }),IrManifestEntrySchema.parse({ id: "club--1", ... }),IrManifestEntrySchema.parse({ id: "1-club", ... })is called - THEN each call throws a
ZodError
Verified by: src/types/manifest-entry.test.ts::"valid entry round-trips through Zod and is frozen" AND src/types/manifest-entry.test.ts::"rejects uppercase / consecutive-dash / non-letter-start ids"
Requirement: IrManifest enforces id-prefix and family coherence
The IrManifestSchema SHALL refine every parsed manifest so that (a) every entry.id begins with <manifest.packageSlug>- (case-sensitive), AND (b) every entry.family equals manifest.family. Violations SHALL surface as AUDIO_IR.ID_PREFIX_MISMATCH and AUDIO_IR.FAMILY_MISMATCH respectively when the validation is invoked through the registry; raw ZodError is also acceptable when callers parse the schema directly.
Scenario: id without slug prefix is rejected
- WHEN the registry is asked to
register({ id: "wrong-1", ... })from a manifest whosepackageSlug === "club" - THEN the call throws
AudioIrErrorwithcode === "AUDIO_IR.ID_PREFIX_MISMATCH",expectedPrefix === "club-",actualId === "wrong-1"
Scenario: entry family disagreeing with manifest family is rejected
- WHEN the registry is asked to
register({ id: "club-x", family: "mic", ... })from a manifest whosefamily === "room" - THEN the call throws
AudioIrErrorwithcode === "AUDIO_IR.FAMILY_MISMATCH",manifestFamily === "room",entryFamily === "mic"
Verified by: src/types/manifest.test.ts::"rejects entry whose id lacks the manifest slug prefix" AND src/registry/registry.test.ts::"throws FAMILY_MISMATCH when entry family disagrees with manifest"
Requirement: Registry register / has / get / list / clear semantics
createIrRegistry() SHALL return an IrRegistry exposing:
register(entry: IrManifestEntry, context: { manifestSlug: string; manifestFamily: IrFamily }): void— validatesentryviaIrManifestEntrySchema, then enforces ID-prefix and family-coherence (per the prior requirement), then inserts into an internalMap<string, IrManifestEntry>. ThrowsAUDIO_IR.DUPLICATE_IDwhen the id is already registered.has(id: string): boolean— O(1) lookup.get(id: string): IrManifestEntry— O(1) lookup; throwsAUDIO_IR.NOT_REGISTEREDwhen absent.list({ family? }: { family?: IrFamily } = {}): readonly IrManifestEntry[]— stable insertion-order enumeration, filtered whenfamilyis supplied.clear(): void— resets internal state. Intended for test setups.
Scenario: register then get returns the registered entry
- WHEN
registry.register(entry, { manifestSlug: "club", manifestFamily: "room" })succeeds ANDregistry.get(entry.id)is called - THEN the returned object is identity-equal (
===) to the entry passed in, AND it is frozen
Scenario: duplicate id throws
- WHEN
registry.registeris invoked twice with two distinct entry objects that share the sameid - THEN the second call throws
AudioIrErrorwithcode === "AUDIO_IR.DUPLICATE_ID"ANDidequal to the duplicated identifier; the first registration remains in place
Scenario: list filters by family in stable insertion order
- WHEN four entries are registered in order
[r1(room), m1(mic), r2(room), s1(speaker)]ANDregistry.list({ family: "room" })is called - THEN the result is
[r1, r2](insertion order;m1ands1excluded)
Scenario: get against an unknown id throws NOT_REGISTERED
- WHEN
registry.get("nope")is called against an empty registry - THEN the call throws
AudioIrErrorwithcode === "AUDIO_IR.NOT_REGISTERED"ANDid === "nope"
Scenario: clear wipes the registry
- WHEN entries are registered AND
registry.clear()is called ANDregistry.list()is invoked - THEN
list()returns[]AND every previously-registered id reportshas(id) === false
Verified by: src/registry/registry.test.ts::"register then get returns the same frozen entry" AND src/registry/registry.test.ts::"throws DUPLICATE_ID on second register of same id" AND src/registry/registry.test.ts::"list filters by family in stable insertion order" AND src/registry/registry.test.ts::"get throws NOT_REGISTERED for unknown id" AND src/registry/registry.test.ts::"clear empties the registry"
Requirement: Manifest registration helper
The package SHALL export registerManifest(registry, manifest) that iterates manifest.entries and calls registry.register(entry, { manifestSlug: manifest.packageSlug, manifestFamily: manifest.family }) for each. On any failure (DUPLICATE_ID, ID_PREFIX_MISMATCH, FAMILY_MISMATCH, or Zod parse) the helper SHALL rethrow the original error verbatim WITHOUT rolling back prior registrations — the registry is allowed to be partially populated, since the failure surface is asserted before any IO and a re-run after fixing the manifest will hit DUPLICATE_ID on the partial entries, surfacing the conflict immediately. Callers that need atomicity SHALL use a fresh createIrRegistry() per attempt.
Scenario: well-formed manifest registers every entry
- WHEN
registerManifest(registry, { packageSlug: "club", family: "room", entries: [eA, eB] })is called - THEN
registry.has(eA.id)andregistry.has(eB.id)are bothtrueANDregistry.list({ family: "room" })includes both
Scenario: failure mid-manifest leaves registry partially populated
- WHEN
registerManifest(registry, { packageSlug: "club", family: "room", entries: [eA, badEntry, eC] })is called andbadEntryfailsID_PREFIX_MISMATCH - THEN the call throws AND
registry.has(eA.id) === trueANDregistry.has(eC.id) === false
Verified by: src/registry/register-manifest.test.ts::"registers every entry in a well-formed manifest" AND src/registry/register-manifest.test.ts::"leaves registry partially populated on mid-manifest failure"
Requirement: OpenTelemetry instrumentation
Every public registry method SHALL emit a span on the @suaveplan/audio-ir-core tracer. Successful register SHALL increment audio_ir.registrations_total{family, outcome:"success"}. Successful get/has SHALL increment audio_ir.lookups_total{family, outcome:"hit"|"miss"}. Every AUDIO_IR.* throw SHALL increment audio_ir.errors_total{code}. The package SHALL register an observable gauge audio_ir.registry_size{family} that reports the current count per family on each scrape.
Scenario: register success increments registrations_total
- WHEN
registry.registersucceeds for aroom-family entry - THEN
audio_ir.registrations_total{family:"room", outcome:"success"}is incremented by 1
Scenario: error throw increments errors_total with the resolved code
- WHEN
registry.get("nope")throwsNOT_REGISTERED - THEN
audio_ir.errors_total{code:"AUDIO_IR.NOT_REGISTERED"}is incremented by 1
Scenario: registry_size gauge reports per-family count
- WHEN two
roomand onemicentries are registered AND the gauge is sampled - THEN the sample yields
audio_ir.registry_size{family:"room"} === 2ANDaudio_ir.registry_size{family:"mic"} === 1
Verified by: src/otel/otel.test.ts::"increments registrations_total on successful register" AND src/otel/otel.test.ts::"increments errors_total on every AUDIO_IR throw" AND src/otel/otel.test.ts::"registry_size gauge reports per-family counts"
Requirement: Error-code registry exhaustion
The AUDIO_IR_CODES registry exposed via defineCodes("AUDIO_IR", [...]) SHALL contain exactly the five codes declared in the proposal: DUPLICATE_ID, NOT_REGISTERED, INVALID_MANIFEST, ID_PREFIX_MISMATCH, FAMILY_MISMATCH. Every code SHALL be reachable from a real call site exercised by the test suite (AGENTS §2c).
Scenario: every code is reachable from a real call site
- WHEN the test suite enumerates
Object.values(AUDIO_IR_CODES)AND maps each to a fixture call site - THEN every code maps to at least one
it(...)assertion that exercises the code from a real call site
Verified by: src/error-codes/error-codes.test.ts::"every AUDIO_IR code reachable from a real call site"
Requirement: MutableUriPluginRegistrar is the canonical collector interface for IR content packages
The package SHALL export MutableUriPluginRegistrar — a one-method interface of shape { use(plugin: UriPlugin): void } where UriPlugin is the type from @suaveplan/storage-uri. The interface SHALL be the canonical target type for register(registry, target, options?) across every audio-ir-{family}-plugin-<name> content package, replacing the locally-defined copy in audio-ir-room-plugin-pool and the divergent (registry, resolver) shape in audio-ir-room-plugin-club.
The interface SHALL NOT extend StorageUriResolver and SHALL NOT reference any resolver type — the collector pattern is deliberately decoupled from resolver construction so that consumers compose plugin lists freely (single resolver, parent + inner, future builder pattern).
Scenario: interface is exported from the package root barrel
- WHEN a caller imports
{ MutableUriPluginRegistrar }from@suaveplan/audio-ir-core - THEN the import resolves AND the resulting type accepts
{ use: (p: UriPlugin) => void }(verified viaexpectTypeOf)
Scenario: interface accepts a single-method object as target
- WHEN a caller passes
{ use: (p) => pluginArray.push(p) }to anyaudio-ir-{family}-plugin-<name>package'sregisterfunction - THEN the call typechecks AND
registerinvokesuseexactly once per bundle plugin
Verified by: src/registrar/registrar.spec-conformance.test.ts::"MutableUriPluginRegistrar accepts a single-method collector object" AND src/registrar/registrar.spec-conformance.test.ts::"MutableUriPluginRegistrar is exported from the root barrel"
Requirement: createUriPluginCollector() factory wraps an ordered list
The package SHALL export createUriPluginCollector() returning an object that satisfies MutableUriPluginRegistrar AND additionally exposes plugins: readonly UriPlugin[]. The internal storage SHALL be an array; use(plugin) SHALL push, preserving insertion order. The plugins accessor SHALL return a defensive snapshot — a frozen array — so that callers cannot mutate the collector's internal state after reading. Calling use after reading plugins SHALL still work; subsequent plugins reads return a new frozen snapshot reflecting the latest state.
Scenario: collector exposes pushed plugins in order
- WHEN
c = createUriPluginCollector()is constructed ANDc.use(pA),c.use(pB),c.use(pC)are invoked in that order - THEN
c.pluginsequals[pA, pB, pC](referential equality on each entry, declared order)
Scenario: plugins snapshot is frozen
- WHEN
c.pluginsis read - THEN the returned array is
Object.isFrozen(c.plugins) === trueAND attempting to mutate it ((c.plugins as UriPlugin[]).push(pX)) throws aTypeErrorunder strict mode
Scenario: subsequent use() after plugins read still works
- WHEN
c.pluginsis read ANDc.use(pD)is then invoked ANDc.pluginsis read a second time - THEN the second read returns a new array containing
pD(the first read's snapshot remains the same — it is frozen)
Verified by: src/registrar/registrar.test.ts::"collector preserves insertion order across use() calls" AND src/registrar/registrar.test.ts::"plugins snapshot is frozen and rejects mutation" AND src/registrar/registrar.test.ts::"use() after plugins read reflects in the next snapshot"
Requirement: Package barrel re-exports both additions
The package's root barrel SHALL re-export both MutableUriPluginRegistrar (as a type) AND createUriPluginCollector (as a value) under their original names. The subpath export ./registrar SHALL also resolve to a module exposing the same two symbols. Neither symbol SHALL appear under a different name elsewhere — there is one canonical exit.
Scenario: root barrel re-export
- WHEN a caller imports both symbols from
@suaveplan/audio-ir-core - THEN both imports resolve AND
typeof createUriPluginCollector === "function"AND the type import compiles
Scenario: subpath export resolves identically
- WHEN a caller imports both symbols from
@suaveplan/audio-ir-core/registrar - THEN the imports resolve to the same references returned by the root barrel
Verified by: src/registrar/registrar.spec-conformance.test.ts::"root barrel re-exports MutableUriPluginRegistrar and createUriPluginCollector" AND src/registrar/registrar.spec-conformance.test.ts::"./registrar subpath resolves to the same references as the root barrel"