Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
# `decode-wav`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`decodeWav(bytes, options?)` is the headline entry-point of `@suaveplan/audio-decode-wav`: it takes a `Uint8Array` of WAV bytes and resolves to a frozen, validated `DecodedAudio`. It wraps the upstream `@audio/decode-wav` library behind a stable, OTel-instrumented, AbortSignal-honouring contract that classifies failures into seven distinct error codes — every dimension a downstream consumer (starting with `@suaveplan/audio-ir-loader`) might want to react to. It is the wrapper-layer guarantee that callers can treat WAV the same way they will treat MP3, FLAC and Opus when those sibling packages land.
## Features
- Pre-screens the buffer via `inspectWavHeader` so `CORRUPT_HEADER`, `TRUNCATED_BODY`, and `UNSUPPORTED_FORMAT` are surfaced before the upstream library is invoked.
- Returns `DecodedAudio` validated by `DecodedAudioSchema` and frozen by `freezeDecodedAudio`.
- Honours `AbortSignal` synchronously at entry; mid-decode aborts surface as `ABORTED` with the original `AbortError` on `cause`.
- Honours `targetSampleRate`: a no-op when it matches the decoded rate, raises `RESAMPLE_UNSUPPORTED` carrying both `decodedSampleRate` and `requestedSampleRate` when it does not.
- Emits OTel telemetry on every call — `audio_decode_wav.bytes_in_total`, `audio_decode_wav.frames_out_total`, `audio_decode_wav.errors_total{code}`, `audio_decode_wav.op_duration_ms{op, outcome}`, plus a span per call on the `@suaveplan/audio-decode-wav` tracer.
## Usage: Basic
```ts
import { decodeWav } from "@suaveplan/audio-decode-wav";
const audio = await decodeWav(wavBytes);
console.log(audio.channels, audio.sampleRate, audio.durationSec);
```
## Usage: Advanced
```ts
import { AUDIO_DECODE_WAV_CODES, decodeWav } from "@suaveplan/audio-decode-wav";
async function decodeOrResample(bytes: Uint8Array, target: number) {
try {
return await decodeWav(bytes, {
targetSampleRate: target,
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
if (
err instanceof Error &&
(err as { code?: string }).code ===
AUDIO_DECODE_WAV_CODES.RESAMPLE_UNSUPPORTED
) {
const native = await decodeWav(bytes);
return resampleTo(native, target);
}
throw err;
}
}
```
## API Reference
- `decodeWav(bytes: Uint8Array, options?: AudioDecodeOptions): Promise<DecodedAudio>` — main entry-point.
- **Throws** `AudioDecodeWavError` with `code === "AUDIO_DECODE.WAV.INVALID_INPUT"` for empty buffer.
- **Throws** `AUDIO_DECODE.WAV.ABORTED` for pre- or mid-decode abort; `cause` carries the originating `AbortError` reason.
- **Throws** `AUDIO_DECODE.WAV.CORRUPT_HEADER` for missing magic, malformed `fmt`, no `data` chunk.
- **Throws** `AUDIO_DECODE.WAV.TRUNCATED_BODY` when the `data` chunk declares more bytes than the buffer holds. Error carries `bytesAvailable` + `bytesDeclared`.
- **Throws** `AUDIO_DECODE.WAV.UNSUPPORTED_FORMAT` for A-law, μ-law, ADPCM variants, WAVE_FORMAT_EXTENSIBLE. Error carries `format`.
- **Throws** `AUDIO_DECODE.WAV.RESAMPLE_UNSUPPORTED` when `targetSampleRate` differs from the decoded rate. Error carries both rates.
## Implementation Notes
The header pre-scan is essential because the upstream library's failure mode is undifferentiated — `TypeError("Not a WAV file")`, `TypeError("Unsupported WAV format")`, and an out-of-bounds read on a truncated body all surface as plain errors. We classify them in pure JS before calling upstream, which makes the wrapper's error contract trivially testable and avoids cascading exceptions from inside the library's binary-data DataView reads. `performance.now()` is used for the duration histogram because it is universally available across Node, Bun and the browser; the value is monotonic so the histogram's `outcome` partition is meaningful even under clock skew. The function deliberately does **not** clip the decoded samples — upstream PCM normalisation produces values in `[-1, 1]` for well-formed integer PCM and float samples can legitimately exceed that range, so clipping would be lossy.

View File

@@ -0,0 +1,239 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildAlawWav,
buildCorruptHeaderWav,
buildTruncatedWav,
buildWav,
} from "../../tests/fixtures/build-wav.js";
import {
AUDIO_DECODE_WAV_CODES,
type AudioDecodeWavError,
} from "../error-codes/error-codes.js";
import { decodeWav } from "./decode-wav.js";
function sine(channel: number, index: number): number {
const phase = (index / 44100) * 2 * Math.PI * 220 * (channel + 1);
return Math.sin(phase) * 0.5;
}
describe("decodeWav — whole-buffer", () => {
it("decodes stereo 16-bit PCM fixture to two equal-length Float32Array channels", {
timeout: 10000,
}, async () => {
const bytes = buildWav({
sampleRate: 44100,
channels: 2,
bitsPerSample: 16,
samplesPerChannel: 44100,
formatCode: 1,
sampler: sine,
});
const audio = await decodeWav(bytes);
expect(audio.channels).toBe(2);
expect(audio.sampleRate).toBe(44100);
expect(audio.channelData[0]?.length).toBe(44100);
expect(audio.channelData[1]?.length).toBe(44100);
expect(audio.channelData[0]?.length).toBe(audio.channelData[1]?.length);
expect(audio.durationSec).toBe(1);
expect(audio.bytesDecoded).toBe(bytes.byteLength);
for (const c of audio.channelData) {
for (const s of c) {
expect(s).toBeGreaterThanOrEqual(-1);
expect(s).toBeLessThanOrEqual(1);
}
}
expect(Object.isFrozen(audio)).toBe(true);
expect(Object.isFrozen(audio.channelData)).toBe(true);
});
it("preserves 32-bit float peak values without clipping", {
timeout: 5000,
}, async () => {
const peak = 0.999;
const bytes = buildWav({
sampleRate: 48000,
channels: 1,
bitsPerSample: 32,
samplesPerChannel: 16,
formatCode: 3,
sampler: (_c, i) => (i === 0 ? peak : i === 1 ? -peak : 0),
});
const audio = await decodeWav(bytes);
expect(audio.channelData[0]?.[0]).toBeCloseTo(peak, 5);
expect(audio.channelData[0]?.[1]).toBeCloseTo(-peak, 5);
});
it("throws INVALID_INPUT for empty Uint8Array", {
timeout: 5000,
}, async () => {
await expect(decodeWav(new Uint8Array(0))).rejects.toMatchObject({
code: AUDIO_DECODE_WAV_CODES.INVALID_INPUT,
});
});
it("missing RIFF magic throws CORRUPT_HEADER", {
timeout: 5000,
}, async () => {
const bytes = buildCorruptHeaderWav();
await expect(decodeWav(bytes)).rejects.toMatchObject({
code: AUDIO_DECODE_WAV_CODES.CORRUPT_HEADER,
});
});
it("truncated data chunk throws TRUNCATED_BODY", {
timeout: 5000,
}, async () => {
const bytes = buildTruncatedWav();
let caught: AudioDecodeWavError | undefined;
try {
await decodeWav(bytes);
} catch (e) {
caught = e as AudioDecodeWavError;
}
expect(caught?.code).toBe(AUDIO_DECODE_WAV_CODES.TRUNCATED_BODY);
expect(caught?.bytesAvailable).toBeDefined();
expect(caught?.bytesDeclared).toBeDefined();
expect(
(caught?.bytesDeclared ?? 0) > (caught?.bytesAvailable ?? 0)
).toBe(true);
});
it("A-law fixture throws UNSUPPORTED_FORMAT with format discriminant", {
timeout: 5000,
}, async () => {
const bytes = buildAlawWav();
let caught: AudioDecodeWavError | undefined;
try {
await decodeWav(bytes);
} catch (e) {
caught = e as AudioDecodeWavError;
}
expect(caught?.code).toBe(AUDIO_DECODE_WAV_CODES.UNSUPPORTED_FORMAT);
expect(caught?.format).toBe("alaw");
});
it("pre-aborted signal short-circuits without invoking upstream", {
timeout: 5000,
}, async () => {
const bytes = buildWav({
sampleRate: 8000,
channels: 1,
bitsPerSample: 16,
samplesPerChannel: 8,
formatCode: 1,
});
const signal = AbortSignal.abort();
await expect(decodeWav(bytes, { signal })).rejects.toMatchObject({
code: AUDIO_DECODE_WAV_CODES.ABORTED,
});
});
it("mid-decode abort surfaces AbortError on cause", {
timeout: 5000,
}, async () => {
const bytes = buildWav({
sampleRate: 44100,
channels: 2,
bitsPerSample: 16,
samplesPerChannel: 44100,
formatCode: 1,
sampler: sine,
});
const controller = new AbortController();
const cause = new Error("user cancel");
const promise = decodeWav(bytes, { signal: controller.signal });
controller.abort(cause);
let caught: AudioDecodeWavError | undefined;
try {
await promise;
} catch (e) {
caught = e as AudioDecodeWavError;
}
expect(caught?.code).toBe(AUDIO_DECODE_WAV_CODES.ABORTED);
expect(caught?.cause).toBe(cause);
});
it("mismatched targetSampleRate throws RESAMPLE_UNSUPPORTED with both rates", {
timeout: 5000,
}, async () => {
const bytes = buildWav({
sampleRate: 44100,
channels: 1,
bitsPerSample: 16,
samplesPerChannel: 128,
formatCode: 1,
});
let caught: AudioDecodeWavError | undefined;
try {
await decodeWav(bytes, { targetSampleRate: 48000 });
} catch (e) {
caught = e as AudioDecodeWavError;
}
expect(caught?.code).toBe(AUDIO_DECODE_WAV_CODES.RESAMPLE_UNSUPPORTED);
expect(caught?.decodedSampleRate).toBe(44100);
expect(caught?.requestedSampleRate).toBe(48000);
});
it("matching targetSampleRate is a no-op", { timeout: 5000 }, async () => {
const bytes = buildWav({
sampleRate: 44100,
channels: 1,
bitsPerSample: 16,
samplesPerChannel: 128,
formatCode: 1,
sampler: (_c, i) => Math.sin(i / 16) * 0.25,
});
const baseline = await decodeWav(bytes);
const matched = await decodeWav(bytes, { targetSampleRate: 44100 });
expect(matched.sampleRate).toBe(baseline.sampleRate);
expect(matched.channelData[0]?.length).toBe(
baseline.channelData[0]?.length
);
for (let i = 0; i < (matched.channelData[0]?.length ?? 0); i++) {
expect(matched.channelData[0]?.[i]).toBe(
baseline.channelData[0]?.[i]
);
}
});
it("decodes mono 24-bit PCM fixture", { timeout: 5000 }, async () => {
const bytes = buildWav({
sampleRate: 48000,
channels: 1,
bitsPerSample: 24,
samplesPerChannel: 32,
formatCode: 1,
sampler: (_c, i) => (i % 2 === 0 ? 0.25 : -0.25),
});
const audio = await decodeWav(bytes);
expect(audio.channels).toBe(1);
expect(audio.sampleRate).toBe(48000);
expect(audio.channelData[0]?.length).toBe(32);
});
it("upstream decoder rejection surfaces as CORRUPT_HEADER", {
timeout: 5000,
}, async () => {
// Build a WAV whose fmt chunk advertises a format the inspector
// accepts (PCM 0x0001) but with an extreme bitDepth (12) the
// upstream library refuses. Forces the catch-block path.
const bytes = buildWav({
sampleRate: 8000,
channels: 1,
bitsPerSample: 16,
samplesPerChannel: 4,
formatCode: 1,
});
// Mutate bitDepth field at offset 34 to 12.
const dv = new DataView(bytes.buffer);
dv.setUint16(34, 12, true);
let caught: AudioDecodeWavError | undefined;
try {
await decodeWav(bytes);
} catch (e) {
caught = e as AudioDecodeWavError;
}
expect(caught?.code).toBe(AUDIO_DECODE_WAV_CODES.CORRUPT_HEADER);
expect(caught?.cause).toBeDefined();
});
});

