Files
2026-05-27 23:29:53 +01:00

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