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

15 KiB

Capability Spec Delta: audio-ir-loader (add-audio-ir-loader)

Package: @suaveplan/audio-ir-loader Status: PROPOSED

Every requirement carries a **Verified by:** line.

ADDED Requirements

Requirement: createIrLoader constructs a typed loader bound to a registry, resolver, and decoder map

createIrLoader(options) SHALL validate options via Zod and return an IrLoader interface containing load, preload, evict, and cacheStats. Missing registry, resolver, or empty decoders SHALL throw at construction time (Zod parse error). The decoder map's keys SHALL be normalized to lowercase at construction. When options.cache is supplied, an in-process LRU SHALL be created with the declared maxBytes; when absent, the loader operates uncached.

Scenario: minimal options construct successfully

  • WHEN createIrLoader({ registry, resolver, decoders: { wav: decodeWav } }) is called with non-null fixtures
  • THEN the returned object exposes callable load, preload, evict, and cacheStats functions

Scenario: missing decoders rejected

  • WHEN createIrLoader({ registry, resolver, decoders: {} }) is called
  • THEN the call throws a Zod error before returning a loader

Scenario: decoder keys normalized to lowercase

  • WHEN createIrLoader({ registry, resolver, decoders: { WAV: decodeWav } }) is called AND a bundle://x/y.wav entry is loaded
  • THEN the WAV key resolves the wav extension AND decode succeeds

Verified by: src/loader/loader.test.ts::"minimal options construct a loader" AND src/loader/loader.test.ts::"throws on empty decoder map" AND src/loader/loader.test.ts::"normalizes decoder keys to lowercase"


Requirement: load(id) walks registry → resolver → decoder → manifest validation

load(id, { signal? }) SHALL:

  1. Call registry.get(id) (lets AUDIO_IR.NOT_REGISTERED pass through verbatim).
  2. Check the cache (if configured) — on hit, return the cached ImpulseResponse AND increment audio_ir_loader.cache_hits_total{family}.
  3. On miss, call resolver.read(entry.uri, signal) (lets every STORAGE_URI.*, BUNDLE_URI.*, FALLBACK_URI.*, DRIVE_URI.* error pass through verbatim — no re-wrap).
  4. Dispatch to decoders[ext] where ext is the lowercase extension derived from the URI path. Missing decoder throws AUDIO_IR_LOADER.DECODER_UNAVAILABLE carrying extension.
  5. Pass the decoded DecodedAudio to manifest validation (next requirement).
  6. Construct a frozen ImpulseResponse AND insert into the cache (if configured) AND return.

Scenario: end-to-end load returns a frozen ImpulseResponse

  • WHEN the registry holds { id: "club-club1", uri: "bundle://x/club1.wav", family: "room", channels: 2, sampleRate: 44100, gainDb: 0, ... } AND the resolver returns valid WAV bytes for that URI
  • THEN await loader.load("club-club1") resolves to a frozen ImpulseResponse with id === "club-club1", channels === 2, sampleRate === 44100, channelData.length === 2

Scenario: NOT_REGISTERED passes through

  • WHEN loader.load("nope") is called against an empty registry
  • THEN the call rejects with AudioIrError whose code === "AUDIO_IR.NOT_REGISTERED" (no AUDIO_IR_LOADER.* envelope)

Scenario: resolver error passes through unchanged

  • WHEN the registered entry's URI resolves to a STORAGE_URI.NOT_FOUND from the resolver
  • THEN the call rejects with StorageUriError whose code === "STORAGE_URI.NOT_FOUND" (no re-wrap)

Scenario: missing decoder for extension throws DECODER_UNAVAILABLE

  • WHEN the entry's URI is bundle://x/foo.flac AND the loader was constructed with decoders: { wav: decodeWav } only
  • THEN the call rejects with AudioIrLoaderError whose code === "AUDIO_IR_LOADER.DECODER_UNAVAILABLE" AND extension === "flac"