View File

@@ -0,0 +1,164 @@
import decode from "@audio/decode-wav";
import {
AUDIO_DECODE_WAV_CODES,
AudioDecodeWavError,
} from "../error-codes/error-codes.js";
import { recordError, recordSuccess } from "../otel/otel.js";
import {
type DecodedAudio,
DecodedAudioSchema,
freezeDecodedAudio,
} from "../types/decoded-audio.js";
import {
type AudioDecodeOptions,
AudioDecodeOptionsSchema,
} from "../types/options.js";
import { inspectWavHeader } from "../utils/inspect.js";
/**
* Decode an entire WAV byte buffer into a frozen, validated `DecodedAudio`.
*
* Resolves with PCM samples normalised to `[-1.0, 1.0]`. Pre-screens the
* header so corrupt, truncated and unsupported-format inputs surface as
* distinct error codes — the upstream decoder is not invoked when the
* pre-screen fails. AbortSignal is checked synchronously at entry.
*/
export async function decodeWav(
bytes: Uint8Array,
options?: AudioDecodeOptions
): Promise<DecodedAudio> {
const started = performance.now();
const opts = AudioDecodeOptionsSchema.parse(options ?? {});
if (bytes.length === 0) {
const code = AUDIO_DECODE_WAV_CODES.INVALID_INPUT;
recordError({ op: "decodeWav", code, durationMs: 0 });
throw new AudioDecodeWavError(code, "WAV byte buffer is empty", {
op: "decodeWav",
});
}
if (opts.signal?.aborted) {
const code = AUDIO_DECODE_WAV_CODES.ABORTED;
recordError({ op: "decodeWav", code, durationMs: 0 });
throw new AudioDecodeWavError(code, "aborted before decode started", {
op: "decodeWav",
cause: opts.signal.reason,
});
}
const inspected = inspectWavHeader(bytes);
if (inspected.kind === "corrupt") {
const code = AUDIO_DECODE_WAV_CODES.CORRUPT_HEADER;
recordError({
op: "decodeWav",
code,
durationMs: performance.now() - started,
});
throw new AudioDecodeWavError(code, inspected.reason, {
op: "decodeWav",
});
}
if (inspected.kind === "truncated") {
const code = AUDIO_DECODE_WAV_CODES.TRUNCATED_BODY;
recordError({
op: "decodeWav",
code,
durationMs: performance.now() - started,
});
throw new AudioDecodeWavError(
code,
`data chunk declared ${inspected.bytesDeclared} bytes, only ${inspected.bytesAvailable} present`,
{
op: "decodeWav",
bytesAvailable: inspected.bytesAvailable,
bytesDeclared: inspected.bytesDeclared,
}
);
}
if (inspected.kind === "unsupported") {
const code = AUDIO_DECODE_WAV_CODES.UNSUPPORTED_FORMAT;
recordError({
op: "decodeWav",
code,
durationMs: performance.now() - started,
});
throw new AudioDecodeWavError(
code,
`unsupported WAV format: ${inspected.format}`,
{ op: "decodeWav", format: inspected.format }
);
}
let result: { channelData: Float32Array[]; sampleRate: number };
try {
result = await decode(bytes);
} catch (cause) {
const code = AUDIO_DECODE_WAV_CODES.CORRUPT_HEADER;
recordError({
op: "decodeWav",
code,
durationMs: performance.now() - started,
});
throw new AudioDecodeWavError(code, "upstream decoder rejected input", {
op: "decodeWav",
cause,
});
}
if (opts.signal?.aborted) {
const code = AUDIO_DECODE_WAV_CODES.ABORTED;
recordError({
op: "decodeWav",
code,
durationMs: performance.now() - started,
});
throw new AudioDecodeWavError(code, "aborted during decode", {
op: "decodeWav",
cause: opts.signal.reason,
});
}
const channels = result.channelData.length;
// The inspector guarantees `channels >= 1` and `sampleRate > 0` reached
// this branch — upstream cannot return zero channels for an OK fixture.
const firstChannel = result.channelData[0] as Float32Array;
const framesPerChannel = firstChannel.length;
const durationSec = framesPerChannel / result.sampleRate;
const candidate: DecodedAudio = {
channelData: result.channelData,
sampleRate: result.sampleRate,
channels,
durationSec,
bytesDecoded: bytes.byteLength,
};
if (
opts.targetSampleRate !== undefined &&
opts.targetSampleRate !== result.sampleRate
) {
const code = AUDIO_DECODE_WAV_CODES.RESAMPLE_UNSUPPORTED;
recordError({
op: "decodeWav",
code,
durationMs: performance.now() - started,
});
throw new AudioDecodeWavError(
code,
`decoded ${result.sampleRate} Hz, target ${opts.targetSampleRate} Hz; resampling not supported`,
{
op: "decodeWav",
decodedSampleRate: result.sampleRate,
requestedSampleRate: opts.targetSampleRate,
}
);
}
DecodedAudioSchema.parse(candidate);
const frozen = freezeDecodedAudio(candidate);
recordSuccess({
op: "decodeWav",
bytesIn: bytes.byteLength,
framesOut: framesPerChannel,
durationMs: performance.now() - started,
});
return frozen;
}

View File

@@ -0,0 +1,66 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the canonical registry of every error code raised by `@suaveplan/audio-decode-wav`, alongside the structured `AudioDecodeWavError` class that carries discriminants for filtering. The registry is the contract — every code is reachable from a real call site exercised by the test suite, per `AGENTS.md` §2c. The error class extends `@suaveplan/error`'s `SuaveplanError` so it threads through the repo-wide error machinery (OTel span event adapter, redaction, i18n resolver, Zod-validated wire format) without bespoke wiring.
## Features
- Seven codes via `defineCodes("AUDIO_DECODE.WAV", […])`: `INVALID_INPUT`, `UNSUPPORTED_FORMAT`, `CORRUPT_HEADER`, `TRUNCATED_BODY`, `ABORTED`, `RESAMPLE_UNSUPPORTED`, `STREAM_INVARIANT_VIOLATION`.
- `AudioDecodeWavError` carries six dimension-specific discriminants: `format` (for `UNSUPPORTED_FORMAT`), `decodedSampleRate` + `requestedSampleRate` (for `RESAMPLE_UNSUPPORTED`), `op` (every error), `bytesAvailable` + `bytesDeclared` (for `TRUNCATED_BODY`).
- `AudioFormatType` union covers the WAV format codes recognised at the inspect layer: `pcm`, `ieee-float`, `alaw`, `mulaw`, `ima-adpcm`, `ms-adpcm`, `extensible`, `unknown`.
- Registry is frozen at module load; `Object.values(AUDIO_DECODE_WAV_CODES)` is the authoritative list for exhaustion tests.
## Usage: Basic
```ts
import {
AUDIO_DECODE_WAV_CODES,
AudioDecodeWavError,
} from "@suaveplan/audio-decode-wav";
throw new AudioDecodeWavError(
AUDIO_DECODE_WAV_CODES.INVALID_INPUT,
"WAV byte buffer is empty",
{ op: "decodeWav" }
);
```
## Usage: Advanced
```ts
import {
AUDIO_DECODE_WAV_CODES,
AudioDecodeWavError,
decodeWav,
} from "@suaveplan/audio-decode-wav";
try {
await decodeWav(bytes, { targetSampleRate: 48000 });
} catch (err) {
if (
err instanceof AudioDecodeWavError &&
err.code === AUDIO_DECODE_WAV_CODES.RESAMPLE_UNSUPPORTED
) {
console.warn(
`cannot decode-and-resample: ${err.decodedSampleRate} → ${err.requestedSampleRate}`
);
}
}
```
## API Reference
- `const AUDIO_DECODE_WAV_CODES` — `{ INVALID_INPUT: "AUDIO_DECODE.WAV.INVALID_INPUT", … }`. Frozen.
- `type AudioDecodeWavErrorCode` — union of every code's string value.
- `type AudioFormatType` — `"pcm" | "ieee-float" | "alaw" | "mulaw" | "ima-adpcm" | "ms-adpcm" | "extensible" | "unknown"`.
- `interface AudioDecodeWavErrorOptions extends SuaveplanErrorOptions` — adds `format`, `decodedSampleRate`, `requestedSampleRate`, `op`, `bytesAvailable`, `bytesDeclared`.
- `class AudioDecodeWavError extends SuaveplanError<AudioDecodeWavErrorCode>` — exposes every option above as a readonly field; `name === "AudioDecodeWavError"`.
## Implementation Notes
The registry uses `defineCodes` so the code-string format is uniform across the project (`<namespace>.<reason>`), making OTel attribute filtering by code prefix trivial. The error class follows the same discriminant-surface pattern as `BundleUriError` in `@suaveplan/storage-uri-plugin-bundle` — every dimension a downstream observer might filter on is a typed readonly field rather than buried in the message string. `RESAMPLE_UNSUPPORTED` deliberately carries both rates so the failing call can be replayed against a downstream resampler without re-decoding to discover the source rate.

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildAlawWav,
buildCorruptHeaderWav,
buildTruncatedWav,
buildWav,
} from "../../tests/fixtures/build-wav.js";
import { decodeWav } from "../decode-wav/decode-wav.js";
import { createWavStreamDecoder } from "../stream/stream.js";
import {
AUDIO_DECODE_WAV_CODES,
AudioDecodeWavError,
type AudioDecodeWavErrorCode,
} from "./error-codes.js";
const EXPECTED_CODES: ReadonlyArray<AudioDecodeWavErrorCode> = [
"AUDIO_DECODE.WAV.INVALID_INPUT",
"AUDIO_DECODE.WAV.UNSUPPORTED_FORMAT",
"AUDIO_DECODE.WAV.CORRUPT_HEADER",
"AUDIO_DECODE.WAV.TRUNCATED_BODY",
"AUDIO_DECODE.WAV.ABORTED",
"AUDIO_DECODE.WAV.RESAMPLE_UNSUPPORTED",
"AUDIO_DECODE.WAV.STREAM_INVARIANT_VIOLATION",
];
describe("AUDIO_DECODE_WAV_CODES registry", () => {
it("exposes exactly the seven declared codes", { timeout: 5000 }, () => {
const got = Object.values(AUDIO_DECODE_WAV_CODES).sort();
const want = [...EXPECTED_CODES].sort();
expect(got).toEqual(want);
});
it("AudioDecodeWavError surfaces every discriminant", {
timeout: 5000,
}, () => {
const err = new AudioDecodeWavError(
AUDIO_DECODE_WAV_CODES.UNSUPPORTED_FORMAT,
"boom",
{
format: "alaw",
decodedSampleRate: 44100,
requestedSampleRate: 48000,
op: "decodeWav",
bytesAvailable: 100,
bytesDeclared: 200,
}
);
expect(err.code).toBe(AUDIO_DECODE_WAV_CODES.UNSUPPORTED_FORMAT);
expect(err.format).toBe("alaw");
expect(err.decodedSampleRate).toBe(44100);
expect(err.requestedSampleRate).toBe(48000);
expect(err.op).toBe("decodeWav");
expect(err.bytesAvailable).toBe(100);
expect(err.bytesDeclared).toBe(200);
expect(err.name).toBe("AudioDecodeWavError");
});
it("AudioDecodeWavError carries undefined discriminants when omitted", {
timeout: 5000,
}, () => {
const err = new AudioDecodeWavError(
AUDIO_DECODE_WAV_CODES.INVALID_INPUT,
"x"
);
expect(err.format).toBeUndefined();
expect(err.decodedSampleRate).toBeUndefined();
expect(err.requestedSampleRate).toBeUndefined();
expect(err.op).toBeUndefined();
expect(err.bytesAvailable).toBeUndefined();
expect(err.bytesDeclared).toBeUndefined();
});
it("every AUDIO_DECODE.WAV code reachable from a real call site", {
timeout: 30000,
}, async () => {
const seen = new Set<string>();
// INVALID_INPUT
await decodeWav(new Uint8Array(0)).catch((e) => {
seen.add((e as AudioDecodeWavError).code);
});
// ABORTED
await decodeWav(
buildWav({
sampleRate: 8000,
channels: 1,
bitsPerSample: 16,
samplesPerChannel: 4,
formatCode: 1,
}),
{ signal: AbortSignal.abort() }
).catch((e) => {
seen.add((e as AudioDecodeWavError).code);
});
// CORRUPT_HEADER
await decodeWav(buildCorruptHeaderWav()).catch((e) => {
seen.add((e as AudioDecodeWavError).code);
});
// TRUNCATED_BODY
await decodeWav(buildTruncatedWav()).catch((e) => {
seen.add((e as AudioDecodeWavError).code);
});
// UNSUPPORTED_FORMAT
await decodeWav(buildAlawWav()).catch((e) => {
seen.add((e as AudioDecodeWavError).code);
});
// RESAMPLE_UNSUPPORTED
await decodeWav(
buildWav({
sampleRate: 44100,
channels: 1,
bitsPerSample: 16,
samplesPerChannel: 16,
formatCode: 1,
}),
{ targetSampleRate: 48000 }
).catch((e) => {
seen.add((e as AudioDecodeWavError).code);
});
// STREAM_INVARIANT_VIOLATION
const dec = createWavStreamDecoder();
dec.free();
await dec.feed(new Uint8Array([1, 2])).catch((e) => {
seen.add((e as AudioDecodeWavError).code);
});
for (const code of EXPECTED_CODES) {
expect(seen.has(code)).toBe(true);
}
});
});

