Files
suaveplan/openspec/specs/audio-decode-wav/spec.md
2026-05-27 23:29:53 +01:00

142 lines
9.3 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: audio-decode-wav
**Package**: `@suaveplan/audio-decode-wav`
**Status**: SHIPPED
> Every requirement carries a `**Verified by:**` line at authoring time. No exceptions.
## 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<void>`, `flush(): Promise<DecodedAudio>`, 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"`
<!-- markdownlint-enable MD024 -->