Verified by: src/loader/loader.test.ts::"end-to-end load returns a frozen ImpulseResponse" AND src/loader/loader.test.ts::"passes through AUDIO_IR.NOT_REGISTERED" AND src/loader/loader.test.ts::"passes through resolver errors unchanged" AND src/loader/loader.test.ts::"throws DECODER_UNAVAILABLE for unknown extension"


Requirement: Manifest declarations are validated against decoded audio

After decode the loader SHALL compare DecodedAudio.channels to entry.channels and DecodedAudio.sampleRate to entry.sampleRate. Any mismatch SHALL throw AUDIO_IR_LOADER.MANIFEST_MISMATCH carrying both the manifest and decoded values per field. entry.gainDb SHALL be applied to every sample via sample * 10^(gainDb / 20) BEFORE the ImpulseResponse is frozen; the manifest's gainDb SHALL be preserved on the returned object's manifest.gainDb field unchanged (so callers know what was applied).

Scenario: channel-count mismatch throws

  • WHEN the manifest declares channels: 2 AND the decoded audio reports channels: 1
  • THEN the call rejects with AudioIrLoaderError whose code === "AUDIO_IR_LOADER.MANIFEST_MISMATCH", field === "channels", manifestValue === 2, decodedValue === 1

Scenario: sample-rate mismatch throws

  • WHEN the manifest declares sampleRate: 44100 AND the decoded audio reports sampleRate: 48000
  • THEN the call rejects with code === "AUDIO_IR_LOADER.MANIFEST_MISMATCH", field === "sampleRate", manifestValue === 44100, decodedValue === 48000

Scenario: gainDb is applied multiplicatively

  • WHEN the manifest declares gainDb: -6.0206 AND the decoded audio has a peak sample of 1.0
  • THEN the returned ImpulseResponse's peak sample is 0.5 ± 1e-6 AND result.manifest.gainDb === -6.0206

Verified by: src/loader/loader.test.ts::"throws MANIFEST_MISMATCH on channel-count divergence" AND src/loader/loader.test.ts::"throws MANIFEST_MISMATCH on sample-rate divergence" AND src/loader/loader.test.ts::"applies gainDb multiplicatively"


Requirement: AbortSignal threading

load(id, { signal }) SHALL inspect signal synchronously and reject with AUDIO_IR_LOADER.ABORTED BEFORE invoking the registry, resolver, or decoder when the signal is already aborted. Mid-flight aborts that surface as STORAGE_URI.ABORTED, BUNDLE_URI.ABORTED, FALLBACK_URI.ABORTED, or AUDIO_DECODE.<FMT>.ABORTED SHALL pass through unchanged (no re-wrap). preload(ids, { signal }) SHALL share the same signal across every per-id load AND SHALL stop launching new loads once the signal aborts.

Scenario: pre-aborted signal short-circuits load

  • WHEN loader.load("club-club1", { signal: AbortSignal.abort() }) is called
  • THEN the call rejects with code === "AUDIO_IR_LOADER.ABORTED" AND the registry, resolver, and decoder are NEVER invoked

Scenario: mid-flight resolver abort passes through

  • WHEN the resolver rejects with STORAGE_URI.ABORTED due to a mid-flight abort
  • THEN loader.load rejects with that same StorageUriError (no AUDIO_IR_LOADER.* envelope)

Scenario: preload stops launching new loads after abort

  • WHEN loader.preload(["a", "b", "c", "d"], { signal: controller.signal, concurrency: 2 }) is called AND controller.abort() fires after the first two complete
  • THEN the call rejects AND the third + fourth resolver reads are NEVER attempted

Verified by: src/loader/loader.test.ts::"pre-aborted signal short-circuits load" AND src/loader/loader.test.ts::"mid-flight resolver abort passes through" AND src/loader/preload.test.ts::"preload stops launching new loads after abort"