View File

@@ -0,0 +1,84 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Registry of every error code raised by `@suaveplan/audio-decode-wav`.
*
* Seven codes total — `INVALID_INPUT`, `UNSUPPORTED_FORMAT`, `CORRUPT_HEADER`,
* `TRUNCATED_BODY`, `ABORTED`, `RESAMPLE_UNSUPPORTED`,
* `STREAM_INVARIANT_VIOLATION` — every code is reachable from a real call
* site, exercised by `error-codes.test.ts`.
*/
export const AUDIO_DECODE_WAV_CODES = defineCodes("AUDIO_DECODE.WAV", [
"INVALID_INPUT",
"UNSUPPORTED_FORMAT",
"CORRUPT_HEADER",
"TRUNCATED_BODY",
"ABORTED",
"RESAMPLE_UNSUPPORTED",
"STREAM_INVARIANT_VIOLATION",
] as const);
export type AudioDecodeWavErrorCode =
(typeof AUDIO_DECODE_WAV_CODES)[keyof typeof AUDIO_DECODE_WAV_CODES];
/** Upstream `audio-format` discriminant carried on `UNSUPPORTED_FORMAT`. */
export type AudioFormatType =
| "pcm"
| "ieee-float"
| "alaw"
| "mulaw"
| "ima-adpcm"
| "ms-adpcm"
| "extensible"
| "unknown";
export interface AudioDecodeWavErrorOptions extends SuaveplanErrorOptions {
/** Upstream format discriminant for UNSUPPORTED_FORMAT. */
format?: AudioFormatType;
/** Decoded sample rate for RESAMPLE_UNSUPPORTED. */
decodedSampleRate?: number;
/** Requested target rate for RESAMPLE_UNSUPPORTED. */
requestedSampleRate?: number;
/** Operation that surfaced the error (`decodeWav`, `stream.feed`, etc.). */
op?: string;
/** Bytes available in the body when truncation was detected. */
bytesAvailable?: number;
/** Bytes declared in the WAV `data` chunk header. */
bytesDeclared?: number;
}
/**
* Structured error type for `@suaveplan/audio-decode-wav`.
*
* Carries the decoded sample rate / requested rate discriminants for the
* `RESAMPLE_UNSUPPORTED` path, the upstream `AudioFormatType` for
* `UNSUPPORTED_FORMAT`, and the available/declared byte counts for the
* `TRUNCATED_BODY` path so observers can filter without parsing messages.
*/
export class AudioDecodeWavError extends SuaveplanError<AudioDecodeWavErrorCode> {
public readonly format: AudioFormatType | undefined;
public readonly decodedSampleRate: number | undefined;
public readonly requestedSampleRate: number | undefined;
public readonly op: string | undefined;
public readonly bytesAvailable: number | undefined;
public readonly bytesDeclared: number | undefined;
public constructor(
code: AudioDecodeWavErrorCode,
message: string,
options?: AudioDecodeWavErrorOptions
) {
super(code, message, options);
this.format = options?.format;
this.decodedSampleRate = options?.decodedSampleRate;
this.requestedSampleRate = options?.requestedSampleRate;
this.op = options?.op;
this.bytesAvailable = options?.bytesAvailable;
this.bytesDeclared = options?.bytesDeclared;
this.name = "AudioDecodeWavError";
}
}

View File

@@ -0,0 +1,47 @@
// Side-effect import: ensures the AUDIO_DECODE.WAV.* code registry registers
// even when subpath consumers never reach this barrel.
import "./error-codes/error-codes.js";
export { decodeWav } from "./decode-wav/decode-wav.js";
export {
AUDIO_DECODE_WAV_CODES,
AudioDecodeWavError,
type AudioDecodeWavErrorCode,
type AudioDecodeWavErrorOptions,
type AudioFormatType,
} from "./error-codes/error-codes.js";
export {
type AudioDecodeWavInstruments,
type AudioDecodeWavOp,
type AudioDecodeWavOutcome,
type ErrorOpInput,
getInstruments,
recordError,
recordSuccess,
resetInstruments,
type SuccessOpInput,
setAudioDecodeWavMeterProvider,
} from "./otel/otel.js";
export {
createWavStreamDecoder,
type WavStreamDecoder,
} from "./stream/stream.js";
export {
type DecodedAudio,
DecodedAudioSchema,
freezeDecodedAudio,
MAX_CHANNELS,
MAX_SAMPLE_RATE,
} from "./types/decoded-audio.js";
export {
type AudioDecodeOptions,
AudioDecodeOptionsSchema,
} from "./types/options.js";
export {
type InspectCorrupt,
type InspectOk,
type InspectResult,
type InspectTruncated,
type InspectUnsupported,
inspectWavHeader,
} from "./utils/inspect.js";

View File

@@ -0,0 +1,68 @@
# `otel`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module wires `@suaveplan/audio-decode-wav` into OpenTelemetry: it builds (and caches) the tracer, meter, counters and histogram the package emits, supports swapping the meter provider for tests, and exposes the two convenience recorders (`recordSuccess`, `recordError`) used by the decoder and the stream wrapper. Every public call in the package routes its telemetry through one of those two functions so the spec's `audio_decode_wav.*` instrument names are emitted from exactly one place.
## Features
- `setAudioDecodeWavMeterProvider(provider)` overrides the meter provider; pass `undefined` to revert to the global. Resets the instrument cache so the next access binds to the new provider — essential for tests that snapshot per-test metrics.
- Lazy-built, cached instruments via `getInstruments(force?)` — first call builds, subsequent calls return the cache unless `force === true`.
- Three counters (`bytes_in_total`, `frames_out_total`, `errors_total`) and one histogram (`op_duration_ms`) on the `@suaveplan/audio-decode-wav` meter.
- Tracer spans on the `@suaveplan/audio-decode-wav` tracer, named `audio_decode_wav.<op>` where `op ∈ {"decodeWav", "stream.feed", "stream.flush"}`.
- `recordSuccess(input)` and `recordError(input)` are the canonical emit points — both increment the duration histogram, the relevant counter, and start/end a span.
## Usage: Basic
```ts
import { getInstruments, recordSuccess } from "@suaveplan/audio-decode-wav";
recordSuccess({
op: "decodeWav",
bytesIn: 1024,
framesOut: 256,
durationMs: 1.2,
});
const inst = getInstruments();
inst.tracer.startSpan("custom.span").end();
```
## Usage: Advanced
```ts
import {
resetInstruments,
setAudioDecodeWavMeterProvider,
} from "@suaveplan/audio-decode-wav";
const counters: Array<{ name: string; value: number }> = [];
setAudioDecodeWavMeterProvider({
getMeter: () => ({
createCounter: (name) => ({ add: (v) => counters.push({ name, value: v }) }),
createHistogram: () => ({ record: () => {} }),
}),
} as never);
resetInstruments();
// run decode(), then assert against `counters`
setAudioDecodeWavMeterProvider(undefined);
resetInstruments();
```
## API Reference
- `setAudioDecodeWavMeterProvider(provider: MeterProvider | undefined): void` — override the meter provider; clears the instrument cache.
- `getInstruments(force?: boolean): AudioDecodeWavInstruments` — return the cached instruments (or rebuild if `force`).
- `resetInstruments(): void` — drop the cache; next `getInstruments` rebuilds.
- `recordSuccess(input: SuccessOpInput): void` — emit duration + bytes_in + frames_out + span for a successful op.
- `recordError(input: ErrorOpInput): void` — emit duration + errors_total + span for a failed op.
- `type AudioDecodeWavOp = "decodeWav" | "stream.feed" | "stream.flush"`.
- `type AudioDecodeWavOutcome = "success" | "error"`.
## Implementation Notes
The cache is module-scoped — there is exactly one set of instruments per process. Tests swap the provider via `setAudioDecodeWavMeterProvider` and call `resetInstruments()` to force a rebuild; the override pointer is read inside `getInstruments` so the swap takes effect on the next access. Span attributes include `audio_decode_wav.outcome` and either `audio_decode_wav.bytes_in`/`audio_decode_wav.frames_out` (success) or `audio_decode_wav.code` (error); these mirror the metric attribute keys so an OTel collector can join spans and metrics on the same dimension.

