Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
# Add `@suaveplan/audio-decode-wav`
**Package**: `@suaveplan/audio-decode-wav`
**Status**: PROPOSED
**Type**: new-package
## Why
The audio ecosystem (IR loader, sprite packer, future codec family) needs a uniform `AudioDecoder` contract over WAV bytes. `../audio-decode/packages/decode-wav` exists upstream (audiojs `@audio/decode-wav` 1.2.0, MIT) and decodes correctly, but it has no error-code namespace, no Zod validation of inputs, no OTel, no `AbortSignal` threading, and returns its own ad-hoc `{ channelData: Float32Array[], sampleRate: number }` shape with no conformance guarantees about channel-count consistency, sample-rate validity, or sample range. This package wraps it behind a stable contract so every downstream consumer (starting with `audio-ir-loader`) treats WAV the same way it will treat MP3, FLAC, Opus, etc. once those siblings land.
## What changes
- ADDED: `decodeWav(bytes: Uint8Array, options?: AudioDecodeOptions): Promise<DecodedAudio>` — whole-buffer decode behind the universal `AudioDecoder` contract.
- ADDED: `createWavStreamDecoder(options?: AudioDecodeOptions): WavStreamDecoder` — chunked decode for `ReadableStream<Uint8Array>` sources.
- ADDED: `DecodedAudio` — `{ channelData: readonly Float32Array[]; sampleRate: number; channels: number; durationSec: number; bytesDecoded: number }`. Frozen, validated.
- ADDED: `AudioDecodeOptions` — `{ signal?: AbortSignal; targetSampleRate?: number }`. Resampling is out of scope here — if `targetSampleRate` is supplied and differs, decode throws `AUDIO_DECODE.WAV.RESAMPLE_UNSUPPORTED` so callers know to add a resampler step.
- ADDED: `AUDIO_DECODE.WAV.*` codes via `defineCodes`: `INVALID_INPUT`, `UNSUPPORTED_FORMAT`, `CORRUPT_HEADER`, `TRUNCATED_BODY`, `ABORTED`, `RESAMPLE_UNSUPPORTED`, `STREAM_INVARIANT_VIOLATION`.
- ADDED: OTel spans on `@suaveplan/audio-decode-wav` tracer; counters `audio_decode_wav.bytes_in_total`, `audio_decode_wav.frames_out_total`, `audio_decode_wav.errors_total{code}`; histogram `audio_decode_wav.op_duration_ms{outcome}`.
## Out of scope
Nothing functional — package lands at full functional completeness per the no-deferrals rule. The following are explicitly NOT in this package, because they belong to siblings:
- Resampling — belongs in a future `@suaveplan/audio-resample` package; this package surfaces `RESAMPLE_UNSUPPORTED` so callers know to insert that step.
- Non-WAV formats — sibling packages `audio-decode-{flac,mp3,opus,…}` will wrap their respective upstream decoders behind the same `AudioDecoder` contract.
- The format-dispatching `audio-decode` umbrella — separate package, separate change.
## Source mapping
Genesis-original wrapper over the upstream `@audio/decode-wav` library at `../audio-decode/packages/decode-wav/`.
### Requirements with no source line
| Requirement | Justification |
|---|---|
| every spec requirement | Upstream `@audio/decode-wav` has no `enterprise/<pkg>.md` — every requirement is genesis-original wrapper contract |
## Verification approach
- `src/decode-wav/decode-wav.test.ts` — covers `decodeWav` happy path (mono/stereo/16-bit PCM/24-bit PCM/32-bit float fixtures), `INVALID_INPUT`, `CORRUPT_HEADER`, `TRUNCATED_BODY`, `UNSUPPORTED_FORMAT`, `RESAMPLE_UNSUPPORTED`, `ABORTED`.
- `src/stream/stream.test.ts` — covers `createWavStreamDecoder` chunk feed, flush, free, `STREAM_INVARIANT_VIOLATION` when chunks arrive after `free()`.
- `src/otel/otel.test.ts` — covers span emission, counter increments, histogram observations.
- `src/error-codes/error-codes.test.ts` — exhaustion check: every `AUDIO_DECODE.WAV.*` code reachable.
## Impact
### Affected dependents
No dependents yet (the package is brand new). Verified by `bunx turbo ls --filter=...@suaveplan/audio-decode-wav` returning only the package itself.
The first downstream consumer will be `@suaveplan/audio-ir-loader`, landing in a separate change `add-audio-ir-loader`. That change carries its own integration test against `audio-decode-wav`.

View File

@@ -0,0 +1,141 @@
<!-- markdownlint-disable MD024 -->
# 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<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 -->

View File

