Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.

View 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.

View File

@@ -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 -->

View 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`