View File

@@ -0,0 +1,216 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildCorruptHeaderWav,
buildWav,
} from "../../tests/fixtures/build-wav.js";
import { decodeWav } from "../decode-wav/decode-wav.js";
import { AUDIO_DECODE_WAV_CODES } from "../error-codes/error-codes.js";
import {
getInstruments,
recordError,
recordSuccess,
resetInstruments,
setAudioDecodeWavMeterProvider,
} from "./otel.js";
interface CounterObs {
readonly name: string;
readonly value: number;
readonly attributes: Record<string, unknown> | undefined;
}
interface HistogramObs {
readonly value: number;
readonly attributes: Record<string, unknown> | undefined;
}
interface SpanObs {
readonly name: string;
readonly attributes: Record<string, unknown>;
}
function installRecorder(): {
counters: CounterObs[];
durations: HistogramObs[];
spans: SpanObs[];
} {
const counters: CounterObs[] = [];
const durations: HistogramObs[] = [];
const spans: SpanObs[] = [];
setAudioDecodeWavMeterProvider({
getMeter: () => ({
createCounter: (name: string) => ({
add: (value: number, attrs?: Record<string, unknown>) => {
counters.push({
name,
value,
attributes: attrs ?? undefined,
});
},
}),
createHistogram: () => ({
record: (value: number, attrs?: Record<string, unknown>) => {
durations.push({
value,
attributes: attrs ?? undefined,
});
},
}),
}),
} as unknown as Parameters<typeof setAudioDecodeWavMeterProvider>[0]);
resetInstruments();
const inst = getInstruments(true);
const realStart = inst.tracer.startSpan.bind(inst.tracer);
(
inst.tracer as unknown as {
startSpan: (...args: unknown[]) => unknown;
}
).startSpan = (...args: unknown[]) => {
const name = args[0] as string;
const opts = args[1] as
| { attributes?: Record<string, unknown> }
| undefined;
spans.push({ name, attributes: opts?.attributes ?? {} });
return (realStart as unknown as (...a: unknown[]) => unknown)(...args);
};
return { counters, durations, spans };
}
function uninstallRecorder(): void {
setAudioDecodeWavMeterProvider(undefined);
resetInstruments();
}
describe("audio-decode-wav otel surface", () => {
it("records bytes_in and frames_out on success", {
timeout: 5000,
}, async () => {
const rec = installRecorder();
try {
const samplesPerChannel = 44100;
const bytes = buildWav({
sampleRate: 44100,
channels: 2,
bitsPerSample: 16,
samplesPerChannel,
formatCode: 1,
sampler: () => 0,
});
await decodeWav(bytes);
const bytesIn = rec.counters.find(
(c) => c.name === "audio_decode_wav.bytes_in_total"
);
expect(bytesIn?.value).toBe(bytes.byteLength);
const framesOut = rec.counters.find(
(c) => c.name === "audio_decode_wav.frames_out_total"
);
expect(framesOut?.value).toBe(samplesPerChannel);
const histogramHit = rec.durations.find(
(d) =>
d.attributes?.["op"] === "decodeWav" &&
d.attributes?.["outcome"] === "success"
);
expect(histogramHit).toBeDefined();
const successSpan = rec.spans.find(
(s) =>
s.name === "audio_decode_wav.decodeWav" &&
s.attributes["audio_decode_wav.outcome"] === "success"
);
expect(successSpan).toBeDefined();
} finally {
uninstallRecorder();
}
});
it("increments errors_total with resolved AUDIO_DECODE.WAV code", {
timeout: 5000,
}, async () => {
const rec = installRecorder();
try {
await decodeWav(buildCorruptHeaderWav()).catch(() => {});
const errorsHit = rec.counters.find(
(c) =>
c.name === "audio_decode_wav.errors_total" &&
c.attributes?.["code"] ===
AUDIO_DECODE_WAV_CODES.CORRUPT_HEADER
);
expect(errorsHit).toBeDefined();
} finally {
uninstallRecorder();
}
});
it("getInstruments returns cached instance without force", {
timeout: 5000,
}, () => {
installRecorder();
try {
const a = getInstruments();
const b = getInstruments();
expect(a).toBe(b);
} finally {
uninstallRecorder();
}
});
it("recordSuccess and recordError emit span + duration + counters in one call", {
timeout: 5000,
}, () => {
const rec = installRecorder();
try {
recordSuccess({
op: "stream.feed",
bytesIn: 100,
framesOut: 25,
durationMs: 0.5,
});
recordError({
op: "stream.flush",
code: AUDIO_DECODE_WAV_CODES.ABORTED,
durationMs: 0.2,
});
expect(
rec.counters.find(
(c) => c.name === "audio_decode_wav.bytes_in_total"
)?.value
).toBe(100);
expect(
rec.counters.find(
(c) => c.name === "audio_decode_wav.frames_out_total"
)?.value
).toBe(25);
expect(
rec.counters.find(
(c) => c.name === "audio_decode_wav.errors_total"
)?.value
).toBe(1);
expect(rec.durations.length).toBe(2);
expect(rec.spans.length).toBe(2);
} finally {
uninstallRecorder();
}
});
it("resetInstruments + getInstruments rebuilds on next access", {
timeout: 5000,
}, () => {
installRecorder();
try {
const a = getInstruments();
resetInstruments();
const b = getInstruments();
expect(a).not.toBe(b);
} finally {
uninstallRecorder();
}
});
it("setAudioDecodeWavMeterProvider(undefined) reverts to global", {
timeout: 5000,
}, () => {
installRecorder();
uninstallRecorder();
// Should not throw — global provider returns a no-op meter.
const inst = getInstruments();
expect(inst.tracer).toBeDefined();
expect(inst.meter).toBeDefined();
});
});

View File

