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

215 lines
15 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->