Requirement: Bounded-concurrency preload

preload(ids, { concurrency = 4 }) SHALL load every id in ids with at most concurrency in-flight at any time, return readonly ImpulseResponse[] in input order on full success, and reject with an aggregate carrying every per-id error otherwise. concurrency SHALL be clamped to [1, 32] to prevent caller mistakes from saturating the runtime.

Scenario: concurrency cap honoured

  • WHEN preload(["a","b","c","d","e","f"], { concurrency: 2 }) is called against a resolver that delays each read by 10ms
  • THEN at most two resolver reads are in flight at any time (verified via spy)

Scenario: input order preserved on success

  • WHEN every preload candidate succeeds
  • THEN the returned array's [i].id equals ids[i] for every index

Scenario: aggregate error on partial failure

  • WHEN preload(["a","b","c"]) is called and only b fails with MANIFEST_MISMATCH
  • THEN the call rejects with AudioIrLoaderError whose code === "AUDIO_IR_LOADER.DECODE_FAILED" AND whose errors: readonly { id: string; error: SuaveplanError }[] contains exactly one entry with id === "b"

Verified by: src/loader/preload.test.ts::"honours concurrency cap" AND src/loader/preload.test.ts::"preserves input order on success" AND src/loader/preload.test.ts::"rejects with aggregate on partial failure"


Requirement: Optional in-process LRU cache

When options.cache = { maxBytes } is supplied, every successful load SHALL insert the resulting ImpulseResponse into the cache keyed by id. Subsequent load(id) calls SHALL return the cached object WITHOUT invoking resolver or decoder AND SHALL increment audio_ir_loader.cache_hits_total{family}. The cache SHALL evict oldest-by-insertion entries until total cached bytes (sum of channelData[i].byteLength for every channel of every cached IR) ≤ maxBytes. A single IR larger than maxBytes SHALL throw AUDIO_IR_LOADER.CACHE_OVERFLOW rather than evicting every other entry. evict(id) SHALL drop the entry and return true; on miss it returns false. cacheStats() SHALL report { entries, bytes, hits, misses }.

Scenario: hit returns cached object without re-decode

  • WHEN loader.load("x") succeeds AND loader.load("x") is invoked a second time
  • THEN the second call returns the same reference AND the decoder is invoked exactly once (across both calls)

Scenario: LRU evicts oldest by insertion

  • WHEN the cache is configured with maxBytes sized to hold exactly two IRs AND three IRs (a, b, c) are loaded in order
  • THEN loader.cacheStats().entries === 2 AND a subsequent loader.load("a") misses (decodes again)

Scenario: oversize IR throws CACHE_OVERFLOW

  • WHEN the cache is configured with maxBytes: 1024 AND the decoded IR for id has channelData totalling 4096 bytes
  • THEN the call rejects with AudioIrLoaderError whose code === "AUDIO_IR_LOADER.CACHE_OVERFLOW" AND maxBytes === 1024 AND decodedBytes === 4096

Scenario: evict returns true on hit, false on miss

  • WHEN loader.evict("x") is called for a cached id AND then loader.evict("x") is called again
  • THEN the first call returns true AND the second call returns false

Verified by: src/cache/cache.test.ts::"second load returns cached reference without re-decode" AND src/cache/cache.test.ts::"LRU evicts oldest by insertion when maxBytes exceeded" AND src/cache/cache.test.ts::"oversize IR throws CACHE_OVERFLOW" AND src/cache/cache.test.ts::"evict returns true on hit and false on miss"


Requirement: OpenTelemetry instrumentation