@@ -0,0 +1,122 @@
import {
type Counter,
type Histogram,
type Meter,
type MeterProvider,
metrics,
type Tracer,
trace,
} from "@opentelemetry/api";
const METER_NAME = "@suaveplan/audio-decode-wav";
const TRACER_NAME = "@suaveplan/audio-decode-wav";
let overrideMeterProvider: MeterProvider | undefined;
/**
* Override the meter provider used to acquire instruments. Pass `undefined`
* to revert to the global provider. Resets the cached instrument bag so
* the next access binds to the new provider — used by tests between
* in-memory `MeterProvider` swaps.
*/
export function setAudioDecodeWavMeterProvider(
provider: MeterProvider | undefined
): void {
overrideMeterProvider = provider;
cachedInstruments = undefined;
}
export type AudioDecodeWavOp = "decodeWav" | "stream.feed" | "stream.flush";
export type AudioDecodeWavOutcome = "success" | "error";
export interface AudioDecodeWavInstruments {
readonly tracer: Tracer;
readonly meter: Meter;
readonly bytesInTotal: Counter;
readonly framesOutTotal: Counter;
readonly errorsTotal: Counter;
readonly opDurationMs: Histogram;
}
let cachedInstruments: AudioDecodeWavInstruments | undefined;
/** Lazily build (or return cached) instruments bound to the active provider. */
export function getInstruments(force = false): AudioDecodeWavInstruments {
if (cachedInstruments !== undefined && !force) return cachedInstruments;
const tracer = trace.getTracer(TRACER_NAME);
const meter = (overrideMeterProvider ?? metrics).getMeter(METER_NAME);
cachedInstruments = {
tracer,
meter,
bytesInTotal: meter.createCounter("audio_decode_wav.bytes_in_total", {
description: "Total bytes accepted by the decoder",
unit: "By",
}),
framesOutTotal: meter.createCounter(
"audio_decode_wav.frames_out_total",
{ description: "Total decoded frame count emitted" }
),
errorsTotal: meter.createCounter("audio_decode_wav.errors_total", {
description: "Failed decode operations partitioned by code",
}),
opDurationMs: meter.createHistogram("audio_decode_wav.op_duration_ms", {
description: "Per-operation duration in milliseconds",
unit: "ms",
}),
};
return cachedInstruments;
}
/** Reset cached instruments — used by tests between provider swaps. */
export function resetInstruments(): void {
cachedInstruments = undefined;
}
export interface SuccessOpInput {
readonly op: AudioDecodeWavOp;
readonly bytesIn: number;
readonly framesOut: number;
readonly durationMs: number;
}
export interface ErrorOpInput {
readonly op: AudioDecodeWavOp;
readonly code: string;
readonly durationMs: number;
}
/** Record one full observability bundle for a successful operation. */
export function recordSuccess(input: SuccessOpInput): void {
const inst = getInstruments();
inst.opDurationMs.record(input.durationMs, {
op: input.op,
outcome: "success",
});
inst.bytesInTotal.add(input.bytesIn, { op: input.op });
inst.framesOutTotal.add(input.framesOut, { op: input.op });
const span = inst.tracer.startSpan(`audio_decode_wav.${input.op}`, {
attributes: {
"audio_decode_wav.outcome": "success",
"audio_decode_wav.bytes_in": input.bytesIn,
"audio_decode_wav.frames_out": input.framesOut,
},
});
span.end();
}
/** Record one full observability bundle for a failed operation. */
export function recordError(input: ErrorOpInput): void {
const inst = getInstruments();
inst.opDurationMs.record(input.durationMs, {
op: input.op,
outcome: "error",
});
inst.errorsTotal.add(1, { op: input.op, code: input.code });
const span = inst.tracer.startSpan(`audio_decode_wav.${input.op}`, {
attributes: {
"audio_decode_wav.outcome": "error",
"audio_decode_wav.code": input.code,
},
});
span.end();
}

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildWav } from "../../tests/fixtures/build-wav.js";
import {
AUDIO_DECODE_WAV_CODES,
type AudioDecodeWavError,
} from "../error-codes/error-codes.js";
import { createWavStreamDecoder } from "./stream.js";
describe("createWavStreamDecoder — branch coverage", () => {
it("feeds partial header (EMPTY segment) then samples — both branches of accum.length and segment.sampleRate", {
timeout: 5000,
}, async () => {
const bytes = buildWav({
sampleRate: 8000,
channels: 1,
bitsPerSample: 16,
samplesPerChannel: 64,
formatCode: 1,
});
const dec = createWavStreamDecoder();
try {
// 16 bytes — less than the 44-byte header, EMPTY segment.
await dec.feed(bytes.subarray(0, 16));
// Next 32 bytes — completes header (now at 48 bytes), some samples.
await dec.feed(bytes.subarray(16, 48));
// Remaining samples — appendChannel takes accum.length > 0 path.
await dec.feed(bytes.subarray(48));
const audio = await dec.flush();
expect(audio.sampleRate).toBe(8000);
expect(audio.channels).toBe(1);
expect(audio.channelData[0]?.length).toBe(64);
} finally {
dec.free();
}
});
it("feed catches synchronous upstream throw and surfaces CORRUPT_HEADER", {
timeout: 5000,
}, async () => {
const dec = createWavStreamDecoder();
try {
// RIFF/WAVE + fmt chunk with bogus format code 0x99 — upstream
// scanWavHdr throws TypeError synchronously inside u.decode().
const bad = new Uint8Array(44);
const dv = new DataView(bad.buffer);
bad[0] = 0x52;
bad[1] = 0x49;
bad[2] = 0x46;
bad[3] = 0x46;
dv.setUint32(4, 36, true);
bad[8] = 0x57;
bad[9] = 0x41;
bad[10] = 0x56;
bad[11] = 0x45;
bad[12] = 0x66;
bad[13] = 0x6d;
bad[14] = 0x74;
bad[15] = 0x20;
dv.setUint32(16, 16, true);
dv.setUint16(20, 0x0099, true);
dv.setUint16(22, 1, true);
dv.setUint32(24, 8000, true);
dv.setUint32(28, 16000, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
let caught: AudioDecodeWavError | undefined;
try {
await dec.feed(bad);
} catch (e) {
caught = e as AudioDecodeWavError;
}
expect(caught?.code).toBe(AUDIO_DECODE_WAV_CODES.CORRUPT_HEADER);
expect(caught?.cause).toBeInstanceOf(Error);
} finally {
dec.free();
}
});
});

View File

@@ -0,0 +1,68 @@
# `stream`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createWavStreamDecoder(options?)` returns a `WavStreamDecoder` for callers who receive WAV bytes incrementally — `ReadableStream<Uint8Array>`, an HTTP chunked response, a sliding window over a large file. The decoder accumulates samples across `feed()` calls and produces a frozen `DecodedAudio` on `flush()`. It wraps the upstream `@audio/decode-wav` incremental decoder behind the same OTel-instrumented contract as `decodeWav`, with the additional `STREAM_INVARIANT_VIOLATION` code for misuse (feeding after `free()`, flushing before any samples were decoded).
## Features
- `feed(chunk)` accepts arbitrary `Uint8Array` chunks — header bytes are buffered internally until the upstream `data` chunk is observed, then samples start streaming.
- `flush()` resolves to a frozen `DecodedAudio` whose `channelData` is the concatenation of every sample fed since construction (or since the previous `flush()` if you choose to dispose between flushes).
- `free()` releases the upstream decoder; idempotent. Any subsequent `feed()` raises `STREAM_INVARIANT_VIOLATION`.
- Honours `AbortSignal` on every `feed()` call.
- Honours `targetSampleRate` from `flush()` — raises `RESAMPLE_UNSUPPORTED` carrying both rates if it differs from the upstream-detected sample rate.
- Emits the same OTel telemetry surface as `decodeWav` with `op ∈ {"stream.feed", "stream.flush"}`.
## Usage: Basic
```ts
import { createWavStreamDecoder } from "@suaveplan/audio-decode-wav";
const decoder = createWavStreamDecoder();
try {
for (const chunk of chunks) {
await decoder.feed(chunk);
}
const audio = await decoder.flush();
console.log(audio.channels, audio.sampleRate, audio.bytesDecoded);
} finally {
decoder.free();
}
```
## Usage: Advanced
```ts
import { createWavStreamDecoder } from "@suaveplan/audio-decode-wav";
async function decodeFromResponse(res: Response, signal?: AbortSignal) {
const dec = createWavStreamDecoder({ signal: signal ?? new AbortController().signal });
const reader = res.body!.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
await dec.feed(value);
}
return await dec.flush();
} finally {
dec.free();
}
}
```
## API Reference
- `createWavStreamDecoder(options?: AudioDecodeOptions): WavStreamDecoder`.
- `interface WavStreamDecoder` —
- `feed(chunk: Uint8Array): Promise<void>` — accumulate bytes. Throws `STREAM_INVARIANT_VIOLATION` after `free()`, `ABORTED` for pre-aborted signal, `CORRUPT_HEADER` for upstream failures.
- `flush(): Promise<DecodedAudio>` — produce the cumulative decoded audio. Throws `RESAMPLE_UNSUPPORTED` for mismatched `targetSampleRate`; throws `STREAM_INVARIANT_VIOLATION` if no samples have been decoded yet.
- `free(): void` — release the upstream decoder. Idempotent.
## Implementation Notes
The wrapper appends each upstream segment's channels to the accumulator by allocating a fresh `Float32Array(prev + incoming)` per channel; this is `O(n)` per flush rather than `O(n log n)`, which is fine for the realistic case where `flush()` is called once at end-of-stream. Callers that want true streaming output (emitting samples to an output device as they decode) should listen to `feed()` results in a future iteration of this API — the current contract is "feed until end, flush once". The upstream library exposes its own `flush()` and `free()`, but it does not surface partial-decode segments separately; we accumulate at the wrapper layer so the wrapper's `DecodedAudio` invariant holds end-to-end. `bytesDecoded` is the sum of every `chunk.byteLength` passed to `feed()`, not just the decoded body — this lets observers correlate input throughput with frame output.

View File

@@ -0,0 +1,195 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildWav } from "../../tests/fixtures/build-wav.js";
import { decodeWav } from "../decode-wav/decode-wav.js";
import {
AUDIO_DECODE_WAV_CODES,
type AudioDecodeWavError,
} from "../error-codes/error-codes.js";
import { createWavStreamDecoder } from "./stream.js";
function chunked(bytes: Uint8Array, size: number): Uint8Array[] {
const out: Uint8Array[] = [];
for (let i = 0; i < bytes.length; i += size) {
out.push(bytes.slice(i, Math.min(i + size, bytes.length)));
}
return out;
}
describe("createWavStreamDecoder", () => {
it("chunked feed concatenates to whole-buffer decode", {
timeout: 5000,
}, async () => {
const bytes = buildWav({
sampleRate: 22050,
channels: 2,
bitsPerSample: 16,
samplesPerChannel: 1024,
formatCode: 1,
sampler: (c, i) => Math.sin((i + c) / 8) * 0.5,
});
const whole = await decodeWav(bytes);
const dec = createWavStreamDecoder();
try {
for (const chunk of chunked(bytes, 1024)) {
await dec.feed(chunk);
}
const streamed = await dec.flush();
expect(streamed.sampleRate).toBe(whole.sampleRate);
expect(streamed.channels).toBe(whole.channels);
expect(streamed.channelData[0]?.length).toBe(
whole.channelData[0]?.length
);
for (let c = 0; c < streamed.channels; c++) {
for (
let i = 0;
i < (streamed.channelData[c]?.length ?? 0);
i++
) {
expect(streamed.channelData[c]?.[i]).toBeCloseTo(
whole.channelData[c]?.[i] ?? 0,
5
);
}
}
expect(streamed.bytesDecoded).toBe(bytes.byteLength);
} finally {
dec.free();
}
});
it("feed after free throws STREAM_INVARIANT_VIOLATION", {
timeout: 5000,
}, async () => {
const dec = createWavStreamDecoder();
dec.free();
let caught: AudioDecodeWavError | undefined;
try {
await dec.feed(new Uint8Array([1, 2, 3]));
} catch (e) {
caught = e as AudioDecodeWavError;
}
expect(caught?.code).toBe(
AUDIO_DECODE_WAV_CODES.STREAM_INVARIANT_VIOLATION
);
});
it("free is idempotent", { timeout: 5000 }, async () => {
const dec = createWavStreamDecoder();
dec.free();
dec.free();
await expect(dec.feed(new Uint8Array(1))).rejects.toMatchObject({
code: AUDIO_DECODE_WAV_CODES.STREAM_INVARIANT_VIOLATION,
});
});
it("pre-aborted signal short-circuits feed", {
timeout: 5000,
}, async () => {
const dec = createWavStreamDecoder({ signal: AbortSignal.abort() });
try {
await expect(dec.feed(new Uint8Array(8))).rejects.toMatchObject({
code: AUDIO_DECODE_WAV_CODES.ABORTED,
});
} finally {
dec.free();
}
});
it("feed throws CORRUPT_HEADER when upstream rejects bytes", {
timeout: 5000,
}, async () => {
const dec = createWavStreamDecoder();
try {
const bad = new Uint8Array(16);
bad[0] = 0x52; // 'R'
bad[1] = 0x49; // 'I'
bad[2] = 0x46; // 'F'
bad[3] = 0x46; // 'F' — RIFF
bad[8] = 0x57; // 'W'
bad[9] = 0x41; // 'A'
bad[10] = 0x56; // 'V'
bad[11] = 0x45; // 'E' — WAVE
// No fmt chunk; first chunk after WAVE has bogus chunk id 0x00…
// Force a long enough buffer so upstream tries to scan and
// throws on missing fmt.
let caught: AudioDecodeWavError | undefined;
try {
// Feed enough bytes for upstream to scan and fail —
// since our fixture lacks fmt, upstream will buffer.
// To actually trigger an upstream throw, use a buffer
// that DOES start with RIFF/WAVE but advertises a bad
// format code (which upstream throws on synchronously).
const tooBad = new Uint8Array(40);
const dv = new DataView(tooBad.buffer);
tooBad[0] = 0x52;
tooBad[1] = 0x49;
tooBad[2] = 0x46;
tooBad[3] = 0x46;
dv.setUint32(4, 32, true);
tooBad[8] = 0x57;
tooBad[9] = 0x41;
tooBad[10] = 0x56;
tooBad[11] = 0x45;
tooBad[12] = 0x66;
tooBad[13] = 0x6d;
tooBad[14] = 0x74;
tooBad[15] = 0x20; // 'fmt '
dv.setUint32(16, 16, true);
dv.setUint16(20, 0x0099, true); // bogus format → upstream throws
dv.setUint16(22, 1, true);
dv.setUint32(24, 8000, true);
dv.setUint32(28, 16000, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
await dec.feed(tooBad);
} catch (e) {
caught = e as AudioDecodeWavError;
}
expect(caught?.code).toBe(AUDIO_DECODE_WAV_CODES.CORRUPT_HEADER);
} finally {
dec.free();
}
});
it("flush before any decoded samples throws STREAM_INVARIANT_VIOLATION", {
timeout: 5000,
}, async () => {
const dec = createWavStreamDecoder();
try {
await expect(dec.flush()).rejects.toMatchObject({
code: AUDIO_DECODE_WAV_CODES.STREAM_INVARIANT_VIOLATION,
});
} finally {
dec.free();
}
});
it("mismatched targetSampleRate on flush throws RESAMPLE_UNSUPPORTED", {
timeout: 5000,
}, async () => {
const bytes = buildWav({
sampleRate: 22050,
channels: 1,
bitsPerSample: 16,
samplesPerChannel: 256,
formatCode: 1,
});
const dec = createWavStreamDecoder({ targetSampleRate: 48000 });
try {
await dec.feed(bytes);
let caught: AudioDecodeWavError | undefined;
try {
await dec.flush();
} catch (e) {
caught = e as AudioDecodeWavError;
}
expect(caught?.code).toBe(
AUDIO_DECODE_WAV_CODES.RESAMPLE_UNSUPPORTED
);
expect(caught?.decodedSampleRate).toBe(22050);
expect(caught?.requestedSampleRate).toBe(48000);
} finally {
dec.free();
}
});
});

View File

@@ -0,0 +1,201 @@
import { decoder } from "@audio/decode-wav";
import {
AUDIO_DECODE_WAV_CODES,
AudioDecodeWavError,
} from "../error-codes/error-codes.js";
import { recordError, recordSuccess } from "../otel/otel.js";
import {
type DecodedAudio,
DecodedAudioSchema,
freezeDecodedAudio,
} from "../types/decoded-audio.js";
import {
type AudioDecodeOptions,
AudioDecodeOptionsSchema,
} from "../types/options.js";
/**
* Streaming WAV decoder over a chunked source. Callers feed `Uint8Array`
* chunks as they arrive (`feed`), then call `flush` to obtain the cumulative
* `DecodedAudio` over every chunk fed since the previous `flush()` (or
* since construction). `free()` releases the upstream decoder; any
* subsequent `feed()` raises `AUDIO_DECODE.WAV.STREAM_INVARIANT_VIOLATION`.
*/
export interface WavStreamDecoder {
/** Accept a chunk of bytes. */
feed(chunk: Uint8Array): Promise<void>;
/** Resolve all bytes fed so far into a frozen `DecodedAudio`. */
flush(): Promise<DecodedAudio>;
/** Release the upstream decoder. Idempotent. */
free(): void;
}
interface UpstreamSegment {
readonly channelData: Float32Array[];
readonly sampleRate: number;
}
interface UpstreamDecoder {
decode(data: Uint8Array): UpstreamSegment;
flush(): UpstreamSegment;
free(): void;
}
function appendChannel(
accum: Float32Array[],
segment: UpstreamSegment
): Float32Array[] {
if (segment.channelData.length === 0) return accum;
if (accum.length === 0) {
return segment.channelData.map((c) => new Float32Array(c));
}
const out: Float32Array[] = [];
const incomingChannels = segment.channelData;
for (let c = 0; c < accum.length; c++) {
const prev = accum[c] as Float32Array;
const incoming = incomingChannels[c] as Float32Array;
const merged = new Float32Array(prev.length + incoming.length);
merged.set(prev, 0);
merged.set(incoming, prev.length);
out.push(merged);
}
return out;
}
/**
* Build a `WavStreamDecoder` wrapping the upstream `@audio/decode-wav`
* incremental decoder. Honours `AbortSignal` and `targetSampleRate` the
* same way `decodeWav` does — pre-aborted signals short-circuit `feed`,
* mismatched `targetSampleRate` raises `RESAMPLE_UNSUPPORTED` from `flush`.
*/
export function createWavStreamDecoder(
options?: AudioDecodeOptions
): WavStreamDecoder {
const opts = AudioDecodeOptionsSchema.parse(options ?? {});
let upstream: UpstreamDecoder | undefined;
let freed = false;
let bytesIn = 0;
let accumulated: Float32Array[] = [];
let lastSampleRate = 0;
async function ensureUpstream(): Promise<UpstreamDecoder> {
if (upstream === undefined) {
upstream = (await decoder()) as UpstreamDecoder;
}
return upstream;
}
return {
async feed(chunk: Uint8Array): Promise<void> {
const started = performance.now();
if (freed) {
const code = AUDIO_DECODE_WAV_CODES.STREAM_INVARIANT_VIOLATION;
recordError({ op: "stream.feed", code, durationMs: 0 });
throw new AudioDecodeWavError(
code,
"feed() called after free()",
{ op: "stream.feed" }
);
}
if (opts.signal?.aborted) {
const code = AUDIO_DECODE_WAV_CODES.ABORTED;
recordError({ op: "stream.feed", code, durationMs: 0 });
throw new AudioDecodeWavError(code, "aborted before feed", {
op: "stream.feed",
cause: opts.signal.reason,
});
}
try {
const u = await ensureUpstream();
const segment = u.decode(chunk);
bytesIn += chunk.byteLength;
if (segment.sampleRate > 0) lastSampleRate = segment.sampleRate;
accumulated = appendChannel(accumulated, segment);
} catch (cause) {
const code = AUDIO_DECODE_WAV_CODES.CORRUPT_HEADER;
recordError({
op: "stream.feed",
code,
durationMs: performance.now() - started,
});
throw new AudioDecodeWavError(
code,
"upstream decoder rejected chunk",
{ op: "stream.feed", cause }
);
}
const head = accumulated[0];
recordSuccess({
op: "stream.feed",
bytesIn: chunk.byteLength,
framesOut: head === undefined ? 0 : head.length,
durationMs: performance.now() - started,
});
},
async flush(): Promise<DecodedAudio> {
const started = performance.now();
const u = await ensureUpstream();
u.flush();
const channels = accumulated.length;
const firstChannel = accumulated[0];
const framesPerChannel =
firstChannel === undefined ? 0 : firstChannel.length;
if (lastSampleRate === 0 || channels === 0) {
const code = AUDIO_DECODE_WAV_CODES.STREAM_INVARIANT_VIOLATION;
recordError({
op: "stream.flush",
code,
durationMs: performance.now() - started,
});
throw new AudioDecodeWavError(
code,
"flush() called before any decoded samples produced",
{ op: "stream.flush" }
);
}
if (
opts.targetSampleRate !== undefined &&
opts.targetSampleRate !== lastSampleRate
) {
const code = AUDIO_DECODE_WAV_CODES.RESAMPLE_UNSUPPORTED;
recordError({
op: "stream.flush",
code,
durationMs: performance.now() - started,
});
throw new AudioDecodeWavError(
code,
`decoded ${lastSampleRate} Hz, target ${opts.targetSampleRate} Hz; resampling not supported`,
{
op: "stream.flush",
decodedSampleRate: lastSampleRate,
requestedSampleRate: opts.targetSampleRate,
}
);
}
const candidate: DecodedAudio = {
channelData: accumulated,
sampleRate: lastSampleRate,
channels,
durationSec: framesPerChannel / lastSampleRate,
bytesDecoded: bytesIn,
};
DecodedAudioSchema.parse(candidate);
const frozen = freezeDecodedAudio(candidate);
recordSuccess({
op: "stream.flush",
bytesIn,
framesOut: framesPerChannel,
durationMs: performance.now() - started,
});
return frozen;
},
free(): void {
if (freed) return;
freed = true;
upstream?.free();
upstream = undefined;
accumulated = [];
},
};
}

View File

@@ -0,0 +1,19 @@
/**
* Local ambient declaration for the untyped upstream `@audio/decode-wav`
* library. Mirrors the runtime shape documented in `decode-wav.js`.
*/
declare module "@audio/decode-wav" {
export interface DecodedSegment {
channelData: Float32Array[];
sampleRate: number;
}
export interface IncrementalDecoder {
decode(data: Uint8Array): DecodedSegment;
flush(): DecodedSegment;
free(): void;
}
export default function decode(src: Uint8Array): Promise<DecodedSegment>;
export function decoder(): Promise<IncrementalDecoder>;
}

View File

@@ -0,0 +1,57 @@
# `types/decoded-audio`
## Purpose
`DecodedAudio` is the canonical shape every WAV decode in `@suaveplan/audio-decode-wav` resolves to. It is a frozen, validated record carrying PCM samples normalised to `[-1.0, 1.0]`, plus the dimensions every downstream consumer needs to dispatch on (sample rate, channel count, duration in seconds, raw input byte length). The shape is intentionally identical to what the future MP3/FLAC/Opus sibling packages will return, so format-dispatching callers (`@suaveplan/audio-decode`, the upcoming umbrella) can treat every codec uniformly. The schema is exposed alongside the type so callers and tests can validate inputs they did not produce themselves.
## Features
- Frozen, validated `DecodedAudio` interface — `channelData: readonly Float32Array[]`, `sampleRate`, `channels`, `durationSec`, `bytesDecoded`.
- Zod schema (`DecodedAudioSchema`) enforces: at least one channel, every channel same length, `channels === channelData.length`, sample rate is a positive integer up to `MAX_SAMPLE_RATE` (384 kHz), channel count up to `MAX_CHANNELS` (64), duration is non-negative and finite, decoded byte count is a non-negative integer.
- `freezeDecodedAudio(audio)` helper deep-freezes the record + channel array so consumers cannot mutate it after the wrapper hands it back.
## Usage: Basic
```ts
import {
type DecodedAudio,
DecodedAudioSchema,
freezeDecodedAudio,
} from "@suaveplan/audio-decode-wav";
const raw: DecodedAudio = {
channelData: [new Float32Array([0, 0.5, 0, -0.5])],
sampleRate: 8000,
channels: 1,
durationSec: 0.0005,
bytesDecoded: 44 + 8,
};
DecodedAudioSchema.parse(raw);
const frozen = freezeDecodedAudio(raw);
```
## Usage: Advanced
```ts
import { DecodedAudioSchema, MAX_CHANNELS } from "@suaveplan/audio-decode-wav";
function assertConformant(value: unknown): asserts value is import("@suaveplan/audio-decode-wav").DecodedAudio {
const parsed = DecodedAudioSchema.parse(value);
if (parsed.channels > MAX_CHANNELS) {
throw new Error("multichannel format exceeds wrapper budget");
}
}
```
## API Reference
- `interface DecodedAudio` — see fields above. All fields readonly.
- `const DecodedAudioSchema: ZodSchema` — runtime validator. Throws `ZodError` on invariant violations.
- `function freezeDecodedAudio(audio: DecodedAudio): DecodedAudio` — freezes the outer record and the channel array. Returns the same object.
- `const MAX_SAMPLE_RATE = 384_000`.
- `const MAX_CHANNELS = 64`.
## Implementation Notes
The schema deliberately enforces equal-length channels — the upstream decoder produces this invariant naturally, but we surface it as a hard schema rule so the wrapper's `DecodedAudio` contract is identical for callers regardless of source codec. `channelData` carries `Float32Array` instances directly rather than copying, so consumers should treat the result as immutable. The freeze step does not deep-freeze the underlying `Float32Array` buffers — typed arrays cannot be frozen in any meaningful way, but their `.length` is fixed and the wrapper never hands the same instance back from two distinct decode calls.

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type DecodedAudio,
DecodedAudioSchema,
freezeDecodedAudio,
} from "./decoded-audio.js";
describe("DecodedAudioSchema", () => {
it("accepts a well-formed DecodedAudio", { timeout: 5000 }, () => {
const ok: DecodedAudio = {
channelData: [new Float32Array(4), new Float32Array(4)],
sampleRate: 44100,
channels: 2,
durationSec: 4 / 44100,
bytesDecoded: 96,
};
expect(() => DecodedAudioSchema.parse(ok)).not.toThrow();
});
it("rejects mismatched channel lengths", { timeout: 5000 }, () => {
const bad: DecodedAudio = {
channelData: [new Float32Array(4), new Float32Array(3)],
sampleRate: 44100,
channels: 2,
durationSec: 4 / 44100,
bytesDecoded: 96,
};
expect(() => DecodedAudioSchema.parse(bad)).toThrow();
});
it("rejects channels mismatching channelData.length", {
timeout: 5000,
}, () => {
const bad: DecodedAudio = {
channelData: [new Float32Array(4)],
sampleRate: 44100,
channels: 2,
durationSec: 4 / 44100,
bytesDecoded: 96,
};
expect(() => DecodedAudioSchema.parse(bad)).toThrow();
});
it("rejects non-Float32Array channel data", { timeout: 5000 }, () => {
expect(() =>
DecodedAudioSchema.parse({
channelData: [[0, 0, 0]],
sampleRate: 44100,
channels: 1,
durationSec: 0,
bytesDecoded: 0,
})
).toThrow();
});
it("freezes the record and channel array", { timeout: 5000 }, () => {
const audio: DecodedAudio = {
channelData: [new Float32Array(2)],
sampleRate: 8000,
channels: 1,
durationSec: 2 / 8000,
bytesDecoded: 48,
};
const frozen = freezeDecodedAudio(audio);
expect(Object.isFrozen(frozen)).toBe(true);
expect(Object.isFrozen(frozen.channelData)).toBe(true);
});
});

