142 lines
9.3 KiB
Markdown
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 -->
|