Every public method SHALL emit a span on the @suaveplan/audio-ir-loader tracer AND record audio_ir_loader.load_duration_ms{family, outcome} on the meter. Successful loads SHALL increment audio_ir_loader.bytes_read_total{family} (by the raw byte count returned by the resolver) AND audio_ir_loader.frames_out_total{family} (by decoded frame count). Cache outcomes SHALL increment audio_ir_loader.cache_hits_total{family} or audio_ir_loader.cache_misses_total{family} respectively. Errors SHALL increment audio_ir_loader.errors_total{code} with the resolved *.code, whether it's an AUDIO_IR_LOADER.*, AUDIO_IR.*, STORAGE_URI.*, BUNDLE_URI.*, FALLBACK_URI.*, or AUDIO_DECODE.<FMT>.* code. Span attributes SHALL include audio_ir.id, audio_ir.family, AND audio_ir.uri_kind ("bundle" | "fallback" | "https" | "drive" | "other" based on the URI's leading scheme).

Scenario: successful load records duration and bytes_read

  • WHEN loader.load("club-club1") succeeds against a 100000-byte WAV decoding to 25000 frames
  • THEN audio_ir_loader.load_duration_ms{family:"room", outcome:"success"} records one observation AND audio_ir_loader.bytes_read_total{family:"room"} is incremented by 100000 AND audio_ir_loader.frames_out_total{family:"room"} is incremented by 25000

Scenario: cache hit increments hits counter

  • WHEN a cached id is requested a second time
  • THEN audio_ir_loader.cache_hits_total{family} is incremented by 1 AND the load_duration_ms outcome is "cache_hit"

Scenario: error increments errors_total with the resolved code

  • WHEN loader.load("nope") rejects with AUDIO_IR.NOT_REGISTERED
  • THEN audio_ir_loader.errors_total{code:"AUDIO_IR.NOT_REGISTERED"} is incremented by 1

Verified by: src/otel/otel.test.ts::"records load_duration_ms and bytes_read_total on success" AND src/otel/otel.test.ts::"increments cache_hits_total on cache hit" AND src/otel/otel.test.ts::"increments errors_total with the resolved code from any namespace"


Requirement: End-to-end bundle + fallback composition

The package SHALL include an integration test asserting that a manifest entry whose uri is a fallback://?u=bundle%3A%2F%2F…&u=https%3A%2F%2F… URI resolves correctly when the bundle candidate is missing AND the HTTPS candidate succeeds. The test SHALL stand up a node:http fixture server in beforeAll, populate the registry with one entry, configure the loader with bundleUriPlugin, fallbackUriPlugin, and a http plugin registered on the resolver, AND assert that loader.load(id) returns a valid ImpulseResponse AND that audio_ir_loader.bytes_read_total is incremented exactly once.

Scenario: bundle misses, fallback to HTTPS succeeds

  • WHEN the manifest entry's URI is buildFallbackUri(["bundle://test/missing.wav", "http://localhost:<port>/club1.wav"]) AND the bundle root contains no missing.wav AND the HTTP fixture serves valid WAV bytes at /club1.wav
  • THEN loader.load("test-id") resolves to a valid ImpulseResponse AND audio_ir_loader.bytes_read_total{family:"room"} is incremented exactly once AND fallback_uri.fallback_total{candidate_index:0, outcome:"transient"} from the underlying plugin is also incremented exactly once

Verified by: tests/integration/with-bundle-fallback.test.ts::"bundle misses fall back to HTTPS and load succeeds end-to-end"


Requirement: Error-code registry exhaustion

The AUDIO_IR_LOADER_CODES registry SHALL contain exactly the six codes declared in the proposal: DECODER_UNAVAILABLE, MANIFEST_MISMATCH, ABORTED, CACHE_OVERFLOW, DECODE_FAILED, RESOLVE_FAILED. Every code SHALL be reachable from a real call site exercised by the test suite (AGENTS §2c).

Scenario: every code is reachable

  • WHEN the test suite enumerates Object.values(AUDIO_IR_LOADER_CODES)
  • THEN every code maps to at least one it(...) assertion that exercises it from a real call site

Verified by: src/error-codes/error-codes.test.ts::"every AUDIO_IR_LOADER code reachable from a real call site"