View File

@@ -0,0 +1,63 @@
import z from "zod";
/**
* Frozen, validated result of a WAV decode.
*
* Every dimension a downstream consumer needs to dispatch on is surfaced
* explicitly: `channelData` (PCM samples in `[-1, 1]`), `sampleRate`,
* `channels` (always equal to `channelData.length`), `durationSec` (computed
* from the first channel), and `bytesDecoded` (the byte-length of the input
* buffer).
*/
export interface DecodedAudio {
readonly channelData: readonly Float32Array[];
readonly sampleRate: number;
readonly channels: number;
readonly durationSec: number;
readonly bytesDecoded: number;
}
/** Maximum sample rate accepted by the validator (DXD / DSD upper bound). */
export const MAX_SAMPLE_RATE = 384_000;
/** Maximum channel count accepted by the validator (matches WAVEFORMATEXTENSIBLE). */
export const MAX_CHANNELS = 64;
const Float32ArraySchema = z.custom<Float32Array>(
(v) => v instanceof Float32Array,
{ message: "expected Float32Array" }
);
/**
* Zod schema for `DecodedAudio` invariants. Used at the wrapper boundary to
* guarantee callers receive a result that satisfies every clause of the
* `## Whole-buffer decode` requirement.
*/
export const DecodedAudioSchema = z
.object({
channelData: z.array(Float32ArraySchema).min(1).max(MAX_CHANNELS),
sampleRate: z.number().int().positive().max(MAX_SAMPLE_RATE),
channels: z.number().int().positive().max(MAX_CHANNELS),
durationSec: z.number().nonnegative().finite(),
bytesDecoded: z.number().int().nonnegative(),
})
.refine((d) => d.channels === d.channelData.length, {
message: "channels must equal channelData.length",
})
.refine(
(d) => {
// `min(1)` above guarantees channelData[0] exists at refine time.
const firstLength = (d.channelData[0] as Float32Array).length;
return d.channelData.every((c) => c.length === firstLength);
},
{ message: "every channel must have the same length" }
);
/**
* Freeze a `DecodedAudio` after validation. The outer record is frozen with
* `Object.freeze`; the channel array is frozen via `Object.freeze` as well
* so downstream callers cannot push fresh channels in.
*/
export function freezeDecodedAudio(audio: DecodedAudio): DecodedAudio {
Object.freeze(audio.channelData);
return Object.freeze(audio);
}

