# Capability Spec Delta: audio-decode-wav (add-audio-decode-wav) **Package**: `@suaveplan/audio-decode-wav` **Status**: PROPOSED > Every requirement carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Whole-buffer decode returns a normalized `DecodedAudio` `decodeWav(bytes, options?)` SHALL accept any `Uint8Array` of WAV-encoded bytes and SHALL resolve to a frozen `DecodedAudio` containing `channelData: readonly Float32Array[]` (length `>= 1`, every channel same length), `sampleRate: number` (positive integer, `> 0`, `<= 384000`), `channels: number` (equal to `channelData.length`, `>= 1`, `<= 64`), `durationSec: number` (equal to `channelData[0].length / sampleRate`), and `bytesDecoded: number` (equal to `bytes.byteLength`). Samples SHALL be in `[-1.0, 1.0]` after upstream PCM normalization; clipping SHALL NOT be applied. #### Scenario: stereo 16-bit PCM decodes to two Float32Array channels - **WHEN** `decodeWav(bytes)` is called with a known stereo 16-bit PCM fixture of 1.0 seconds at 44100 Hz - **THEN** the result has `channels === 2`, `sampleRate === 44100`, `channelData[0].length === channelData[1].length === 44100`, `durationSec === 1`, every sample is in `[-1, 1]` #### Scenario: mono 32-bit float decodes without clipping - **WHEN** `decodeWav(bytes)` is called against a mono 32-bit float fixture containing peak values `±0.999` - **THEN** the peak channelData sample is byte-identical to the fixture's peak (no clipping applied) #### Scenario: empty input throws INVALID_INPUT - **WHEN** `decodeWav(new Uint8Array(0))` is called - **THEN** the call rejects with `AudioDecodeWavError` whose `code === "AUDIO_DECODE.WAV.INVALID_INPUT"` **Verified by:** `src/decode-wav/decode-wav.test.ts::"decodes stereo 16-bit PCM fixture to two equal-length Float32Array channels"` AND `src/decode-wav/decode-wav.test.ts::"preserves 32-bit float peak values without clipping"` AND `src/decode-wav/decode-wav.test.ts::"throws INVALID_INPUT for empty Uint8Array"` --- ### Requirement: Corrupt and truncated inputs map to distinct codes `decodeWav` SHALL distinguish header-level corruption (`CORRUPT_HEADER` — `RIFF`/`WAVE` magic missing, malformed `fmt ` chunk) from body-level truncation (`TRUNCATED_BODY` — declared sample count exceeds available bytes). Unsupported codecs (compressed WAV, A-law, μ-law) SHALL throw `UNSUPPORTED_FORMAT` with a `format` discriminant carrying the upstream `AudioFormatType` value. #### Scenario: missing RIFF magic throws CORRUPT_HEADER - **WHEN** `decodeWav(bytes)` is called with a fixture whose first 4 bytes are `0x00 0x00 0x00 0x00` instead of `R I F F` - **THEN** the call rejects with `code === "AUDIO_DECODE.WAV.CORRUPT_HEADER"` #### Scenario: truncated data chunk throws TRUNCATED_BODY - **WHEN** `decodeWav(bytes)` is called with a fixture whose `data` chunk declares 88200 bytes but only 44100 are present - **THEN** the call rejects with `code === "AUDIO_DECODE.WAV.TRUNCATED_BODY"` #### Scenario: A-law fixture throws UNSUPPORTED_FORMAT with discriminant - **WHEN** `decodeWav(bytes)` is called against an A-law WAV fixture - **THEN** the call rejects with `code === "AUDIO_DECODE.WAV.UNSUPPORTED_FORMAT"` AND `error.format === "alaw"` **Verified by:** `src/decode-wav/decode-wav.test.ts::"missing RIFF magic throws CORRUPT_HEADER"` AND `src/decode-wav/decode-wav.test.ts::"truncated data chunk throws TRUNCATED_BODY"` AND `src/decode-wav/decode-wav.test.ts::"A-law fixture throws UNSUPPORTED_FORMAT with format discriminant"` --- ### Requirement: AbortSignal propagation `decodeWav(bytes, { signal })` SHALL inspect `signal` synchronously and reject with `AUDIO_DECODE.WAV.ABORTED` without invoking the upstream decoder when the signal is already aborted. Mid-decode aborts SHALL surface as `AUDIO_DECODE.WAV.ABORTED` with the original `AbortError` on `cause`. #### Scenario: pre-aborted signal short-circuits - **WHEN** `decodeWav(bytes, { signal: AbortSignal.abort() })` is called - **THEN** the call rejects with `code === "AUDIO_DECODE.WAV.ABORTED"` AND the upstream `@audio/decode-wav` is NEVER invoked #### Scenario: mid-decode abort surfaces with original on cause - **WHEN** `decodeWav(largeBytes, controller.signal)` is called AND `controller.abort()` fires during decode - **THEN** the rejection has `code === "AUDIO_DECODE.WAV.ABORTED"` AND `cause` is the originating `AbortError` **Verified by:** `src/decode-wav/decode-wav.test.ts::"pre-aborted signal short-circuits without invoking upstream"` AND `src/decode-wav/decode-wav.test.ts::"mid-decode abort surfaces AbortError on cause"` --- ### Requirement: Resampling is out-of-band — explicit `RESAMPLE_UNSUPPORTED` throw `decodeWav(bytes, { targetSampleRate })` SHALL throw `AUDIO_DECODE.WAV.RESAMPLE_UNSUPPORTED` when `targetSampleRate` is provided AND differs from the decoded sample rate. The error SHALL carry both `decodedSampleRate` and `requestedSampleRate` discriminants so callers can wire a resampler step. When `targetSampleRate` equals the decoded rate the call SHALL succeed (no-op verification). #### Scenario: mismatched targetSampleRate throws with discriminants - **WHEN** `decodeWav(bytes44k, { targetSampleRate: 48000 })` is called against a 44100 Hz fixture - **THEN** the call rejects with `code === "AUDIO_DECODE.WAV.RESAMPLE_UNSUPPORTED"`, `decodedSampleRate === 44100`, `requestedSampleRate === 48000` #### Scenario: matching targetSampleRate is a no-op - **WHEN** `decodeWav(bytes44k, { targetSampleRate: 44100 })` is called against a 44100 Hz fixture - **THEN** the call resolves to the same `DecodedAudio` as `decodeWav(bytes44k)` (byte-equal channelData) **Verified by:** `src/decode-wav/decode-wav.test.ts::"mismatched targetSampleRate throws RESAMPLE_UNSUPPORTED with both rates"` AND `src/decode-wav/decode-wav.test.ts::"matching targetSampleRate is a no-op"` --- ### Requirement: Streaming decoder over chunked sources `createWavStreamDecoder(options?)` SHALL return a `WavStreamDecoder` exposing `feed(chunk: Uint8Array): Promise`, `flush(): Promise`, and `free(): void`. The decoder SHALL accumulate header bytes from the first chunk(s) until the `data` chunk header is observed, then SHALL emit decoded `DecodedAudio` segments on each `flush()` whose `channelData` consists of all samples decoded since the previous `flush()`. Any `feed()` after `free()` SHALL throw `AUDIO_DECODE.WAV.STREAM_INVARIANT_VIOLATION`. #### Scenario: chunked feed + flush produces same audio as whole-buffer decode - **WHEN** a stereo WAV fixture is split into 1 KiB chunks AND each chunk is fed to `decoder.feed(chunk)` AND `decoder.flush()` is called at the end - **THEN** the concatenated `channelData` byte-equals `decodeWav(wholeBuffer).channelData` AND `flush()`'s `bytesDecoded` equals the buffer's total length #### Scenario: feed after free throws STREAM_INVARIANT_VIOLATION - **WHEN** `decoder.free()` is called AND `decoder.feed(chunk)` is called immediately afterwards - **THEN** the call rejects with `code === "AUDIO_DECODE.WAV.STREAM_INVARIANT_VIOLATION"` **Verified by:** `src/stream/stream.test.ts::"chunked feed concatenates to whole-buffer decode"` AND `src/stream/stream.test.ts::"feed after free throws STREAM_INVARIANT_VIOLATION"` --- ### Requirement: OpenTelemetry instrumentation Every public call SHALL emit a span on the `@suaveplan/audio-decode-wav` tracer AND SHALL record `audio_decode_wav.op_duration_ms{op, outcome}` on the meter, where `op ∈ {"decodeWav", "stream.feed", "stream.flush"}` and `outcome ∈ {"success", "error"}`. Successful decodes SHALL increment `audio_decode_wav.bytes_in_total` (by `bytes.byteLength`) AND `audio_decode_wav.frames_out_total` (by total decoded frame count). Errors SHALL increment `audio_decode_wav.errors_total{code}` with the resolved `AUDIO_DECODE.WAV.*` code. #### Scenario: successful decode records bytes_in and frames_out - **WHEN** `decodeWav(stereo44k1sec)` succeeds (88200 frames, ~352800 bytes including header) - **THEN** `audio_decode_wav.bytes_in_total` increments by `bytes.byteLength` AND `audio_decode_wav.frames_out_total` increments by 88200 #### Scenario: error increments errors_total with resolved code - **WHEN** `decodeWav(corruptBytes)` rejects with `CORRUPT_HEADER` - **THEN** `audio_decode_wav.errors_total{code:"AUDIO_DECODE.WAV.CORRUPT_HEADER"}` increments by 1 **Verified by:** `src/otel/otel.test.ts::"records bytes_in and frames_out on success"` AND `src/otel/otel.test.ts::"increments errors_total with resolved AUDIO_DECODE.WAV code"` --- ### Requirement: Error-code registry exhaustion The `AUDIO_DECODE_WAV_CODES` registry exposed via `defineCodes("AUDIO_DECODE.WAV", [...])` SHALL contain exactly the seven codes declared in the proposal: `INVALID_INPUT`, `UNSUPPORTED_FORMAT`, `CORRUPT_HEADER`, `TRUNCATED_BODY`, `ABORTED`, `RESAMPLE_UNSUPPORTED`, `STREAM_INVARIANT_VIOLATION`. Every code SHALL be reachable from a real call site exercised by the test suite (AGENTS §2c). #### Scenario: every code is reachable from a real call site - **WHEN** the test suite enumerates `Object.values(AUDIO_DECODE_WAV_CODES)` AND maps each to a fixture call site - **THEN** every code maps to at least one `it(...)` assertion that exercises the code from a real call site **Verified by:** `src/error-codes/error-codes.test.ts::"every AUDIO_DECODE.WAV code reachable from a real call site"`