Files
suaveplan/openspec/specs/audio-ir-core/spec.md
2026-05-27 23:29:53 +01:00

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 whose packageSlug === "club"
  • THEN the call throws AudioIrError with code === "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 whose family === "room"
  • THEN the call throws AudioIrError with code === "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:

  1. register(entry: IrManifestEntry, context: { manifestSlug: string; manifestFamily: IrFamily }): void — validates entry via IrManifestEntrySchema, then enforces ID-prefix and family-coherence (per the prior requirement), then inserts into an internal Map<string, IrManifestEntry>. Throws AUDIO_IR.DUPLICATE_ID when the id is already registered.
  2. has(id: string): boolean — O(1) lookup.
  3. get(id: string): IrManifestEntry — O(1) lookup; throws AUDIO_IR.NOT_REGISTERED when absent.
  4. list({ family? }: { family?: IrFamily } = {}): readonly IrManifestEntry[] — stable insertion-order enumeration, filtered when family is supplied.
  5. 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 AND registry.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.register is invoked twice with two distinct entry objects that share the same id
  • THEN the second call throws AudioIrError with code === "AUDIO_IR.DUPLICATE_ID" AND id equal 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)] AND registry.list({ family: "room" }) is called
  • THEN the result is [r1, r2] (insertion order; m1 and s1 excluded)

Scenario: get against an unknown id throws NOT_REGISTERED

  • WHEN registry.get("nope") is called against an empty registry
  • THEN the call throws AudioIrError with code === "AUDIO_IR.NOT_REGISTERED" AND id === "nope"

Scenario: clear wipes the registry

  • WHEN entries are registered AND registry.clear() is called AND registry.list() is invoked
  • THEN list() returns [] AND every previously-registered id reports has(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) and registry.has(eB.id) are both true AND registry.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 and badEntry fails ID_PREFIX_MISMATCH
  • THEN the call throws AND registry.has(eA.id) === true AND registry.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.register succeeds for a room-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") throws NOT_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 room and one mic entries are registered AND the gauge is sampled
  • THEN the sample yields audio_ir.registry_size{family:"room"} === 2 AND audio_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 via expectTypeOf)

Scenario: interface accepts a single-method object as target

  • WHEN a caller passes { use: (p) => pluginArray.push(p) } to any audio-ir-{family}-plugin-<name> package's register function
  • THEN the call typechecks AND register invokes use exactly 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 AND c.use(pA), c.use(pB), c.use(pC) are invoked in that order
  • THEN c.plugins equals [pA, pB, pC] (referential equality on each entry, declared order)

Scenario: plugins snapshot is frozen

  • WHEN c.plugins is read
  • THEN the returned array is Object.isFrozen(c.plugins) === true AND attempting to mutate it ((c.plugins as UriPlugin[]).push(pX)) throws a TypeError under strict mode

Scenario: subsequent use() after plugins read still works

  • WHEN c.plugins is read AND c.use(pD) is then invoked AND c.plugins is 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"