View File

@@ -0,0 +1,46 @@
# `types/options`
## Purpose
`AudioDecodeOptions` is the option bag accepted by every public entry-point in `@suaveplan/audio-decode-wav` (`decodeWav`, `createWavStreamDecoder`). It carries the universal cross-cutting concerns every codec wrapper in the `audio-decode-*` family needs to honour: an `AbortSignal` for cooperative cancellation and a `targetSampleRate` for declaring caller intent about resampling. The shape is intentionally tiny — anything format-specific belongs in a dedicated options bag, not here.
## Features
- `signal?: AbortSignal` — propagated synchronously by the decoder. Pre-aborted signals short-circuit before the upstream library is invoked, surfacing `AUDIO_DECODE.WAV.ABORTED`. Mid-decode aborts surface with the original `AbortError` on `cause`.
- `targetSampleRate?: number` — declares the caller's intent. The decoder does NOT resample. Supplying a value that differs from the decoded sample rate raises `AUDIO_DECODE.WAV.RESAMPLE_UNSUPPORTED`, carrying both `decodedSampleRate` and `requestedSampleRate` discriminants so callers can wire a resampler step in.
- Zod-validated at the wrapper boundary via `AudioDecodeOptionsSchema`. Invalid `targetSampleRate` values (non-positive, non-integer, exceeding `MAX_SAMPLE_RATE`) raise `ZodError` before the upstream library is touched.
## Usage: Basic
```ts
import { decodeWav } from "@suaveplan/audio-decode-wav";
const audio = await decodeWav(wavBytes, {
signal: AbortSignal.timeout(5000),
});
```
## Usage: Advanced
```ts
import { AudioDecodeOptionsSchema, decodeWav } from "@suaveplan/audio-decode-wav";
function decodeOrResample(bytes: Uint8Array, target: number) {
AudioDecodeOptionsSchema.parse({ targetSampleRate: target });
return decodeWav(bytes, { targetSampleRate: target }).catch((err) => {
if (err.code === "AUDIO_DECODE.WAV.RESAMPLE_UNSUPPORTED") {
return decodeWav(bytes).then((a) => resampleTo(a, target));
}
throw err;
});
}
```
## API Reference
- `interface AudioDecodeOptions` — `{ signal?: AbortSignal; targetSampleRate?: number }`. All fields optional.
- `const AudioDecodeOptionsSchema: ZodSchema` — runtime validator. Custom `signal` checker validates `instanceof AbortSignal` or `undefined`; `targetSampleRate` is a positive integer up to `MAX_SAMPLE_RATE`.
## Implementation Notes
The schema uses Zod's `z.custom<AbortSignal>` rather than a structural check because `AbortSignal` is a host-defined interface — duck-typing it would defeat the purpose of validation. Browser, Node, and Bun all expose `AbortSignal` as a constructor with consistent semantics, so the runtime check works uniformly. Resampling is intentionally a separate concern (planned `@suaveplan/audio-resample`) so this wrapper stays focused on byte-to-Float32Array decoding; surfacing `RESAMPLE_UNSUPPORTED` keeps the option in the bag without expanding the package's responsibility surface.

View File

@@ -0,0 +1,35 @@
import z from "zod";
import { MAX_SAMPLE_RATE } from "./decoded-audio.js";
/**
* Caller-supplied options for `decodeWav` / `createWavStreamDecoder`.
*
* - `signal` — optional `AbortSignal` propagated synchronously by the
* decoder. A pre-aborted signal short-circuits before the upstream
* library is invoked.
* - `targetSampleRate` — informs the decoder of the caller's desired sample
* rate. Resampling is out of scope for this package; supplying a value
* that differs from the decoded rate raises `AUDIO_DECODE.WAV.RESAMPLE_UNSUPPORTED`
* so a downstream resampler step can be wired in by the caller. When the
* value matches the decoded rate the call is a no-op.
*/
export interface AudioDecodeOptions {
readonly signal?: AbortSignal;
readonly targetSampleRate?: number;
}
/** Zod schema for `AudioDecodeOptions` boundary validation. */
export const AudioDecodeOptionsSchema = z.object({
signal: z
.custom<AbortSignal>(
(v) => v === undefined || v instanceof AbortSignal,
{ message: "expected AbortSignal" }
)
.optional(),
targetSampleRate: z
.number()
.int()
.positive()
.max(MAX_SAMPLE_RATE)
.optional(),
});

View File

