Initial Commit
This commit is contained in:
63
openspec/archive/2026-05-19-add-audio-ir-loader/design.md
Normal file
63
openspec/archive/2026-05-19-add-audio-ir-loader/design.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Design: `@suaveplan/audio-ir-loader`
|
||||
|
||||
Cross-cutting model: see `openspec/changes/add-audio-ir-core/design.md`. This file covers loader-specific decisions only.
|
||||
|
||||
## 1. The pipeline
|
||||
|
||||
```text
|
||||
load(id)
|
||||
│
|
||||
▼
|
||||
registry.get(id) → IrManifestEntry (AUDIO_IR.NOT_REGISTERED)
|
||||
│
|
||||
▼
|
||||
cache.get(id) → ImpulseResponse | undefined
|
||||
│ hit ──────────────────────────────────────────────┐
|
||||
▼ miss │
|
||||
resolver.read(entry.uri, signal) → Uint8Array │ (STORAGE_URI.* | DRIVE_URI.* | BUNDLE_URI.* | FALLBACK_URI.* pass through)
|
||||
│ │
|
||||
▼ │
|
||||
decoders[selectDecoder(entry.uri)] → DecodedAudio │ (AUDIO_DECODE.<FMT>.* pass through)
|
||||
│ │
|
||||
▼ │
|
||||
validateAgainstManifest() → ImpulseResponse │ (AUDIO_IR_LOADER.MANIFEST_MISMATCH)
|
||||
│ │
|
||||
▼ │
|
||||
cache.put(id, response) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
▼
|
||||
return ImpulseResponse
|
||||
```
|
||||
|
||||
The loader is **deliberately thin**. Every layer it talks to has its own error namespace; the loader's job is to compose them and only wrap when it has something to add (manifest mismatch). It does **not** re-wrap `STORAGE_URI.*`, `BUNDLE_URI.*`, `FALLBACK_URI.*`, or `AUDIO_DECODE.<FMT>.*` errors — those pass through verbatim per the same rule that `storage-uri` and `drive-uri` already follow for `SuaveplanError` subclasses.
|
||||
|
||||
## 2. Decoder dispatch
|
||||
|
||||
`options.decoders` is `Record<string, AudioDecoder>`. Keys are normalized lowercase extensions (`"wav"`, `"flac"`, `"opus"`) OR mime types (`"audio/wav"`, `"audio/flac"`). The loader extracts the extension from `entry.uri`'s path component (after URI parse via the resolver — for `fallback://` URIs, the extension is taken from the first candidate's path since every candidate must share a decoder). If no decoder is registered for the extension, the loader throws `AUDIO_IR_LOADER.DECODER_UNAVAILABLE` carrying the `extension` discriminant.
|
||||
|
||||
For `fallback://` URIs containing candidates with different extensions, the loader throws `DECODER_UNAVAILABLE` during `load()` because cross-format candidates make decode dispatch ambiguous. The IR content packages must keep every candidate's filename extension consistent (in practice they always will — a fallback chain is "same IR, different host").
|
||||
|
||||
## 3. Manifest validation
|
||||
|
||||
After decode the loader compares the `DecodedAudio` against the manifest declaration:
|
||||
|
||||
| Field | Tolerance |
|
||||
|---|---|
|
||||
| `channels` | exact (mismatch → `MANIFEST_MISMATCH`) |
|
||||
| `sampleRate` | exact (mismatch → `MANIFEST_MISMATCH`; resampling is out-of-band) |
|
||||
| `gainDb` | applied (multiply each sample by `10^(gainDb/20)`) — no comparison |
|
||||
|
||||
This catches stale manifests against re-uploaded files immediately, which matters as IR collections grow and people swap files on the CDN without bumping the package.
|
||||
|
||||
## 4. Cache
|
||||
|
||||
In-process LRU keyed by `id`, bounded by total decoded bytes (`channelData.byteLength` summed across channels). Default off (`options.cache` absent → no cache); when present, `maxBytes` is enforced strictly. A single IR larger than `maxBytes` throws `CACHE_OVERFLOW` instead of evicting everything else — better to fail loud than to thrash. Eviction is oldest-first by insertion time (not access time — IRs are typically loaded once and held; access-time LRU adds complexity without obvious benefit at this scale).
|
||||
|
||||
## 5. Preload concurrency
|
||||
|
||||
`preload(ids, { concurrency })` uses a bounded queue (default 4). Failures don't abort the rest — the helper returns `readonly ImpulseResponse[]` only if every load succeeds; otherwise it throws an aggregate carrying every per-id error. Callers that want best-effort use `Promise.allSettled(ids.map(id => loader.load(id)))` directly.
|
||||
|
||||
## 6. OTel
|
||||
|
||||
Counters are partitioned by `family` (read off the manifest entry) so dashboards can split mic / speaker / room load behavior. Span attributes include `audio_ir.id` and `audio_ir.uri_kind` (`bundle` | `fallback` | `https` | other — derived from URI scheme). The loader does NOT add a `audio_ir.uri` attribute — the underlying resolver/bundle/fallback plugins already attach their own URI attributes (sometimes with sensitive query strings stripped per their own rules), and we'd rather rely on those than duplicate the stripping logic.
|
||||
54
openspec/archive/2026-05-19-add-audio-ir-loader/proposal.md
Normal file
54
openspec/archive/2026-05-19-add-audio-ir-loader/proposal.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Add `@suaveplan/audio-ir-loader`
|
||||
|
||||
**Package**: `@suaveplan/audio-ir-loader`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
## Why
|
||||
|
||||
`audio-ir-core` owns the contract + registry. Content packages ship manifest entries + bytes. Between the two we need the pipeline: resolve the URI through `StorageUriResolver`, decode the bytes through the right `AudioDecoder`, normalize against the manifest's declared channels/sampleRate/gainDb, and hand back a frozen `ImpulseResponse`. That pipeline is `audio-ir-loader`. Hard prereq: `@suaveplan/audio-ir-core` (registry shape), `@suaveplan/storage-uri` (resolver), `@suaveplan/audio-decode-wav` (initial decoder). Future decoders plug into the same dispatch table.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `createIrLoader(options): IrLoader` — factory. Options: `{ registry: IrRegistry; resolver: StorageUriResolver; decoders: Record<string, AudioDecoder>; cache?: { maxBytes: number } }`. `decoders` maps MIME or extension to a decoder; the loader picks the right one for each entry.
|
||||
- ADDED: `IrLoader.load(id: string, options?: { signal?: AbortSignal }): Promise<ImpulseResponse>` — resolves the entry from the registry, reads bytes via `resolver.read(entry.uri, signal)`, dispatches to the right decoder, validates against manifest declarations, freezes, returns.
|
||||
- ADDED: `IrLoader.preload(ids: readonly string[], options?: { signal?: AbortSignal; concurrency?: number }): Promise<readonly ImpulseResponse[]>` — bulk load with bounded concurrency (default 4).
|
||||
- ADDED: `IrLoader.evict(id: string): boolean` — drops a cached ImpulseResponse. Returns whether it was cached.
|
||||
- ADDED: `IrLoader.cacheStats(): { entries: number; bytes: number; hits: number; misses: number }` — observability for callers that care.
|
||||
- ADDED: `AUDIO_IR_LOADER.*` codes via `defineCodes`: `DECODER_UNAVAILABLE`, `MANIFEST_MISMATCH`, `ABORTED`, `CACHE_OVERFLOW`, `DECODE_FAILED`, `RESOLVE_FAILED`.
|
||||
- ADDED: OTel — `audio_ir_loader.load_duration_ms{family, outcome}`, `audio_ir_loader.bytes_read_total{family}`, `audio_ir_loader.cache_hits_total{family}`, `audio_ir_loader.cache_misses_total{family}`, `audio_ir_loader.errors_total{code}`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New decoder packages — each sibling decoder (`audio-decode-flac`, `audio-decode-opus`, …) lands in its own change.
|
||||
- Cross-process / network cache — in-process LRU only; future Redis-backed cache is a separate change.
|
||||
- Sample-rate conversion — manifest declares the expected rate; mismatch throws `MANIFEST_MISMATCH`. Future `@suaveplan/audio-resample` integration is a separate change.
|
||||
- Per-package `register()` calls — each content package owns its own registration; this loader consumes a populated registry, it does not populate it.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Genesis-original. The cross-cutting design lives at `openspec/changes/add-audio-ir-core/design.md`.
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| every spec requirement | Genesis-original I/O pipeline |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/loader/loader.test.ts` — happy path against a fake registry + fake resolver returning canned WAV bytes.
|
||||
- `src/loader/loader.spec-conformance.test.ts` — manifest-declaration mismatch tests; resolver error pass-through; decoder error pass-through; signal threading; cache hit/miss invariants.
|
||||
- `src/loader/preload.test.ts` — bulk-load concurrency bound; partial-failure aggregation.
|
||||
- `src/cache/cache.test.ts` — LRU eviction, `CACHE_OVERFLOW` when a single IR exceeds `maxBytes`.
|
||||
- `src/otel/otel.test.ts` — counter + histogram assertions with real in-memory `MeterProvider`.
|
||||
- `src/error-codes/error-codes.test.ts` — exhaustion.
|
||||
- `tests/integration/with-bundle-fallback.test.ts` — end-to-end: registry holds a `pool-outdoor-swimming-pool` entry whose URI is a `fallback://` URI; first candidate (`bundle://`) is missing from the temp dir; second candidate (`https://`) is served by a `node:http` fixture; loader resolves through the fallback walk and decodes successfully.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
No dependents yet. Verified by `bunx turbo ls --filter=...@suaveplan/audio-ir-loader` returning only the package itself.
|
||||
|
||||
First consumers will be application packages that compose `audio-ir-loader` with one or more `audio-ir-{family}-plugin-<name>` packages; each consumer lands in its own change.
|
||||
@@ -0,0 +1,214 @@
|
||||
<!-- 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 -->
|
||||
76
openspec/archive/2026-05-19-add-audio-ir-loader/tasks.md
Normal file
76
openspec/archive/2026-05-19-add-audio-ir-loader/tasks.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Tasks — Add `@suaveplan/audio-ir-loader`
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. No retroactive ticking.
|
||||
> Prereq: `add-audio-ir-core` and `add-audio-ir-loader` may proceed in parallel,
|
||||
> but the integration test in §4.9 requires both `audio-ir-core` and `audio-decode-wav` SHIPPED.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `openspec/AGENTS.md` §§ 13, 14, 14a, 2c, 6a
|
||||
- [ ] 1.2 Read `openspec/changes/add-audio-ir-core/design.md` for the cross-cutting model
|
||||
- [ ] 1.3 `proposal.md` (incl. `## Impact` per §14a)
|
||||
- [ ] 1.4 `design.md` — loader-specific pipeline + dispatch + cache decisions
|
||||
- [ ] 1.5 `specs/audio-ir-loader/spec.md` — every requirement carries `**Verified by:**`
|
||||
- [ ] 1.6 Working-notes checklist of every `### Requirement` and `#### Scenario`
|
||||
|
||||
## 2. Package scaffold
|
||||
|
||||
- [ ] 2.1 `mkdir -p packages/audio-ir/audio-ir-loader/src/{loader,cache,otel,error-codes}`
|
||||
- [ ] 2.2 `package.json` — `version: 0.0.0`, `sideEffects: false`, deps `@suaveplan/{audio-ir-core,storage-uri,error,types,opentelemetry}`, dev-dep `@suaveplan/{audio-decode-wav,storage-uri-plugin-bundle,storage-uri-plugin-fallback}` for integration tests, `zod`, `tslib`
|
||||
- [ ] 2.3 `tsconfig.lib.json`, `vite.config.ts`, `biome.json`
|
||||
- [ ] 2.4 Subpath exports: `.`, `./error-codes`
|
||||
- [ ] 2.5 `README.md`
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
- [ ] 3.1 `src/error-codes/error-codes.ts` + `.md` — `defineCodes("AUDIO_IR_LOADER", [...])`
|
||||
- [ ] 3.2 `src/loader/extension.ts` + `.md` — extension extraction (handles `bundle://`, `https://`, `fallback://`)
|
||||
- [ ] 3.3 `src/loader/manifest-validation.ts` + `.md` — channels / sampleRate / gainDb application
|
||||
- [ ] 3.4 `src/loader/loader.ts` + `.md` — `createIrLoader`, pipeline orchestration
|
||||
- [ ] 3.5 `src/loader/preload.ts` + `.md` — bounded-concurrency preload
|
||||
- [ ] 3.6 `src/cache/cache.ts` + `.md` — LRU keyed by id, byte-bounded, overflow throw
|
||||
- [ ] 3.7 `src/otel/otel.ts` + `.md` — tracer, counters, histogram
|
||||
- [ ] 3.8 `src/index.ts` — explicit named re-exports
|
||||
|
||||
## 4. Tests
|
||||
|
||||
- [ ] 4.1 `src/loader/loader.test.ts` — construction + happy path + pass-through errors
|
||||
- [ ] 4.2 `src/loader/loader.spec-conformance.test.ts` — strict-mode + signal threading invariants
|
||||
- [ ] 4.3 `src/loader/manifest-validation.test.ts` — channels/sampleRate/gainDb
|
||||
- [ ] 4.4 `src/loader/extension.test.ts` — every URI scheme + edge cases
|
||||
- [ ] 4.5 `src/loader/preload.test.ts` — concurrency, order, aggregate failure
|
||||
- [ ] 4.6 `src/cache/cache.test.ts` — hit / miss / eviction / overflow / evict()
|
||||
- [ ] 4.7 `src/otel/otel.test.ts` — real in-memory `MeterProvider`
|
||||
- [ ] 4.8 `src/error-codes/error-codes.test.ts` — exhaustion
|
||||
- [ ] 4.9 `tests/integration/with-bundle-fallback.test.ts` — real `node:http` fixture, real `bundleUriPlugin`, real `fallbackUriPlugin`, real `decodeWav`
|
||||
- [ ] 4.10 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for async)
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
- [ ] 5.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples)
|
||||
- [ ] 5.2 README `## Modules`
|
||||
- [ ] 5.3 Update root `CLAUDE.md` Audio IR subsection
|
||||
|
||||
## 6. Pre-archive verification (5-gate triple)
|
||||
|
||||
- [ ] 6.1 `bun run check` — 0/0/0/0
|
||||
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/audio-ir-loader` — green
|
||||
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/audio-ir-loader` — 100/100/100/100
|
||||
- [ ] 6.4 `bun scripts/openspec-conformance.ts @suaveplan/audio-ir-loader` — exit 0
|
||||
- [ ] 6.5 `bunx turbo build --filter=@suaveplan/audio-ir-loader` — green
|
||||
|
||||
## 7. Pre-archive 7-row checklist (AGENTS §13)
|
||||
|
||||
- [ ] 7.1 Generics threaded; `expectTypeOf` assertions
|
||||
- [ ] 7.2 Zod schemas wired at every boundary (loader options)
|
||||
- [ ] 7.3 Every `AUDIO_IR_LOADER_CODES` code thrown by a production path
|
||||
- [ ] 7.4 Strict-mode behaviors tested both on and off
|
||||
- [ ] 7.5 OTel instruments asserted with real in-memory `MeterProvider`
|
||||
- [ ] 7.6 Lifecycle dedup tested (cache idempotency, evict idempotency)
|
||||
- [ ] 7.7 No retroactive tick
|
||||
|
||||
## 8. Archive
|
||||
|
||||
- [ ] 8.1 Fold delta into new `openspec/specs/audio-ir-loader/spec.md`
|
||||
- [ ] 8.2 Move folder to `openspec/archive/<yyyy-mm-dd>-add-audio-ir-loader/`
|
||||
- [ ] 8.3 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user