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, andcacheStatsfunctions
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 abundle://x/y.waventry is loaded - THEN the
WAVkey resolves thewavextension 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:
- Call
registry.get(id)(letsAUDIO_IR.NOT_REGISTEREDpass through verbatim). - Check the cache (if configured) — on hit, return the cached
ImpulseResponseAND incrementaudio_ir_loader.cache_hits_total{family}. - On miss, call
resolver.read(entry.uri, signal)(lets everySTORAGE_URI.*,BUNDLE_URI.*,FALLBACK_URI.*,DRIVE_URI.*error pass through verbatim — no re-wrap). - Dispatch to
decoders[ext]whereextis the lowercase extension derived from the URI path. Missing decoder throwsAUDIO_IR_LOADER.DECODER_UNAVAILABLEcarryingextension. - Pass the decoded
DecodedAudioto manifest validation (next requirement). - Construct a frozen
ImpulseResponseAND 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 frozenImpulseResponsewithid === "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
AudioIrErrorwhosecode === "AUDIO_IR.NOT_REGISTERED"(noAUDIO_IR_LOADER.*envelope)
Scenario: resolver error passes through unchanged
- WHEN the registered entry's URI resolves to a
STORAGE_URI.NOT_FOUNDfrom the resolver - THEN the call rejects with
StorageUriErrorwhosecode === "STORAGE_URI.NOT_FOUND"(no re-wrap)
Scenario: missing decoder for extension throws DECODER_UNAVAILABLE
- WHEN the entry's URI is
bundle://x/foo.flacAND the loader was constructed withdecoders: { wav: decodeWav }only - THEN the call rejects with
AudioIrLoaderErrorwhosecode === "AUDIO_IR_LOADER.DECODER_UNAVAILABLE"ANDextension === "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: 2AND the decoded audio reportschannels: 1 - THEN the call rejects with
AudioIrLoaderErrorwhosecode === "AUDIO_IR_LOADER.MANIFEST_MISMATCH",field === "channels",manifestValue === 2,decodedValue === 1
Scenario: sample-rate mismatch throws
- WHEN the manifest declares
sampleRate: 44100AND the decoded audio reportssampleRate: 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.0206AND the decoded audio has a peak sample of1.0 - THEN the returned ImpulseResponse's peak sample is
0.5 ± 1e-6ANDresult.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.ABORTEDdue to a mid-flight abort - THEN
loader.loadrejects with that sameStorageUriError(noAUDIO_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 ANDcontroller.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
preloadcandidate succeeds - THEN the returned array's
[i].idequalsids[i]for every index
Scenario: aggregate error on partial failure
- WHEN
preload(["a","b","c"])is called and onlybfails withMANIFEST_MISMATCH - THEN the call rejects with
AudioIrLoaderErrorwhosecode === "AUDIO_IR_LOADER.DECODE_FAILED"AND whoseerrors: readonly { id: string; error: SuaveplanError }[]contains exactly one entry withid === "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 ANDloader.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
maxBytessized to hold exactly two IRs AND three IRs (a,b,c) are loaded in order - THEN
loader.cacheStats().entries === 2AND a subsequentloader.load("a")misses (decodes again)
Scenario: oversize IR throws CACHE_OVERFLOW
- WHEN the cache is configured with
maxBytes: 1024AND the decoded IR foridhas channelData totalling 4096 bytes - THEN the call rejects with
AudioIrLoaderErrorwhosecode === "AUDIO_IR_LOADER.CACHE_OVERFLOW"ANDmaxBytes === 1024ANDdecodedBytes === 4096
Scenario: evict returns true on hit, false on miss
- WHEN
loader.evict("x")is called for a cached id AND thenloader.evict("x")is called again - THEN the first call returns
trueAND the second call returnsfalse
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 ANDaudio_ir_loader.bytes_read_total{family:"room"}is incremented by 100000 ANDaudio_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 withAUDIO_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 nomissing.wavAND the HTTP fixture serves valid WAV bytes at/club1.wav - THEN
loader.load("test-id")resolves to a validImpulseResponseANDaudio_ir_loader.bytes_read_total{family:"room"}is incremented exactly once ANDfallback_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"