@@ -0,0 +1,60 @@
# `utils/inspect`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`inspectWavHeader(bytes)` is the wrapper-layer pre-scan that classifies a WAV byte buffer **before** the upstream `@audio/decode-wav` library is invoked. The upstream library throws an undifferentiated `TypeError` for "Not a WAV file", unsupported formats and missing chunks, so the wrapper's spec requirement to distinguish `CORRUPT_HEADER`, `TRUNCATED_BODY` and `UNSUPPORTED_FORMAT` is satisfied here, in pure JavaScript, without speculative decoding. The function returns a tagged-union result and never throws.
## Features
- Walks the RIFF chunk list (`fmt`, `data`, plus any auxiliary chunks) without allocating.
- Distinguishes `corrupt` (RIFF/WAVE magic missing, fmt chunk too short, no data chunk, non-progressing chunk size, data-before-fmt) from `truncated` (data chunk declares more bytes than the buffer holds) from `unsupported` (format code recognised but non-PCM/non-IEEE-float).
- Recognises seven WAV format codes: PCM (`0x0001`), MS-ADPCM (`0x0002`), IEEE float (`0x0003`), A-law (`0x0006`), μ-law (`0x0007`), IMA-ADPCM (`0x0011`), WAVE_FORMAT_EXTENSIBLE (`0xFFFE`). Unknown codes map to `format: "unknown"`.
- Honours odd-byte padding inside the chunk walk (every chunk's `size & 1` adds a pad byte before the next chunk).
## Usage: Basic
```ts
import { inspectWavHeader } from "@suaveplan/audio-decode-wav";
const result = inspectWavHeader(wavBytes);
if (result.kind === "ok") {
console.log(result.sampleRate, result.channels, result.bitsPerSample);
}
```
## Usage: Advanced
```ts
import { inspectWavHeader } from "@suaveplan/audio-decode-wav";
function summarise(bytes: Uint8Array): string {
const r = inspectWavHeader(bytes);
switch (r.kind) {
case "ok":
return `${r.channels} ch @ ${r.sampleRate} Hz, ${r.bitsPerSample}-bit, data=${r.dataBytesDeclared}`;
case "truncated":
return `truncated: ${r.bytesAvailable}/${r.bytesDeclared}`;
case "unsupported":
return `unsupported: ${r.format}`;
case "corrupt":
return `corrupt: ${r.reason}`;
}
}
```
## API Reference
- `function inspectWavHeader(bytes: Uint8Array): InspectResult` — never throws.
- `type InspectResult = InspectOk | InspectCorrupt | InspectTruncated | InspectUnsupported`.
- `interface InspectOk` — `{ kind: "ok"; sampleRate; channels; bitsPerSample; dataStart; dataBytesDeclared }`.
- `interface InspectCorrupt` — `{ kind: "corrupt"; reason: string }`.
- `interface InspectTruncated` — `{ kind: "truncated"; bytesAvailable; bytesDeclared }`.
- `interface InspectUnsupported` — `{ kind: "unsupported"; format: AudioFormatType }`.
## Implementation Notes
The function reads chunk identifiers as ASCII via `String.fromCharCode` rather than decoding UTF-8 — RIFF chunk IDs are guaranteed ASCII per the spec, so this is faster and avoids host text-decoder dependencies. The buffer-overrun guard (`pos + 8 + chunkSize > bytes.length`) catches both a too-short `fmt` chunk and a malformed `data` chunk before the upstream library has a chance to throw. The order of checks matters: `fmt` must precede `data`, so a `data`-before-`fmt` layout is classified `corrupt`, not `truncated`.

View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildAlawWav, buildWav } from "../../tests/fixtures/build-wav.js";
import { inspectWavHeader } from "./inspect.js";
function writeAscii(b: Uint8Array, off: number, s: string): void {
for (let i = 0; i < s.length; i++) b[off + i] = s.charCodeAt(i);
}
describe("inspectWavHeader", () => {
it("returns corrupt when buffer shorter than 12 bytes", {
timeout: 5000,
}, () => {
const r = inspectWavHeader(new Uint8Array(8));
expect(r.kind).toBe("corrupt");
});
it("returns corrupt when RIFF magic missing", { timeout: 5000 }, () => {
const r = inspectWavHeader(new Uint8Array(16));
expect(r.kind).toBe("corrupt");
if (r.kind === "corrupt") expect(r.reason).toMatch(/RIFF/);
});
it("returns corrupt when WAVE magic missing", { timeout: 5000 }, () => {
const b = new Uint8Array(16);
writeAscii(b, 0, "RIFF");
const r = inspectWavHeader(b);
expect(r.kind).toBe("corrupt");
if (r.kind === "corrupt") expect(r.reason).toMatch(/WAVE/);
});
it("returns corrupt for short fmt chunk", { timeout: 5000 }, () => {
const b = new Uint8Array(24);
const dv = new DataView(b.buffer);
writeAscii(b, 0, "RIFF");
dv.setUint32(4, 16, true);
writeAscii(b, 8, "WAVE");
writeAscii(b, 12, "fmt ");
dv.setUint32(16, 4, true);
const r = inspectWavHeader(b);
expect(r.kind).toBe("corrupt");
if (r.kind === "corrupt") expect(r.reason).toMatch(/16 bytes/);
});
it("returns unsupported with format=unknown for unknown format code", {
timeout: 5000,
}, () => {
const b = new Uint8Array(44);
const dv = new DataView(b.buffer);
writeAscii(b, 0, "RIFF");
dv.setUint32(4, 36, true);
writeAscii(b, 8, "WAVE");
writeAscii(b, 12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 0x4242, true);
dv.setUint16(22, 1, true);
dv.setUint32(24, 8000, true);
dv.setUint32(28, 8000, true);
dv.setUint16(32, 1, true);
dv.setUint16(34, 8, true);
writeAscii(b, 36, "data");
dv.setUint32(40, 0, true);
const r = inspectWavHeader(b);
expect(r.kind).toBe("unsupported");
if (r.kind === "unsupported") expect(r.format).toBe("unknown");
});
it("returns unsupported with format=alaw for A-law format code", {
timeout: 5000,
}, () => {
const r = inspectWavHeader(buildAlawWav());
expect(r.kind).toBe("unsupported");
if (r.kind === "unsupported") expect(r.format).toBe("alaw");
});
it("returns corrupt when data appears before fmt", {
timeout: 5000,
}, () => {
const b = new Uint8Array(40);
const dv = new DataView(b.buffer);
writeAscii(b, 0, "RIFF");
dv.setUint32(4, 32, true);
writeAscii(b, 8, "WAVE");
writeAscii(b, 12, "data");
dv.setUint32(16, 0, true);
const r = inspectWavHeader(b);
expect(r.kind).toBe("corrupt");
if (r.kind === "corrupt") expect(r.reason).toMatch(/before fmt/);
});
it("returns corrupt for non-progressing chunk size", {
timeout: 5000,
}, () => {
// Chunk id is neither 'fmt ' nor 'data', with size 0 — would loop forever
// if not for the non-progressing guard.
const b = new Uint8Array(28);
const dv = new DataView(b.buffer);
writeAscii(b, 0, "RIFF");
dv.setUint32(4, 20, true);
writeAscii(b, 8, "WAVE");
writeAscii(b, 12, "JUNK");
dv.setUint32(16, 0, true);
const r = inspectWavHeader(b);
expect(r.kind).toBe("corrupt");
});
it("returns corrupt when no data chunk found", { timeout: 5000 }, () => {
const b = new Uint8Array(36);
const dv = new DataView(b.buffer);
writeAscii(b, 0, "RIFF");
dv.setUint32(4, 28, true);
writeAscii(b, 8, "WAVE");
writeAscii(b, 12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true);
dv.setUint16(22, 1, true);
dv.setUint32(24, 8000, true);
dv.setUint32(28, 8000, true);
dv.setUint16(32, 1, true);
dv.setUint16(34, 8, true);
const r = inspectWavHeader(b);
expect(r.kind).toBe("corrupt");
if (r.kind === "corrupt") {
expect(r.reason).toMatch(/no data chunk/);
}
});
it("walks past padding byte for odd-sized chunks", {
timeout: 5000,
}, () => {
const padded = buildWav({
sampleRate: 8000,
channels: 1,
bitsPerSample: 16,
samplesPerChannel: 4,
formatCode: 1,
});
// Splice in a 'LIST' chunk of size 1 (odd, needs pad) before 'data'.
const insertion = new Uint8Array([
0x4c,
0x49,
0x53,
0x54, // "LIST"
0x01,
0x00,
0x00,
0x00, // size = 1
0x00, // 1 payload byte
0x00, // 1 pad byte (size & 1)
]);
const out = new Uint8Array(padded.length + insertion.length);
out.set(padded.subarray(0, 36), 0);
out.set(insertion, 36);
out.set(padded.subarray(36), 36 + insertion.length);
// Fix RIFF size.
const dv = new DataView(out.buffer);
dv.setUint32(4, out.length - 8, true);
const r = inspectWavHeader(out);
expect(r.kind).toBe("ok");
});
it("returns ok for well-formed PCM", { timeout: 5000 }, () => {
const bytes = buildWav({
sampleRate: 44100,
channels: 1,
bitsPerSample: 16,
samplesPerChannel: 4,
formatCode: 1,
});
const r = inspectWavHeader(bytes);
expect(r.kind).toBe("ok");
if (r.kind === "ok") {
expect(r.sampleRate).toBe(44100);
expect(r.channels).toBe(1);
expect(r.bitsPerSample).toBe(16);
}
});
});

View File

@@ -0,0 +1,161 @@
import type { AudioFormatType } from "../error-codes/error-codes.js";
/**
* Result of inspecting a WAV byte buffer prior to handing it to the
* upstream decoder. Surfaces the distinct failure modes the wrapper's
* spec MUST disambiguate: corrupt header (`RIFF`/`WAVE` magic missing,
* unparseable `fmt `), truncated body (`data` chunk declared longer
* than the bytes available), and unsupported format codes (A-law, μ-law,
* IMA-ADPCM, MS-ADPCM, extensible WAVE).
*/
export type InspectResult =
| InspectOk
| InspectCorrupt
| InspectTruncated
| InspectUnsupported;
export interface InspectOk {
readonly kind: "ok";
readonly sampleRate: number;
readonly channels: number;
readonly bitsPerSample: number;
readonly dataStart: number;
readonly dataBytesDeclared: number;
}
export interface InspectCorrupt {
readonly kind: "corrupt";
readonly reason: string;
}
export interface InspectTruncated {
readonly kind: "truncated";
readonly bytesAvailable: number;
readonly bytesDeclared: number;
}
export interface InspectUnsupported {
readonly kind: "unsupported";
readonly format: AudioFormatType;
}
const FORMAT_CODES: Readonly<Record<number, AudioFormatType>> = Object.freeze({
1: "pcm",
2: "ms-adpcm",
3: "ieee-float",
6: "alaw",
7: "mulaw",
17: "ima-adpcm",
65534: "extensible",
});
function readAscii4(bytes: Uint8Array, offset: number): string {
// Callers gate on `bytes.length >= offset + 4` (12-byte minimum at entry,
// `pos + 8 <= bytes.length` inside the chunk loop), so every read is
// in-range. The `as number` casts express the invariant.
return String.fromCharCode(
bytes[offset] as number,
bytes[offset + 1] as number,
bytes[offset + 2] as number,
bytes[offset + 3] as number
);
}
interface FmtInfo {
readonly formatCode: number;
readonly channels: number;
readonly sampleRate: number;
readonly bitsPerSample: number;
}
function parseFmt(
bytes: Uint8Array,
dv: DataView,
pos: number,
chunkSize: number
): FmtInfo | "short" {
if (chunkSize < 16 || pos + 8 + chunkSize > bytes.length) return "short";
return {
formatCode: dv.getUint16(pos + 8, true),
channels: dv.getUint16(pos + 10, true),
sampleRate: dv.getUint32(pos + 12, true),
bitsPerSample: dv.getUint16(pos + 22, true),
};
}
/**
* Inspect a WAV buffer. Returns a tagged union — never throws.
*
* Walks the RIFF chunk list looking for `fmt ` and `data`. Distinguishes:
* - `corrupt` — missing magic, fmt chunk too short, unknown format code,
* chunk size walks past the buffer end.
* - `unsupported` — format code we recognise but cannot decode (A-law,
* μ-law, ADPCM variants, WAVE_FORMAT_EXTENSIBLE).
* - `truncated` — `data` chunk declares more bytes than the buffer holds.
* - `ok` — every invariant satisfied.
*/
export function inspectWavHeader(bytes: Uint8Array): InspectResult {
if (bytes.length < 12) {
return { kind: "corrupt", reason: "buffer too short for RIFF header" };
}
if (readAscii4(bytes, 0) !== "RIFF") {
return { kind: "corrupt", reason: "missing RIFF magic" };
}
if (readAscii4(bytes, 8) !== "WAVE") {
return { kind: "corrupt", reason: "missing WAVE magic" };
}
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let pos = 12;
let fmt: FmtInfo | undefined;
while (pos + 8 <= bytes.length) {
const chunkId = readAscii4(bytes, pos);
const chunkSize = dv.getUint32(pos + 4, true);
if (chunkId === "fmt ") {
const parsed = parseFmt(bytes, dv, pos, chunkSize);
if (parsed === "short") {
return {
kind: "corrupt",
reason: "fmt chunk shorter than 16 bytes",
};
}
const known = FORMAT_CODES[parsed.formatCode];
if (known === undefined) {
return { kind: "unsupported", format: "unknown" };
}
if (known !== "pcm" && known !== "ieee-float") {
return { kind: "unsupported", format: known };
}
fmt = parsed;
} else if (chunkId === "data") {
if (fmt === undefined) {
return {
kind: "corrupt",
reason: "data chunk before fmt chunk",
};
}
const dataStart = pos + 8;
const bytesAvailable = bytes.length - dataStart;
if (bytesAvailable < chunkSize) {
return {
kind: "truncated",
bytesAvailable,
bytesDeclared: chunkSize,
};
}
return {
kind: "ok",
sampleRate: fmt.sampleRate,
channels: fmt.channels,
bitsPerSample: fmt.bitsPerSample,
dataStart,
dataBytesDeclared: chunkSize,
};
}
const advance = 8 + chunkSize + (chunkSize & 1);
if (advance <= 8) {
return { kind: "corrupt", reason: "non-progressing chunk" };
}
pos += advance;
}
return { kind: "corrupt", reason: "no data chunk found" };
}