@@ -0,0 +1,74 @@
# Tasks — Add `@suaveplan/audio-decode-wav`
> Every checkbox MUST reflect work actually done in this change. No retroactive ticking.
## 1. Spec authoring
- [ ] 1.1 Read `openspec/AGENTS.md` Sections 13, 14, 2c (code reachability), 6a (barrel rule)
- [ ] 1.2 Inspect `../audio-decode/packages/decode-wav/decode-wav.js` and `../audio-decode/audio-decode.d.ts` to nail the upstream surface
- [ ] 1.3 `proposal.md` from `openspec/templates/proposal.md`
- [ ] 1.4 `specs/audio-decode-wav/spec.md` — every requirement carries `**Verified by:**`
- [ ] 1.5 Working-notes checklist enumerates every `### Requirement` and `#### Scenario` (§206)
## 2. Package scaffold
- [ ] 2.1 `mkdir -p packages/audio/audio-decode-wav/src/{decode-wav,stream,otel,error-codes,types,utils}`
- [ ] 2.2 `package.json` — `version: 0.0.0`, `sideEffects: false`, deps `@suaveplan/{error,types,opentelemetry}`, `@audio/decode-wav` from `../audio-decode/packages/decode-wav` via catalog, `zod`, `tslib`
- [ ] 2.3 `tsconfig.lib.json`, `vite.config.ts` (mirror `audio-plugins-fx` pattern), `biome.json` (extends root)
- [ ] 2.4 Subpath exports: `.` (barrel), `./stream`, `./error-codes`
- [ ] 2.5 `README.md` at package root only — links every co-located `.md` under `## Modules`
## 3. Implementation
- [ ] 3.1 `src/types/decoded-audio.ts` — `DecodedAudio` interface + Zod schema + freeze invariant
- [ ] 3.2 `src/types/decoded-audio.md`
- [ ] 3.3 `src/types/options.ts` — `AudioDecodeOptions` Zod schema
- [ ] 3.4 `src/types/options.md`
- [ ] 3.5 `src/error-codes/error-codes.ts` — `defineCodes("AUDIO_DECODE.WAV", [...])` registry
- [ ] 3.6 `src/error-codes/error-codes.md`
- [ ] 3.7 `src/decode-wav/decode-wav.ts` — `decodeWav` orchestrator over upstream `@audio/decode-wav`
- [ ] 3.8 `src/decode-wav/decode-wav.md`
- [ ] 3.9 `src/stream/stream.ts` — `createWavStreamDecoder` wrapping upstream `decode.stream`
- [ ] 3.10 `src/stream/stream.md`
- [ ] 3.11 `src/otel/otel.ts` — tracer/meter setup, counter + histogram registration
- [ ] 3.12 `src/otel/otel.md`
- [ ] 3.13 `src/index.ts` — explicit named re-exports (rule 10); NO `export *`
## 4. Tests
- [ ] 4.1 `src/decode-wav/decode-wav.test.ts` — every scenario in spec delta requirements 1–4
- [ ] 4.2 `src/stream/stream.test.ts` — every scenario in spec delta requirement 5
- [ ] 4.3 `src/otel/otel.test.ts` — every scenario in spec delta requirement 6 with a real in-memory `MeterProvider`
- [ ] 4.4 `src/error-codes/error-codes.test.ts` — exhaustion check per spec delta requirement 7
- [ ] 4.5 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
- [ ] 4.6 Fixtures under `tests/fixtures/` — generated WAV bytes (mono/stereo, 16/24-bit PCM, 32-bit float, A-law); checked in only if `< 50 KiB` total, else generated programmatically
## 5. Documentation
- [ ] 5.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 5.2 README `## Modules` section linking every `.md`
- [ ] 5.3 Update root `CLAUDE.md` package listing — add `audio-decode-wav` under a new "Audio codecs" subsection (§13b root-doc refresh)
## 6. Pre-archive verification (5-gate triple)
- [ ] 6.1 `bun run check` — Biome 0/0/0/0 across the whole monorepo
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/audio-decode-wav` — green
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/audio-decode-wav` — 100/100/100/100
- [ ] 6.4 `bun scripts/openspec-conformance.ts @suaveplan/audio-decode-wav` — exit 0, no `error` findings
- [ ] 6.5 `bunx turbo build --filter=@suaveplan/audio-decode-wav` — green
## 7. Pre-archive 7-row checklist (AGENTS §13)
- [ ] 7.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
- [ ] 7.2 Zod schemas wired at every declared boundary (`AudioDecodeOptions`, `DecodedAudio`)
- [ ] 7.3 Every code in `AUDIO_DECODE_WAV_CODES` thrown by at least one production path
- [ ] 7.4 Strict-mode behaviors tested both on and off
- [ ] 7.5 OTel instruments asserted with a real in-memory `MeterProvider`
- [ ] 7.6 Lifecycle dedup tested where claimed (stream decoder free → feed throws)
- [ ] 7.7 No retroactive tick
## 8. Archive
- [ ] 8.1 Fold delta into new `openspec/specs/audio-decode-wav/spec.md`
- [ ] 8.2 Move folder to `openspec/archive/<yyyy-mm-dd>-add-audio-decode-wav/`
- [ ] 8.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`