9.3 KiB
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
AudioDecodeWavErrorwhosecode === "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 are0x00 0x00 0x00 0x00instead ofR 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 whosedatachunk 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"ANDerror.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-wavis NEVER invoked
Scenario: mid-decode abort surfaces with original on cause
- WHEN
decodeWav(largeBytes, controller.signal)is called ANDcontroller.abort()fires during decode - THEN the rejection has
code === "AUDIO_DECODE.WAV.ABORTED"ANDcauseis the originatingAbortError
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
DecodedAudioasdecodeWav(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)ANDdecoder.flush()is called at the end - THEN the concatenated
channelDatabyte-equalsdecodeWav(wholeBuffer).channelDataANDflush()'sbytesDecodedequals the buffer's total length
Scenario: feed after free throws STREAM_INVARIANT_VIOLATION
- WHEN
decoder.free()is called ANDdecoder.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_totalincrements bybytes.byteLengthANDaudio_decode_wav.frames_out_totalincrements by 88200
Scenario: error increments errors_total with resolved code
- WHEN
decodeWav(corruptBytes)rejects withCORRUPT_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"