first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
# `codecs/aac`
Lazy AAC codec — decode-only wrapper over `@audio/decode-aac` WASM
module loaded on first use.
## Purpose
AAC (Advanced Audio Coding) is the modern lossy audio successor to MP3,
used by every major streaming service and as the default audio format
in MP4/M4A containers. The codec is decode-only because (a) the AAC
encoder is complex and patent-encumbered in many jurisdictions, and
(b) the canonical lossy encode target is Opus.
Implementation delegates to [`createWasmCodec`](./wasm-base.md) for the
lazy-load, error-wrapping, and missing-module behaviour shared by every
WASM wrapper.
## Features
- `name === "aac"`, extensions `["aac", "m4a"]`, MIME types
`["audio/aac", "audio/mp4"]`.
- `canDecode(bytes)` — magic-byte sniff for the ADTS sync word
(first byte `0xff`, second byte top 4 bits `0b1111`); rejects
buffers shorter than 2 bytes.
- Decode supported via `@audio/decode-aac`.
- `supportsEncode: false` — encode throws `NOT_SUPPORTED`
short-circuit.
- Streaming factory absent.
## Usage: Basic Example
```ts
import { AacCodec } from "@suaveplan/audio-codecs/codecs/aac";
const decoded = await AacCodec.decode(aacBytes);
```
## Usage: Advanced Example
```ts
import { CodecRegistry, AacCodec, OpusCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [AacCodec, OpusCodec] });
// transcode AAC → Opus for downstream storage
const aacDoc = await registry.decode(aacBytes);
const opusBytes = await registry.encode(aacDoc, "opus");
```
## API Reference
- `AacCodec: AudioCodec` — frozen constant; decode-only.
- `canDecode(bytes)`, `decode(bytes, opts?)` — standard `AudioCodec`
contract.
- `encode()` — always throws `AUDIO_CODECS.NOT_SUPPORTED`.
## Implementation Notes
- The ADTS sync word check (`0xff 0xf0`) covers raw ADTS-framed AAC.
AAC inside an MP4/M4A container starts with `ftyp` instead; that
case is currently NOT handled by `canDecode` — callers passing
M4A files need to either use a separate MP4 demuxer first or call
`decode` with `format: "aac"` to bypass detection. A future
extension may add an MP4-container sniffer.
- Decode-only by design. suaveplan's preferred lossy encode target is
Opus; AAC encoding requires non-trivial licensing in some
jurisdictions and is intentionally out of scope.
- First decode lazily loads `@audio/decode-aac`; subsequent calls
reuse the memoised module.
- Missing `@audio/decode-aac` → `AUDIO_CODECS.NOT_SUPPORTED`.
- Streaming factory absent — `CodecRegistry.decodeStream("aac", …)`
throws `AUDIO_CODECS.STREAM_ERROR`.

View File

@@ -0,0 +1,117 @@
# `codecs/aiff`
Native pure-JS AIFF (Audio Interchange File Format) decoder, decode-only.
## Purpose
AIFF is the big-endian counterpart to WAV used historically on Apple
hardware. The codec supports both the original AIFF container and the
later AIFC variant (which uses the same chunk structure but allows
non-PCM payloads — though this codec only decodes uncompressed integer
PCM).
Encoding is intentionally not supported: AIFF write is rarely useful
outside Apple's own tooling and the canonical archive format
is WAV/FLAC. Callers attempting to encode AIFF receive a clean
`AUDIO_CODECS.NOT_SUPPORTED` error rather than a silently-degraded
fallback.
## Features
- `canDecode(bytes)` — magic-byte sniff for `FORM` at byte 0 plus
`AIFF` or `AIFC` at byte 8; rejects buffers shorter than 12 bytes
or with non-matching magic.
- `decode(bytes)` — walks the AIFF chunk list for `COMM` and `SSND`,
decodes the 80-bit IEEE 754 extended-precision sample rate, and
returns a `DecodeResult` with normalised `Float32Array[]` PCM.
- 8-bit signed, 16-bit signed, 24-bit signed, and 32-bit signed PCM
in big-endian byte order are all decoded; the 80-bit extended-float
sample rate field is parsed in full so the codec round-trips any
sample rate AIFF can express, including the unusual ones used by old
samplers (37_800, 32_000, 24_000, etc).
- `encode` — throws `AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")`.
## Usage: Basic Example
```ts
import { AiffCodec } from "@suaveplan/audio-codecs/codecs/aiff";
const decoded = await AiffCodec.decode(aiffBytes);
console.log(decoded.sampleRate, decoded.channelData.length);
```
## Usage: Advanced Example
Detecting AIFF vs AIFC and falling through to a separate processor:
```ts
import { AiffCodec } from "@suaveplan/audio-codecs/codecs/aiff";
async function processAudio(bytes: Uint8Array): Promise<AudioDocument> {
if (AiffCodec.canDecode(bytes)) {
const result = await AiffCodec.decode(bytes);
return buildDocumentFromResult(result);
}
return processWithFallbackCodec(bytes);
}
// Direct registry registration:
import { CodecRegistry } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [AiffCodec] });
const doc = await registry.decode(aiffBytes);
```
## API Reference
- `AiffCodec: AudioCodec` — frozen constant; `name === "aiff"`,
`extensions === ["aiff", "aif", "aifc"]`, `mimeTypes === ["audio/aiff",
"audio/x-aiff"]`.
- `canDecode(bytes: Uint8Array): boolean`
- `decode(bytes: Uint8Array): Promise<DecodeResult>` — throws
`AudioCodecsError("AUDIO_CODECS.DECODE_FAILED")` for:
- buffer length < 12 (`too-short`),
- non-FORM magic (`bad-form`),
- non-AIFF/AIFC type (`bad-type`),
- COMM chunk size < 18 (`bad-comm-size`),
- missing COMM chunk (`no-common`),
- missing SSND chunk (`no-ssnd`),
- `sampleSize` = 0 (`unsupported-bits`),
- `sampleSize` > 32 (`unsupported-bytes-per-sample`).
- `encode(): Promise<Uint8Array>` — always throws
`AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")`.
- The optional streaming-factory methods are absent; `CodecRegistry`
honours that and throws `STREAM_ERROR` on attempted streaming
operations against this codec.
## Implementation Notes
- The 80-bit extended-precision IEEE 754 sample-rate field is decoded
by `parseIeee754Extended(bytes, off)` which reads 10 bytes (1 sign
bit + 15 exponent bits biased by 16383 + 64-bit explicit mantissa —
the AIFF format predates double-precision IEEE 754 and uses an
explicit-mantissa variant rather than the more common implicit-1
binary64). The function returns `0` when `off + 10 > bytes.byteLength`
so a truncated buffer produces a 0-Hz result rather than a thrown
error — the parent `decode` then fails with a meaningful chunk-level
error.
- AIFF chunk sizes are big-endian uint32; the chunk walker advances
`pos = body + size + (size & 1)` to honour the AIFF requirement that
chunks pad to even boundaries.
- The normalisation factor is `(1 << (sampleSize - 1)) >>> 0` — the
unsigned-shift handles the 32-bit case where `1 << 31` would
otherwise produce a negative number under signed arithmetic.
- The 24-bit decoder reads bytes in MSB-first order (AIFF is
big-endian) using one signed byte + two unsigned bytes to preserve
the sign bit without depending on the JavaScript runtime's
sign-extension semantics.
- `numSampleFrames` from COMM caps decoded output; the codec also
caps via `availableFrames = floor(ssndLength / frameBytes)` so a
truncated SSND chunk simply yields fewer frames rather than reading
past the buffer.
- No streaming factory exists because AIFF's `SSND` chunk is variably
positioned within the FORM block — implementing a single-pass
streaming decoder would require buffering the whole header section
anyway, which negates the streaming benefit. Callers needing
streaming PCM can pipe the AIFF through a full decode then push the
result via the registry's `encodeStream` into WAV (which is
streamable).

View File

@@ -0,0 +1,109 @@
# `codecs/caf`
Native pure-JS CAF (Apple Core Audio Format) decoder, decode-only.
## Purpose
CAF is the modern Apple-specific container that replaced AIFF for use
cases needing 64-bit chunk sizes (files larger than 4 GiB) and embedded
metadata. This codec decodes the uncompressed integer PCM (`lpcm`)
payload that the vast majority of real-world CAF files carry. Compressed
payloads (Apple Lossless, AAC inside CAF) are out of scope and require
the corresponding WASM codec.
Encoding is intentionally not supported. CAF write is essentially never
useful outside Apple's own audio toolchain, and the canonical archive
formats are WAV and FLAC. The codec throws
`AUDIO_CODECS.NOT_SUPPORTED` for any encode attempt.
## Features
- `canDecode(bytes)` — magic-byte sniff for `caff` (`0x63 0x61 0x66 0x66`)
at byte 0; rejects buffers shorter than 4 bytes or with non-matching
magic.
- `decode(bytes)` — walks the CAF chunk list (64-bit big-endian chunk
sizes — `caff` uses uint64 chunk sizes, distinct from RIFF/AIFF
uint32 sizes) for `desc` and `data`, decodes the uncompressed PCM,
and returns a `DecodeResult` with normalised `Float32Array[]` PCM
and the sample rate parsed from the IEEE 754 binary64 `desc.sampleRate`
field.
- Sample byte order is determined by the `formatFlags` bit
`kCAFLinearPCMFormatFlagIsLittleEndian` (`0x0002`); the decoder honours
it per sample so files written by either little-endian or big-endian
producers decode correctly.
- 16-bit, 24-bit, and 32-bit signed integer PCM are supported. Other
bit depths (8, 64, 32 float) are rejected with
`AUDIO_CODECS.DECODE_FAILED("unsupported-bytes-per-sample")`.
- `encode` — throws `AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")`.
## Usage: Basic Example
```ts
import { CafCodec } from "@suaveplan/audio-codecs/codecs/caf";
const decoded = await CafCodec.decode(cafBytes);
console.log(decoded.sampleRate, decoded.format);
```
## Usage: Advanced Example
Detect-then-dispatch via the shared registry:
```ts
import { CodecRegistry, CafCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec, CafCodec] });
const fmt = registry.detect(bytes);
if (fmt === "caf") {
// re-encode to WAV for downstream consumers
const doc = await registry.decode(bytes);
const wav = await registry.encode(doc, "wav", { bitDepth: 16, format: "pcm" });
return wav;
}
```
## API Reference
- `CafCodec: AudioCodec` — frozen constant; `name === "caf"`,
`extensions === ["caf"]`, `mimeTypes === ["audio/x-caf"]`.
- `canDecode(bytes: Uint8Array): boolean`
- `decode(bytes: Uint8Array): Promise<DecodeResult>` — throws
`AudioCodecsError("AUDIO_CODECS.DECODE_FAILED")` for:
- buffer length < 8 (`too-short`),
- non-`caff` magic (`bad-magic`),
- `desc` chunk size < 32 (`bad-desc-size`),
- missing `desc` chunk (`no-desc`),
- missing `data` chunk (`no-data`),
- `bitsPerChannel` = 0 (`unsupported-bits`),
- `bitsPerChannel` > 32 (`unsupported-bytes-per-sample`).
- `encode(): Promise<Uint8Array>` — always throws
`AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")`.
- The optional streaming-factory methods are absent; the registry
honours that and throws `STREAM_ERROR` on streaming requests.
## Implementation Notes
- CAF chunk sizes are uint64 big-endian. The codec reads them via two
separate uint32 calls (`high * 2^32 + low`) because JavaScript's
`DataView.getBigUint64` returns a `BigInt` which would force the
caller to convert downstream and complicate arithmetic. The
`high * 2^32` form preserves precision for sizes up to
`Number.MAX_SAFE_INTEGER` (~9 PiB) which is far beyond any real CAF
payload.
- The `data` chunk has a 4-byte `editCount` field at the start of its
body; the codec skips it (offset by 4) before treating the remaining
bytes as PCM.
- The sample rate is decoded via `DataView.getFloat64(..., false)`
(big-endian IEEE 754 binary64) — this is the modern CAF format and
contrasts with AIFF's 80-bit extended-precision representation.
- The format ID at offset 8 of `desc` is `lpcm` (`0x6c70636d`) for
uncompressed integer PCM. The decoder does not currently dispatch on
this field — it assumes `lpcm` and decodes accordingly. Other format
IDs (`alac`, `aac `, `lpcm` floats) would produce wrong output;
callers needing compressed CAF support should use the corresponding
WASM codec for the inner format.
- Sample byte order is determined per sample by the
`formatFlags & 0x02` bit; the decoder applies it uniformly to 16/24/32
bit reads. 24-bit reads use a signed high byte + two unsigned bytes,
reordered based on endianness, to preserve sign across either layout.

View File

@@ -0,0 +1,80 @@
# `codecs/flac`
Lazy FLAC codec — thin wrapper over `@audio/decode-flac` WASM module
loaded on first use.
## Purpose
FLAC (Free Lossless Audio Codec) is the canonical lossless format for
high-quality audio archival. It compresses raw PCM by 30–60% with no
quality loss, making it the standard format for music libraries that
need to preserve every bit of the original signal.
The package treats FLAC as a WASM-backed codec to keep the universal
`audio-codecs` package size-constant — pure-JS FLAC implementations
are large and slow, and bundling one would punish every consumer who
never touches FLAC. The actual decode/encode work delegates to
`@audio/decode-flac` loaded via dynamic `import()` on first call.
Implementation delegates to [`createWasmCodec`](./wasm-base.md) which
documents the shared lazy-load, missing-module, and error-wrapping
contract.
## Features
- `name === "flac"`, extensions `["flac"]`, MIME types
`["audio/flac", "audio/x-flac"]`.
- `canDecode(bytes)` — magic-byte sniff for `fLaC`
(`0x66 0x4c 0x61 0x43`) at byte 0; rejects buffers shorter than 4
bytes.
- Decode + encode both supported (`supportsEncode: true`).
- Streaming factory absent — single-pass full-buffer codec.
## Usage: Basic Example
```ts
import { FlacCodec } from "@suaveplan/audio-codecs/codecs/flac";
const decoded = await FlacCodec.decode(flacBytes);
const reEncoded = await FlacCodec.encode(decoded.channelData, {
sampleRate: decoded.sampleRate,
bitDepth: 24,
});
```
## Usage: Advanced Example
Round-trip FLAC → WAV → FLAC via the shared registry:
```ts
import { CodecRegistry, FlacCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [FlacCodec, WavCodec] });
const flacDoc = await registry.decode(flacBytes);
const intermediate = await registry.encode(flacDoc, "wav", { bitDepth: 24, format: "pcm" });
const wavDoc = await registry.decode(intermediate);
const finalFlac = await registry.encode(wavDoc, "flac");
```
## API Reference
- `FlacCodec: AudioCodec` — `name === "flac"`, frozen.
- `canDecode(bytes)`, `decode(bytes, opts?)`, `encode(channelData,
opts?)` — see [`wasm-base.md`](./wasm-base.md) for the shared
contract.
## Implementation Notes
- Magic-byte sniff is cheap — no WASM load required for `canDecode`.
- The first decode/encode call lazily loads `@audio/decode-flac`;
subsequent calls reuse the same memoised module instance.
- Missing `@audio/decode-flac` → `AUDIO_CODECS.NOT_SUPPORTED` with the
module name in `context.module`.
- FLAC's native streaming-decode form (`Subset` profile blocks) is
not exposed by this wrapper — adding it requires a per-block
callback API which is a future extension.
- FLAC files commonly carry Vorbis comments and embedded artwork in
the same container; the codec emits PCM only — metadata extraction
is `@suaveplan/audio-metadata`'s responsibility via its own FLAC
parser, separate from this codec.

View File

@@ -0,0 +1,100 @@
# `codecs/mp3`
Lazy MP3 codec — thin wrapper that defers decode/encode to the
`@audio/decode-mp3` WASM module loaded via dynamic `import()` on first
use.
## Purpose
MP3 is the canonical lossy audio format. It is treated as a WASM-backed
codec because pure-JS MP3 decoders are slow and large enough that bundling
one with the universal `audio-codecs` package would punish every
consumer who never decodes MP3. Instead the codec ships as a thin lazy
wrapper that costs essentially zero bytes to import and pays for itself
only when MP3 bytes actually arrive.
The implementation delegates to `createWasmCodec` from
[`wasm-base.md`](./wasm-base.md) — see that document for the lazy-load,
error-wrapping, and missing-module-handling contract that every WASM
wrapper inherits.
## Features
- `name === "mp3"`, extensions `["mp3", "mpeg"]`, MIME types
`["audio/mpeg", "audio/mp3"]`.
- `canDecode(bytes)` — magic-byte sniff: returns `true` for buffers
starting with the ID3v2 tag prefix (`"ID3"`) or the MPEG-1 audio sync
word (first byte `0xff`, second byte top 3 bits `0b111`). Returns
`false` for buffers shorter than 2 bytes.
- Decode supported via `@audio/decode-mp3` once loaded.
- Encode supported via the same module (`supportsEncode: true`).
- Streaming factory absent — the underlying module exposes only
full-buffer decode/encode; streaming MP3 needs a per-frame parser
that lives in `@audio/stream-mp3` if/when that ships.
## Usage: Basic Example
```ts
import { Mp3Codec } from "@suaveplan/audio-codecs/codecs/mp3";
// magic sniff works without loading the WASM module
if (Mp3Codec.canDecode(bytes)) {
const decoded = await Mp3Codec.decode(bytes); // first call loads WASM
consume(decoded.channelData);
}
```
## Usage: Advanced Example
Register MP3 alongside other codecs in a shared registry:
```ts
import { CodecRegistry, Mp3Codec, WavCodec, FlacCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({
codecs: [WavCodec, Mp3Codec, FlacCodec],
});
// magic-byte detection picks the right codec
const fmt = registry.detect(bytes);
if (fmt) {
const doc = await registry.decode(bytes, { format: fmt });
return doc;
}
```
## API Reference
- `Mp3Codec: AudioCodec` — frozen constant produced by
`createWasmCodec({ name: "mp3", extensions, mimeTypes, module:
"@audio/decode-mp3", magic: mp3Magic, supportsEncode: true, load })`.
- `canDecode(bytes: Uint8Array): boolean` — magic-byte sniff
(lightweight, synchronous, no WASM load).
- `decode(bytes: Uint8Array, opts?: DecodeOpts): Promise<DecodeResult>` —
lazily loads `@audio/decode-mp3` on first call. Throws
`AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")` if the optional
dependency is not installed, or `DECODE_FAILED` on a codec error.
- `encode(channelData, opts?: EncodeOpts): Promise<Uint8Array>` — same
loading semantics; throws `NOT_SUPPORTED` if the module is missing or
exposes no encode method, `ENCODE_FAILED` on codec error.
## Implementation Notes
- The magic-byte sniff is intentionally cheap so the registry can probe
every codec without paying any WASM-load cost. False positives on
the MPEG sync word are possible because the sync pattern is short
(12 bits); callers that need stricter detection should layer an MP3
frame-header validator on top.
- The ID3v2 prefix means real-world MP3 files carrying ID3 metadata
still sniff as MP3; the metadata extraction is `@suaveplan/audio-metadata`'s
responsibility, not this codec's.
- The codec follows the spec's "WASM-backed codecs loaded on demand"
requirement — no static import of `@audio/decode-mp3`. Consumers
that need full bundling can install the dep explicitly; consumers
that only handle WAV/AIFF pay zero bytes for the MP3 path.
- The first decode/encode call may take 50–200 ms while the WASM module
initialises (instantiate + memory allocation). Subsequent calls reuse
the same instance and run at native-adjacent speed.
- The streaming-decoder / streaming-encoder factories are intentionally
absent on this codec; `CodecRegistry.decodeStream("mp3", …)` therefore
throws `AUDIO_CODECS.STREAM_ERROR`.

View File

@@ -0,0 +1,82 @@
# `codecs/ogg-vorbis`
Lazy Ogg Vorbis codec — decode-only wrapper over
`@audio/decode-ogg-vorbis` WASM module loaded on first use.
## Purpose
Ogg Vorbis is the canonical open-source lossy audio format that pre-dates
Opus, still widely used in game audio and free-software distributions.
The codec is decode-only because (a) the Vorbis encoder is a complex
piece of perceptual-audio engineering best left to the canonical
`libvorbis` implementation packaged separately, and (b) Opus is the
preferred lossy encode target for new content.
Implementation delegates to [`createWasmCodec`](./wasm-base.md) — see
that document for the lazy-load, error-wrapping, and missing-module
behaviour shared by every WASM wrapper.
## Features
- `name === "ogg-vorbis"`, extensions `["ogg", "oga"]`, MIME types
`["audio/ogg", "audio/vorbis"]`.
- `canDecode(bytes)` — magic-byte sniff for `OggS`
(`0x4f 0x67 0x67 0x53`) at byte 0; rejects buffers shorter than 4
bytes.
- Decode supported via `@audio/decode-ogg-vorbis`.
- `supportsEncode: false` — encode throws
`AUDIO_CODECS.NOT_SUPPORTED` short-circuit (no WASM load).
- Streaming factory absent.
## Usage: Basic Example
```ts
import { OggVorbisCodec } from "@suaveplan/audio-codecs/codecs/ogg-vorbis";
const decoded = await OggVorbisCodec.decode(oggBytes);
console.log(decoded.sampleRate, decoded.channelData.length);
```
## Usage: Advanced Example
Detect-then-dispatch from a mixed source via the registry:
```ts
import { CodecRegistry, OggVorbisCodec, OpusCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({
codecs: [WavCodec, OggVorbisCodec, OpusCodec],
});
const fmt = registry.detect(bytes);
// Ogg containers may carry Vorbis or Opus; the first matching codec by
// magic wins. If both Ogg-based codecs are registered, the codec
// registered first takes precedence — register OpusCodec first if you
// want Opus to win the tie.
if (fmt === "ogg-vorbis") {
return registry.decode(bytes);
}
```
## API Reference
- `OggVorbisCodec: AudioCodec` — frozen constant; decode-only.
- `canDecode(bytes)`, `decode(bytes, opts?)` — standard
`AudioCodec` contract; encode throws `NOT_SUPPORTED`.
## Implementation Notes
- The `OggS` magic is shared with Opus, FLAC-in-Ogg, and Ogg-Speex —
callers need to layer downstream sniffing if they need to
discriminate. The default registry order in `decode-source.ts`
registers Vorbis before Opus, which is the convention
for ambiguous Ogg payloads.
- Encode is intentionally not supported. Preferred lossy
encode target is Opus; callers needing Vorbis output should run
`libvorbis` in a separate worker.
- The first decode call lazily loads `@audio/decode-ogg-vorbis`;
subsequent calls reuse the memoised module.
- Missing `@audio/decode-ogg-vorbis` → `AUDIO_CODECS.NOT_SUPPORTED`
with the module name in `context.module`.
- Streaming factory absent — `CodecRegistry.decodeStream("ogg-vorbis",
…)` throws `AUDIO_CODECS.STREAM_ERROR`.

View File

@@ -0,0 +1,73 @@
# `codecs/opus`
Lazy Opus codec — wrapper over `@audio/decode-opus` WASM module loaded on
first use.
## Purpose
Opus is the modern open-source lossy audio codec that supersedes both
Vorbis (for general-purpose use) and Speex (for voice). It encodes
voice and music at low bitrates with quality that frequently beats
MP3 and AAC, and is the default target for WebRTC voice traffic.
The package treats Opus as the canonical lossy encode target.
The codec is implemented as a thin wrapper over `@audio/decode-opus`
loaded via dynamic `import()`. Implementation delegates to
[`createWasmCodec`](./wasm-base.md) for the lazy-load, error-wrapping,
and missing-module behaviour shared by every WASM wrapper.
## Features
- `name === "opus"`, extensions `["opus"]`, MIME types `["audio/opus"]`.
- `canDecode(bytes)` — magic-byte sniff for `OggS` at byte 0 (Opus
payloads are typically Ogg-encapsulated); rejects buffers shorter
than 4 bytes.
- Decode + encode both supported.
- Streaming factory absent — single-pass full-buffer codec.
## Usage: Basic Example
```ts
import { OpusCodec } from "@suaveplan/audio-codecs/codecs/opus";
const decoded = await OpusCodec.decode(opusBytes);
const reEncoded = await OpusCodec.encode(decoded.channelData);
```
## Usage: Advanced Example
Pipeline: decode WAV → re-encode as Opus for transport:
```ts
import { CodecRegistry, OpusCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec, OpusCodec] });
const wavDoc = await registry.decode(wavBytes);
const opusBytes = await registry.encode(wavDoc, "opus");
// Send opusBytes over a low-bandwidth transport.
```
## API Reference
- `OpusCodec: AudioCodec` — frozen constant.
- `canDecode(bytes)`, `decode(bytes, opts?)`, `encode(channelData,
opts?)` — see [`wasm-base.md`](./wasm-base.md).
## Implementation Notes
- The `OggS` magic shared with Vorbis and FLAC-in-Ogg means
detect-then-dispatch may pick the wrong codec when multiple
Ogg-family codecs are registered. Register Opus before Vorbis if
you expect Opus content to dominate; otherwise pass `format: "opus"`
as a hint to `decode` to bypass detection.
- The first decode/encode call lazily loads `@audio/decode-opus`;
subsequent calls reuse the memoised module.
- Missing `@audio/decode-opus` → `AUDIO_CODECS.NOT_SUPPORTED`.
- Streaming Opus (chunked decode at 20 ms frame boundaries) is not
exposed by this wrapper — adding it requires a per-frame parser
and the `@audio/stream-opus` companion module which is a future
extension.
- The codec accepts the standard `EncodeOpts` shape but Opus' real
encoder tuning (bitrate, complexity, signal type) is module-specific
and lives in the `@audio/decode-opus` options surface.

View File

@@ -0,0 +1,74 @@
# `codecs/qoa`
Lazy QOA (Quite OK Audio) codec — wrapper over `@audio/decode-qoa` WASM
module loaded on first use.
## Purpose
QOA is a recently-published lossy-but-tiny audio codec optimised for
embedded/game-audio scenarios where MP3/Opus add too much complexity.
It encodes 16-bit PCM to ~3.2 bits per sample using a fixed-size frame
layout (~6× compression) with no perceptual model — making it cheap
to decode and stream. It is included for the game-audio /
embedded use case.
Implementation delegates to [`createWasmCodec`](./wasm-base.md) for the
shared lazy-load, error-wrapping, and missing-module behaviour.
## Features
- `name === "qoa"`, extensions `["qoa"]`, MIME types `["audio/qoa"]`.
- `canDecode(bytes)` — magic-byte sniff for `qoaf`
(`0x71 0x6f 0x61 0x66`) at byte 0; rejects buffers shorter than 4
bytes.
- Decode + encode both supported (`supportsEncode: true`).
- Streaming factory absent — single-pass full-buffer codec for now;
QOA's fixed-frame structure means a streaming variant is feasible
as a future extension.
## Usage: Basic Example
```ts
import { QoaCodec } from "@suaveplan/audio-codecs/codecs/qoa";
const decoded = await QoaCodec.decode(qoaBytes);
const reEncoded = await QoaCodec.encode(decoded.channelData, {
sampleRate: decoded.sampleRate,
});
```
## Usage: Advanced Example
Use QOA as a compact game-audio sprite format:
```ts
import { CodecRegistry, QoaCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec, QoaCodec] });
// Compress a sound-effect bank from WAV to QOA for shipping.
const wavDoc = await registry.decode(wavBytes);
const qoaBytes = await registry.encode(wavDoc, "qoa");
// Approximately 6× smaller than the input WAV.
```
## API Reference
- `QoaCodec: AudioCodec` — frozen constant.
- `canDecode(bytes)`, `decode(bytes, opts?)`, `encode(channelData,
opts?)` — see [`wasm-base.md`](./wasm-base.md).
## Implementation Notes
- QOA's `qoaf` magic is unambiguous — no other audio format starts
with these bytes, so detect-then-dispatch is reliable.
- The first decode/encode call lazily loads `@audio/decode-qoa`;
subsequent calls reuse the memoised module.
- Missing `@audio/decode-qoa` → `AUDIO_CODECS.NOT_SUPPORTED`.
- QOA's fixed-frame structure (256 samples per frame, 8-byte header
per channel slice) makes it amenable to a streaming-decoder
factory — adding one requires only a per-frame buffer drain and
is a future extension.
- Quality vs MP3/Opus: QOA is lower fidelity at equal bitrate, but
has zero entropy coding so decode is bounded constant-time per
sample. The trade-off favours embedded / low-resource scenarios.

View File

@@ -0,0 +1,136 @@
# `codecs/wasm-base`
Shared factory for the seven lazy WASM-backed codec wrappers.
## Purpose
The MP3, FLAC, Ogg Vorbis, Opus, AAC, WebM, and QOA codecs are all thin
adapters over external `@audio/*` WASM modules declared as
`optionalDependencies` in `package.json`. They share the same structural
contract — lazy-load the WASM module on first use, throw a stable
`AUDIO_CODECS.NOT_SUPPORTED` if the optional dependency is not
installed, and wrap codec errors into the package's `AudioCodecsError`
hierarchy.
`createWasmCodec(spec)` is the factory that captures that contract once
and produces the seven concrete `AudioCodec` instances. Without it,
every wrapper would re-implement memoised loading, error remapping, and
encode-not-supported branching — and any divergence between wrappers
would silently degrade some formats relative to others.
## Features
- `createWasmCodec(spec: WasmCodecSpec): AudioCodec` — returns an
`AudioCodec` whose `name`, `extensions`, `mimeTypes`, and `canDecode`
are taken directly from `spec`, and whose `decode` / `encode` invoke
the lazily-loaded WASM module's corresponding methods.
- Lazy module load via `spec.load()` — typically a closure capturing
`() => import("@audio/decode-<format>")`. The promise is memoised
after the first call, so repeated decode operations reuse the same
loaded module.
- Missing-module behaviour: if `spec.load()` rejects (the
`optionalDependency` is not installed), the codec throws
`AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")` carrying the module
name in `context.module`.
- Encode gating: `spec.supportsEncode === false` short-circuits the
encode method with `NOT_SUPPORTED` before any module load — so a
decode-only codec costs nothing extra on the encode path.
- Decode-only modules: if the loaded module exposes no `decode` method,
the codec throws `NOT_SUPPORTED` with the module name; same shape
applies for missing `encode`.
- Non-API errors from the underlying module are wrapped — `decode`
failures become `AUDIO_CODECS.DECODE_FAILED`, `encode` failures
become `AUDIO_CODECS.ENCODE_FAILED`, both with `cause` set to the
original error and `context.module` + `context.format` for triage.
- `AudioCodecsError` thrown from inside the module passes through
unchanged — already-typed errors don't get re-wrapped.
## Usage: Basic Example
```ts
import { createWasmCodec } from "@suaveplan/audio-codecs/codecs/wasm-base";
export const FooCodec = createWasmCodec({
name: "foo",
extensions: ["foo"],
mimeTypes: ["audio/foo"],
module: "@audio/decode-foo",
magic: (bytes) => bytes.length >= 4 && bytes[0] === 0xfo && bytes[1] === 0x0d,
supportsEncode: false,
load: () => import("@audio/decode-foo"),
});
```
## Usage: Advanced Example
Constructing a codec for an internal WASM module with custom decode
adaptation (e.g. when the underlying module's response shape doesn't
match `DecodeResult` exactly):
```ts
import { createWasmCodec } from "@suaveplan/audio-codecs/codecs/wasm-base";
export const SnakeoilCodec = createWasmCodec({
name: "snakeoil",
extensions: ["snkl"],
mimeTypes: ["audio/x-snakeoil"],
module: "@internal/snakeoil-decoder",
magic: (bytes) =>
bytes.length >= 8 &&
bytes[0] === 0x53 &&
bytes[1] === 0x4e &&
bytes[2] === 0x4b &&
bytes[3] === 0x4c,
supportsEncode: true,
load: async () => {
const mod = await import("@internal/snakeoil-decoder");
// Module exposes `decodeBuffer` / `encodeBuffer` not `decode` / `encode`.
// Adapt at the boundary so wasm-base's contract stays satisfied.
return {
decode: async (bytes) => {
const raw = await mod.decodeBuffer(bytes);
return {
channelData: raw.channels,
sampleRate: raw.sampleRate,
format: "snakeoil",
};
},
encode: async (channelData, opts) =>
mod.encodeBuffer(channelData, opts?.bitDepth ?? 16),
};
},
});
```
## API Reference
- `interface WasmCodecModule` — the shape that loaded modules must
conform to: `decode?`, `encode?`, `canDecode?`. The factory tolerates
modules that expose only a subset (e.g. decode-only) — the
`supportsEncode` flag in `WasmCodecSpec` is the authoritative gate.
- `interface WasmCodecSpec` — `name`, `extensions`, `mimeTypes`,
`module` (the bare specifier string for diagnostics), `magic`
(`(bytes) => boolean`), `supportsEncode` (`boolean`), `load`
(`() => Promise<WasmCodecModule>`).
- `function createWasmCodec(spec: WasmCodecSpec): AudioCodec` —
closure-captures `spec` and returns a frozen `AudioCodec` with
lazy-loaded behaviour and the documented error-wrapping policy.
## Implementation Notes
- The `load` closure stores the promise (not the resolved module) so
parallel `decode` calls during initial load all share the same
promise — no double-load race.
- Errors from `spec.load()` are caught once and re-thrown as
`NOT_SUPPORTED`; subsequent calls reuse the cached rejected promise
so the same error surfaces on every retry without re-importing.
- The `decode` and `encode` methods always re-wrap non-API errors;
`AudioCodecsError` instances pass through. This means downstream
callers can rely on a consistent error type from every WASM codec.
- The factory does not eagerly probe `spec.module` for availability —
importing the package barrel pulls in only the factory, not any WASM
modules. WASM is loaded on first decode/encode call, not at module
import time, matching the spec's `Subpath exports per feature
module` and `WASM-backed codecs loaded on demand` requirements.
- Encode paths short-circuit when `supportsEncode === false` so a
decode-only codec never invokes its loader for an encode call.

View File

@@ -0,0 +1,150 @@
# `codecs/wav`
Native pure-JS WAV codec implementing the full `AudioCodec` contract —
decode, encode, and both streaming factories — without any external
dependency.
## Purpose
WAV is the round-trip canonical format for `audio-codecs`. Every other
codec is allowed to be decode-only or to delegate to a WASM module; the
WAV codec is the one path callers can rely on to be present, complete,
and dependency-free. It also serves as the worked example of the
`AudioCodec` plugin contract for downstream integrators.
The implementation is a minimal RIFF parser plus a sample converter that
handles every common WAV sample format: 8-bit unsigned PCM, 16-bit
signed PCM, 24-bit signed PCM, 32-bit signed PCM, and 32-bit IEEE 754
float. Decoding normalises every format into `Float32Array[]` in `[-1, 1]`;
encoding inverts the conversion respecting the requested bit depth and
storage format (PCM int vs IEEE float).
## Features
- `WavCodec.canDecode(bytes)` — magic-byte sniff: returns `true` iff the
buffer is at least 12 bytes and starts with `RIFF` … `WAVE`.
- `WavCodec.decode(bytes)` — parses the RIFF header, walks the chunk list
for `fmt ` and `data`, decodes the PCM, and returns a `DecodeResult`
with `channelData`, `sampleRate`, and `format: "wav"`.
- `WavCodec.encode(channelData, opts?)` — emits a complete RIFF WAV from
PCM channels at the requested `sampleRate`, `bitDepth`, and
`format: "pcm" | "float"`. Defaults: 44_100 Hz, 16-bit PCM.
- `WavCodec.createDecoder()` — streaming decoder that buffers incoming
chunks until the header is fully parsed, then emits one
`DecodeResult` per `write()` call that brings new full frames.
- `WavCodec.createEncoder(opts?)` — streaming encoder that records sample
rate + channel count at `start()`, accumulates PCM blocks via
`write()`, and writes the complete RIFF header followed by
concatenated samples at `flush()` once the total frame count is known.
## Usage: Basic Example
```ts
import { WavCodec } from "@suaveplan/audio-codecs/codecs/wav";
// decode bytes
const decoded = await WavCodec.decode(wavBytes);
console.log(decoded.sampleRate, decoded.channelData[0].length);
// re-encode at 24-bit PCM
const encoded = await WavCodec.encode(decoded.channelData, {
sampleRate: decoded.sampleRate,
bitDepth: 24,
format: "pcm",
});
```
## Usage: Advanced Example
Streaming decode of a chunked WAV byte source:
```ts
import { WavCodec } from "@suaveplan/audio-codecs/codecs/wav";
const decoder = WavCodec.createDecoder!();
const pcm: Float32Array[] = [];
for await (const chunk of fetchAudio()) {
for (const result of decoder.write(chunk)) {
for (let c = 0; c < result.channelData.length; c++) {
pcm[c] ??= new Float32Array(0);
const merged = new Float32Array(pcm[c]!.length + result.channelData[c]!.length);
merged.set(pcm[c]!, 0);
merged.set(result.channelData[c]!, pcm[c]!.length);
pcm[c] = merged;
}
}
}
for (const tail of decoder.flush()) {
// append remaining
}
decoder.close();
```
Streaming encode against a custom PCM block source:
```ts
const encoder = WavCodec.createEncoder!({
sampleRate: 48_000,
bitDepth: 32,
format: "float",
});
const header = encoder.start(48_000, 2);
yield header; // empty for WAV — header is written at flush
for (const block of pcmBlocks) {
yield encoder.write(block);
}
yield encoder.flush();
encoder.close();
```
## API Reference
- `WavCodec: AudioCodec` — frozen constant; `name === "wav"`, `extensions
=== ["wav", "wave"]`, `mimeTypes === ["audio/wav", "audio/wave",
"audio/x-wav"]`.
- `canDecode(bytes: Uint8Array): boolean`
- `decode(bytes: Uint8Array, opts?: DecodeOpts): Promise<DecodeResult>` —
throws `AudioCodecsError("AUDIO_CODECS.DECODE_FAILED")` on bad magic,
missing `fmt ` chunk, missing `data` chunk, truncated `data`, fmt size
< 16, or unsupported `bitsPerSample` (`bps === 0` or `bps > 4`).
- `encode(channelData, opts?: EncodeOpts): Promise<Uint8Array>` — throws
`AudioCodecsError("AUDIO_CODECS.ENCODE_FAILED")` if `channelData` is
empty. Defaults: `sampleRate: 44_100`, `bitDepth: 16`, integer PCM.
- `createDecoder(opts?: DecodeOpts): StreamingDecoder`
- `createEncoder(opts?: EncodeOpts): StreamingEncoder` — the encoder
buffers all body bytes until `flush()` because the WAV header carries
the total `data` chunk size as a uint32, which is only known at the
end of the stream.
## Implementation Notes
- The RIFF chunk walker tolerates and skips any chunk whose ID is neither
`fmt ` nor `data` — `LIST`, `bext`, `iXML`, and other metadata chunks
are passed over silently. This matches the behaviour every real WAV
consumer expects.
- 24-bit PCM uses three signed-magnitude bytes per sample in
little-endian order; the decoder reads them via two unsigned bytes
and one signed byte to preserve the sign bit cleanly without
bit-twiddling intermediates that could lose precision on edge
values.
- The normalisation factor is `(1 << (bitsPerSample - 1)) >>> 0` — the
unsigned-shift forces a `number` rather than a 32-bit signed integer
for the 32-bit PCM case where `1 << 31` would otherwise be negative.
- Encode clamps every sample to `[-1, 1]` via `Math.max(-1, Math.min(1,
sample))` and rounds via `Math.round` to the nearest integer for the
three integer bit depths. The 32-bit float path writes the sample
directly — clamping is the caller's responsibility for float storage.
- The streaming encoder's `start()` returns an empty `Uint8Array` because
the RIFF header includes a `data` chunk size field whose value is the
total written sample bytes; that value is only known at `flush()`
time. Accordingly the encoder buffers every `write()` output into a
chunk array and emits one concatenated payload (header + body) at
`flush()`. Memory cost is the full PCM size — callers needing constant
memory streaming should use a different format (e.g. raw float headers
or Opus).
- The streaming decoder's `lenient: true` flag on the shared
`parseHeader` helper accepts an under-sized buffer (where the declared
`data` chunk extends past the bytes received so far). The non-streaming
`decode` path passes the default `lenient: false` so it still validates
total payload size.

View File

@@ -0,0 +1,74 @@
# `codecs/webm`
Lazy WebM-audio codec — decode-only wrapper over `@audio/decode-webm`
WASM module loaded on first use.
## Purpose
WebM is an open container format used primarily for video on the web,
but with an audio-only profile that wraps Opus or Vorbis payloads in
the EBML matroska-derived structure. This codec extracts the audio
track from WebM containers and decodes it to PCM.
The codec is decode-only because WebM-audio is almost always a
*receive* format (downloaded from a server), not something callers
need to emit. The canonical audio encode targets are WAV,
FLAC, and Opus. Implementation delegates to
[`createWasmCodec`](./wasm-base.md) for the shared lazy-load and
error-wrapping behaviour.
## Features
- `name === "webm"`, extensions `["webm"]`, MIME types
`["audio/webm"]`.
- `canDecode(bytes)` — magic-byte sniff for the EBML header
`0x1a 0x45 0xdf 0xa3` at byte 0; rejects buffers shorter than 4
bytes.
- Decode supported via `@audio/decode-webm`.
- `supportsEncode: false` — encode throws `NOT_SUPPORTED`.
- Streaming factory absent.
## Usage: Basic Example
```ts
import { WebMCodec } from "@suaveplan/audio-codecs/codecs/webm";
const decoded = await WebMCodec.decode(webmBytes);
```
## Usage: Advanced Example
```ts
import { CodecRegistry, WebMCodec, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WebMCodec, WavCodec] });
// Extract audio from a WebM upload and re-encode as WAV for analysis.
const webmDoc = await registry.decode(webmBytes);
const wavBytes = await registry.encode(webmDoc, "wav", { bitDepth: 24 });
```
## API Reference
- `WebMCodec: AudioCodec` — frozen constant; decode-only.
- `canDecode(bytes)`, `decode(bytes, opts?)` — standard `AudioCodec`
contract.
- `encode()` — always throws `AUDIO_CODECS.NOT_SUPPORTED`.
## Implementation Notes
- WebM uses EBML, not RIFF, so the magic-byte signature is
fundamentally different from other audio containers. The four-byte
EBML header is a hard signal — false positives are rare in
practice.
- The codec returns PCM regardless of whether the underlying audio
track is Opus or Vorbis — `@audio/decode-webm` handles the inner
codec dispatch.
- WebM containers commonly carry video alongside audio; this codec
decodes only the audio track. Video handling is out of scope and
belongs to a separate `@suaveplan/video-*` package family.
- Decode-only by design; first decode lazily loads
`@audio/decode-webm`. Subsequent calls reuse the memoised module.
- Missing `@audio/decode-webm` → `AUDIO_CODECS.NOT_SUPPORTED`.
- Streaming factory absent — `decodeStream("webm", …)` throws
`AUDIO_CODECS.STREAM_ERROR`.

View File

@@ -0,0 +1,123 @@
# `decode/decode-source`
Universal decode entry point — the single function callers use to turn
any `Source` into a decoded `AudioDocument`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`decodeSource` is the top-level public-facing decode API. Callers do
not need to know about codec registries, format detection, or
runtime-gated source resolution — they pass in a `Source` (string,
URL, ArrayBuffer, Uint8Array, or ReadableStream) plus optional
hints/callbacks, and receive a fully-decoded `AudioDocument` from the
default registry (which ships every native + WASM codec).
The companion `progressiveDecode` provides the same surface but is the
hook for future per-chunk-fed decoders that allow editing the
document while decoding is still in progress. The current
implementation falls back to a full-buffer decode for every codec
because none of the native or WASM wrappers expose
`createDecoder` factories yet — the contract is in place so a future
streaming-aware codec can plug in without callers changing their
code.
## Features
- Accepts any `Source` variant — bytes, URL, file path, stream.
- Optional `opts.format` overrides detection (use when callers know
the format and want to skip the magic-byte sniff).
- Optional `opts.budget` projects required memory before allocating
pages — if projection exceeds the budget the call aborts with
`AUDIO_CODECS.BUDGET_EXCEEDED` carrying the projected and budgeted
byte counts in `context`.
- Optional `opts.signal` (AbortSignal) — checked before resolution,
before format detection, and before page allocation. Aborts throw
`AUDIO_CODECS.STREAM_ERROR`.
- Optional `opts.notify()` fires once the first page is materialised;
`opts.ondata(delta)` fires per decoded block delta. Useful for
progress UI on long-running decodes.
- Optional `opts.registry` lets callers inject a pre-configured
`CodecRegistry` (e.g. with extra codecs) instead of using the
default.
- Zod-validated options — invalid `budget` (non-positive number) or
any other schema violation throws `AUDIO_CODECS.INVALID_SOURCE` with
the Zod issue list in `context`.
## Usage: Basic Example
```ts
import { decodeSource } from "@suaveplan/audio-codecs/decode/decode-source";
const doc = await decodeSource(wavBytes);
console.log(doc.sourceLength, doc.sampleRate);
```
## Usage: Advanced Example
Decode a stream with a memory budget, abort signal, and progress
callbacks:
```ts
import { decodeSource } from "@suaveplan/audio-codecs/decode/decode-source";
const ctrl = new AbortController();
const ui = makeProgressUi();
const doc = await decodeSource(
fetch("https://cdn/audio.flac").then((r) => r.body!),
{
budget: 500 * 1024 * 1024, // 500 MB cap
signal: ctrl.signal,
notify: () => ui.startedDecoding(),
ondata: ({ samples }) => ui.updateProgress(samples),
}
);
```
## API Reference
- `interface DecodeSourceOpts { format?, budget?, signal?, notify?,
ondata?, registry? }`.
- `const DecodeSourceOptsSchema` — Zod schema for the options object.
- `async function decodeSource(source: Source, opts?:
DecodeSourceOpts): Promise<AudioDocument>` — throws
`INVALID_SOURCE` for invalid options or unrecognised source values;
`UNKNOWN_FORMAT` if detection finds no codec and `opts.format` is
also missing; `BUDGET_EXCEEDED` if projected memory exceeds
`opts.budget`; `STREAM_ERROR` for AbortSignal cancellation.
- `async function progressiveDecode(source, opts?, registry?):
Promise<AudioDocument>` — same surface as `decodeSource` plus an
optional positional `registry` for callers that prefer not to pass
it via `opts.registry`.
- `re-export CodecRegistry` — convenience so consumers needing custom
codec registration can import it from the same subpath.
## Implementation Notes
- The default registry is constructed lazily via a top-level promise
(`DEFAULT_REGISTRY_PROMISE`) so the side-effects of registering ten
codecs only happen the first time the registry is needed. Imports
of the package barrel itself don't trigger codec registration.
- `opts.signal` is checked at every coarse-grained boundary —
before resolution, after registry availability, before detection.
Codec-internal aborts are codec-specific and not covered by this
function.
- The budget projection uses `sampleRate * channels * sampleCount * 4`
(4 bytes per float32 sample) as an upper bound. For compressed
sources the actual decoded size depends on the codec's expansion
ratio; the projection assumes a conservative 5× expansion of input
bytes which catches the common cases (MP3, FLAC, Opus all
decompress within 5×) without false positives for raw WAV.
- `progressiveDecode` is currently equivalent to `decodeSource` —
the streaming-page-fill behaviour described in the spec
("editable before decode completes") requires codec `createDecoder`
factories which only WAV currently exposes. The function name and
signature reserve the contract so future streaming-aware codecs
drop in without API changes.
- Both functions are async — even when the input is fully in-memory,
format detection and codec dispatch involve at least one microtask
boundary because the codec methods are async.

View File

@@ -0,0 +1,131 @@
# `detect/detect-format`
Format detection and source-resolution helpers — the runtime-gated entry
point that turns any `Source` (string path, URL, ArrayBuffer, Uint8Array,
ReadableStream) into a header slice plus an optional detected format
name.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The package handles five concrete `Source` shapes; each needs a
different platform-specific resolution strategy. Without a single
helper, every public entry point (`decodeSource`, `encodeTo`,
`progressiveDecode`) would re-implement the same `string | URL |
ArrayBuffer | Uint8Array | ReadableStream` switch — duplicating wiring
and risking divergent behaviour for the edge cases. This module
centralises it.
The detection delegates magic-byte sniffing to
`@suaveplan/audio-metadata`'s `detectAudioFormat` — the same function
used by every other package that needs to identify audio bytes
— so the registry's `detect` and the entry-point `detectSource` always
agree.
## Features
- `Source` type union — `string | URL | ArrayBuffer | Uint8Array |
ReadableStream<Uint8Array>`.
- `DetectResult` shape — `{ format: string | undefined; bytes?:
Uint8Array; path?: string; reader?: ReadableStreamDefaultReader }`.
The optional fields tell the caller what kind of resolution happened
so they can continue draining the source efficiently.
- `detectSource(source)` — reads at most `HEADER_SIZE` (256 KiB) bytes
from the source head, sniffs the format, and returns either the
detected format name or `undefined` for unknown content. Never
throws for unknown content — only throws `INVALID_SOURCE` for a
source value that is not a recognised variant or a fetch that
returned no body.
- `resolveSource(source)` — drains the source completely into a single
`Uint8Array`. Used by `decodeSource` to materialise the full payload
before dispatch.
- `HEADER_SIZE` — exported constant set to `256 * 1024`. Reading
exactly this many bytes keeps detection cheap even for multi-GB
files while being large enough to cover every realistic header
layout (ID3v2 with embedded artwork, AIFF with large COMM-adjacent
comment chunks, etc).
## Resolution rules
- `string` starting with `http://` / `https://` / `data:` / `blob:`,
or any `URL` instance → resolved via `fetch()`. Header read via the
response stream reader, which is returned to the caller for
continued draining.
- `string` starting with `file://` → resolved via dynamic
`import('node:fs/promises')`. Returns the resolved file path so the
caller can perform random-access reads.
- Other bare `string` → on a server runtime (process.versions.node is
a string) resolves via `fs/promises.readFile`; in a browser runtime
resolves via `fetch()`.
- `ArrayBuffer` / `Uint8Array` → header is the first 256 KiB slice;
no platform call.
- `ReadableStream<Uint8Array>` → reader drains until 256 KiB observed;
the original reader is returned for continued draining.
## Usage: Basic Example
```ts
import { detectSource } from "@suaveplan/audio-codecs/detect/detect-format";
const result = await detectSource(bytes);
if (result.format === "wav") {
decodeWav(result.bytes!);
}
```
## Usage: Advanced Example
Resolve a remote URL once and reuse the detection result + stream
reader without re-fetching:
```ts
import { detectSource, resolveSource } from "@suaveplan/audio-codecs/detect/detect-format";
const result = await detectSource("https://cdn.example/audio.flac");
if (result.format === "flac") {
// We have the first 256 KiB cached on result.bytes, and the rest
// is available via result.reader.
const rest = await drainReader(result.reader!);
const full = concat(result.bytes!, rest);
return decodeFlac(full);
}
// resolveSource fully drains regardless of source shape.
const buf = await resolveSource("file:///srv/audio/in.wav");
```
## API Reference
- `type Source = string | URL | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>`
- `interface DetectResult { format: string | undefined; bytes?: Uint8Array; path?: string; reader?: ReadableStreamDefaultReader<Uint8Array> }`
- `const HEADER_SIZE: number` — `256 * 1024`
- `async function detectSource(source: Source): Promise<DetectResult>` —
throws `AUDIO_CODECS.INVALID_SOURCE` for an unrecognised source
value or when `fetch()` returns a body-less response.
- `async function resolveSource(source: Source): Promise<Uint8Array>` —
throws `AUDIO_CODECS.INVALID_SOURCE` for an unrecognised source.
## Implementation Notes
- `isServerRuntime()` is a closure over `typeof process !== "undefined"
&& typeof process.versions?.node === "string"`. The function exists
so the runtime branch is not evaluated at module-load time — the
package stays universal with no static `node:fs` import.
- The `node:fs/promises` import is **always** inside a runtime-gated
dynamic `import()` — never a top-level import. This keeps the package
importable from browser bundles without bundler config tweaks.
- The browser-only `fetch()` fallback paths (lines 154-156 in
`detectSource`, 214-216 in `resolveSource`) are unreachable under
Node tests; structural coverage of that branch requires a happy-dom
or browser-target test harness (a future integration suite).
- The `URL` instance branch and the `http://` / `https://` /
`data:` / `blob:` string branch both route through
`fetchHeaderViaFetch` — no logic is duplicated.
- `readHeaderFromStream` keeps reading until at least `HEADER_SIZE`
bytes are observed OR the stream ends. The resulting buffer may
be larger than `HEADER_SIZE` because chunks arrive whole; that's
intentional — over-read is cheap and avoids fragmenting the
caller's understanding of what bytes were consumed.

View File

@@ -0,0 +1,128 @@
# `encode/encode-to`
Universal encode entry point — encode an `AudioDocument` to bytes and
write them to any `Destination` (file path, WritableStream, or
callback).
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`encodeTo` is the top-level public-facing encode API. Callers do not
need to wire codec dispatch + destination plumbing manually — they
pass in a document, a format name, and a destination handle, and the
function takes care of routing through `CodecRegistry.encode` and
then writing the resulting bytes to the destination via the
appropriate platform call.
## Features
- Accepts three `Destination` variants:
- `string` file path → resolved via dynamic
`import('node:fs/promises')` for `writeFile`. Runtime-gated so
the import never appears statically.
- `WritableStream<Uint8Array>` → write via the stream's writer,
closes the stream after writing.
- `(chunk: Uint8Array) => void` callback → single invocation with
the full payload.
- Optional `opts.format: "pcm" | "float"` and `opts.bitDepth: 8 | 16
| 24 | 32` for codecs that support multiple sample formats (WAV).
- Optional `opts.sampleRate` override — used when the encode pipeline
needs to retarget a different rate than the document's natural
rate.
- Optional `opts.signal` (AbortSignal) — checked before encode
dispatch. Pre-aborted signals throw `AUDIO_CODECS.STREAM_ERROR`.
- Optional `opts.registry` to inject a pre-configured registry.
Defaults to a fresh `CodecRegistry()` — callers that need WAV/AIFF/
CAF or WASM codecs available should pass in the registry from
`decode-source.ts` or construct one with the codecs they need.
- Zod-validated options — invalid `sampleRate`/`bitDepth`/`format`
throws `AUDIO_CODECS.INVALID_SOURCE`.
- Encoder errors are wrapped — codec-specific failures surface as
`AUDIO_CODECS.ENCODE_FAILED` with the original error on `cause`.
- Invalid destination (not a path/stream/callback) throws
`AUDIO_CODECS.INVALID_SOURCE`.
## Usage: Basic Example
```ts
import { CodecRegistry, encodeTo, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec] });
await encodeTo(doc, "/tmp/out.wav", "wav", {
registry,
bitDepth: 16,
format: "pcm",
});
```
## Usage: Advanced Example
Encode to a streaming destination (e.g. an HTTP response body) with
abort cancellation:
```ts
import { CodecRegistry, encodeTo, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec] });
const ctrl = new AbortController();
const responseStream = new WritableStream<Uint8Array>({
write(chunk) {
sendOverWebSocket(chunk);
},
});
await encodeTo(doc, responseStream, "wav", {
registry,
bitDepth: 24,
format: "pcm",
signal: ctrl.signal,
});
// Or write via callback for fine-grained control:
let captured: Uint8Array | undefined;
await encodeTo(doc, (chunk) => { captured = chunk; }, "wav", { registry });
```
## API Reference
- `type Destination = string | WritableStream<Uint8Array> | ((chunk: Uint8Array) => void)`
- `interface EncodeToOpts { sampleRate?, bitDepth?, format?, signal?, registry? }`
- `const EncodeToOptsSchema` — Zod schema validating
`sampleRate`, `bitDepth`, and `format`.
- `async function encodeTo(doc: AudioDocument, destination: Destination,
format: string, opts?: EncodeToOpts): Promise<void>` — throws
`INVALID_SOURCE` for invalid options or unrecognised destination,
`STREAM_ERROR` for pre-aborted signals, `NOT_SUPPORTED` for unknown
formats, `ENCODE_FAILED` for codec errors.
## Implementation Notes
- The file-path destination uses dynamic `import('node:fs/promises')`
to keep the package universal. Browser callers must use the
`WritableStream` or callback destination — passing a string path
in a browser would fail with `INVALID_SOURCE` via the dynamic
import failure, which then surfaces as `INVALID_SOURCE` at the
outer boundary.
- The `WritableStream` path acquires a writer, writes one chunk, then
closes via `writer.close()`. Callers that need to keep the stream
open for additional writes should use a callback destination
instead — the closed-after-one-write semantics matches the
single-payload encode model.
- The callback destination receives the full encoded buffer in one
invocation. For per-chunk encode (where bytes arrive as encoding
progresses), use `CodecRegistry.encodeStream` directly instead of
`encodeTo`.
- Codec error wrapping preserves the original via `cause`; callers
can recover the underlying error via
`(err as AudioCodecsError).cause`.
- `opts.signal` is checked only before dispatch — codec internal
abort during encode requires the codec to honour the signal,
which most current codecs (WAV native, WASM wrappers) do not.
Mid-encode cancellation will not interrupt the codec call but
will surface as a thrown `STREAM_ERROR` from any subsequent
`encodeTo` call against the same controller.

View File

@@ -0,0 +1,129 @@
# `error-codes`
Stable error-code registry for `@suaveplan/audio-codecs`. Every fault raised
by the package is an `AudioCodecsError` carrying one of seven codes from this
registry plus an optional `cause` and `context`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`audio-codecs` is a public, plugin-driven boundary: callers hand it arbitrary
bytes, files, URLs, or streams and expect a single stable contract on
failure. Without a frozen code namespace, every codec implementation would be
free to throw its own bespoke error shape and consumers would have to
discriminate by message string — which rots the moment a codec rephrases
itself.
This module is the contract. Every code is declared via `defineCodes`, every
code has a description and severity in `AUDIO_CODECS_ERROR_METADATA`, and
every code is asserted reachable from a call site by
`src/error-codes.test.ts`. The full namespaced values
(`AUDIO_CODECS.UNKNOWN_FORMAT`, etc.) are stable forever: renaming one is a
breaking change that warrants its own OpenSpec proposal.
## Features
- Seven stable codes covering detection, decode, encode, streaming, support,
source-validation, and memory-budget faults.
- Frozen `AUDIO_CODECS_ERROR_METADATA` describing every code's purpose and
severity (`"error"` for hard faults, `"warning"` for AbortSignal / stream-
termination scenarios).
- `AudioCodecsError extends SuaveplanError<AudioCodecsErrorCode>` with a
public constructor, a stable `category = "audio-codecs"` discriminator,
and `cause` / `context` propagation from the base class.
- Idempotent `registerAudioCodecsErrorCodes()` hook for downstream OTel
attribute binders that want a guaranteed touch point.
## Usage: Basic Example
```ts
import {
AUDIO_CODECS_CODES,
AudioCodecsError,
} from "@suaveplan/audio-codecs/error-codes";
if (!codec) {
throw new AudioCodecsError(
AUDIO_CODECS_CODES.UNKNOWN_FORMAT,
"audio-codecs.detection.unknown",
{ context: { sourceKind: "buffer" } }
);
}
```
Consumers catch by `instanceof AudioCodecsError` and discriminate on the
stable `code` field — never on the message text, which is localisation-bound
through the `SuaveplanError` `MessageResolver` hook.
## Usage: Advanced Example
Wiring codes into a structured reporter:
```ts
import {
AUDIO_CODECS_CODES,
AUDIO_CODECS_ERROR_METADATA,
AudioCodecsError,
type AudioCodecsErrorCode,
} from "@suaveplan/audio-codecs/error-codes";
function reportCodecFailure(err: unknown, span: import("@opentelemetry/api").Span): void {
if (!(err instanceof AudioCodecsError)) {
span.recordException(err as Error);
return;
}
const meta = AUDIO_CODECS_ERROR_METADATA[err.code as AudioCodecsErrorCode];
span.setStatus({ code: 2 /* ERROR */ });
span.setAttribute("audio_codec.error_code", err.code);
span.setAttribute("audio_codec.error_severity", meta.severity);
if (err.cause instanceof Error) span.recordException(err.cause);
}
// `BUDGET_EXCEEDED` and `STREAM_ERROR` are first-class outcomes a planning
// stage can check before invoking decode — they aren't only thrown.
if (projection.bytes > budget) {
throw new AudioCodecsError(
AUDIO_CODECS_CODES.BUDGET_EXCEEDED,
"audio-codecs.decode.budget-exceeded",
{ context: { projectedBytes: projection.bytes, budgetBytes: budget } }
);
}
```
## API Reference
- `AUDIO_CODECS_CODES` — frozen record of namespaced codes; access as
`AUDIO_CODECS_CODES.DECODE_FAILED` and the value is
`"AUDIO_CODECS.DECODE_FAILED"`.
- `AudioCodecsErrorCode` — union type of every registry value.
- `AudioCodecsErrorMetadata` — `{ description: string; severity: "warning" |
"error" }`.
- `AUDIO_CODECS_ERROR_METADATA: Readonly<Record<AudioCodecsErrorCode,
AudioCodecsErrorMetadata>>` — frozen lookup keyed by code.
- `AudioCodecsError` — constructor signature
`new AudioCodecsError(code, messageKey, options?)` where `options` is the
shared `SuaveplanErrorOptions` (`cause`, `context`, `traceId`).
- `registerAudioCodecsErrorCodes()` — idempotent no-op hook.
## Implementation Notes
- The seven codes are deliberately narrow: every codec failure maps to one of
them. Adapters that need finer granularity preserve the original error via
`cause` and add provider-specific keys to `context` — they do not add new
codes here.
- `AUDIO_CODECS.STREAM_ERROR` carries `severity: "warning"` because the most
common cause is caller-initiated `AbortSignal` cancellation, which is a
predictable cooperative termination rather than a fault. Genuine streaming
faults (codec stream stalls, unexpected EOF mid-chunk) still use this code
but elevate severity at the reporting layer via `cause`.
- The class carries a `readonly category = "audio-codecs"` literal so the
base `SuaveplanError` constructor widening is non-trivial (avoids
`noUselessConstructor` lint without a suppression comment).
- The registration hook is intentionally a no-op: the metadata record is
declared frozen at module evaluation time, so there is no side-effect to
defer. Keeping the hook exported preserves the same shape as sibling
audio packages, so a bulk registrar (`audio-core`, `audio-controllers`,
`audio-codecs`, etc.) can iterate them uniformly at startup.

View File

@@ -0,0 +1,141 @@
# `registry/registry`
In-memory plugin registry that owns audio-codec lookup and dispatch for the
whole package.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CodecRegistry` is the single entry point through which `decodeSource`,
`encodeTo`, `progressiveDecode`, and the streaming transform factories
resolve a codec from raw bytes, a format hint, an extension, or a MIME type
and then dispatch decode/encode operations to it. Without a single registry,
every public entry point would re-implement the lookup + dispatch + error
mapping triad — duplicating wiring and risking divergence.
The registry also owns the package's OpenTelemetry instrumentation surface
for decode/encode calls (`codec.decode` / `codec.encode` spans, the
`audio_codec_*` counter and histogram pair). Codecs themselves stay free of
OTel concerns; the registry observes the boundary.
## Features
- Three lookup indexes — by canonical `name`, by file extension, by MIME
type. `get(key)` checks all three so callers do not need to know which
shape they hold.
- Eager initial registration via `new CodecRegistry({ codecs: [...] })` plus
imperative `register(codec)` / `unregister(name)`.
- `detect(bytes)` walks every registered codec's `canDecode` in
registration order and returns the first match's `name` (or `undefined`).
- `decode(bytes, opts?)` resolves the codec, awaits its `decode`, and builds
an `AudioDocument` from the resulting `Float32Array[]` PCM. Non-API codec
errors are wrapped in `AudioCodecsError("AUDIO_CODECS.DECODE_FAILED")`
with the original on `cause`; `AudioCodecsError` instances pass through.
- `encode(doc, format, opts?)` materialises the document's PCM, dispatches
to the codec registered for `format`, and returns the encoded bytes.
Unknown format → `AUDIO_CODECS.NOT_SUPPORTED`. Non-API encode error →
`AUDIO_CODECS.ENCODE_FAILED` with `cause`.
- `decodeStream(source, format)` returns a `ReadableStream<AudioPage>` whose
pull loop drives the codec's `createDecoder()` streaming factory.
Codecs without that factory throw `AUDIO_CODECS.STREAM_ERROR`.
- `encodeStream(doc, format)` returns a `ReadableStream<Uint8Array>` whose
pull loop feeds the document's pages (sorted by `startSample`) into the
codec's `createEncoder()`.
- `list()` returns every registered codec for introspection.
- Zod-validated codec registration via `AudioCodecSchema` — a malformed
codec object is rejected at `register()` with `INVALID_SOURCE`.
- OTel spans, counter, and duration histogram on every successful decode
and encode; spans carry the resolved `format` as an attribute.
## Usage: Basic Example
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs/registry/registry";
import { WavCodec } from "@suaveplan/audio-codecs/codecs/wav";
const registry = new CodecRegistry({ codecs: [WavCodec] });
// decode WAV bytes into an AudioDocument
const doc = await registry.decode(wavBytes);
// re-encode back to WAV at 24-bit PCM
const wav = await registry.encode(doc, "wav", { bitDepth: 24, format: "pcm" });
```
## Usage: Advanced Example
Hot-swap a codec and pipeline a streaming decode into a custom transform:
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs/registry/registry";
import { Mp3Codec, WavCodec, FlacCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec, Mp3Codec] });
registry.register(FlacCodec); // additive — now decodes FLAC too
const sourceStream: ReadableStream<Uint8Array> = await fetchAudio();
const pageStream = registry.decodeStream(sourceStream, "mp3");
// pipe through a downstream normaliser (defined elsewhere)
const normalised = pageStream.pipeThrough(makeNormaliser());
for await (const page of normalised) {
consumePage(page);
}
// detect-then-dispatch for unknown bytes
const fmt = registry.detect(unknownBytes);
if (fmt) {
const doc = await registry.decode(unknownBytes, { format: fmt });
useDoc(doc);
}
```
## API Reference
- `class CodecRegistry`
- `constructor(options?: CodecRegistryOptions)` — `options.codecs` are
each `register`-ed eagerly.
- `register(codec: AudioCodec): void` — throws
`AudioCodecsError("AUDIO_CODECS.INVALID_SOURCE")` if `codec` fails
`AudioCodecSchema` validation. Replaces any prior codec sharing the
same `name`; extension and MIME indexes additively expand.
- `unregister(name: string): void` — no-op for unknown names; clears
matching extension and MIME entries.
- `get(nameOrExtOrMime: string): AudioCodec | undefined`
- `list(): readonly AudioCodec[]`
- `detect(bytes: Uint8Array): string | undefined`
- `decode(bytes, opts?): Promise<AudioDocument>` — `opts.format` is a hint
that wins over `canDecode` detection when supplied; `opts.metadata` is
forwarded onto the resulting `AudioDocument`.
- `encode(doc, format, opts?): Promise<Uint8Array>`
- `decodeStream(source, format): ReadableStream<AudioPage>`
- `encodeStream(doc, format): ReadableStream<Uint8Array>`
## Implementation Notes
- The three lookup indexes are kept independently — `unregister` walks both
extension and MIME maps and deletes only entries that still point at the
removed codec, so registering two codecs that share an extension and
unregistering one leaves the other intact.
- `detect` iteration order is the order codecs were registered. Callers
that need a different priority (e.g. AIFF before WAV when both
canDecode an ambiguous prefix) should re-`register` the higher-priority
codec last so its `canDecode` runs first.
- The `#instrumented` helper centralises span lifetime, counter, and
histogram observations. It catches any thrown error, sets the span
status to ERROR, records the exception, then re-throws — so the
outer-most caller still observes the original (or wrapped) error.
- `pcmFromDocument` walks the document's pages in their stored order, not
by `startSample`. For documents produced by `buildDocument` this is the
same; for hand-constructed documents the caller is responsible for
ordered insertion. `encodeStream` does sort by `startSample` explicitly
to handle reordering at the streaming boundary.
- The constants `TRACER`, `METER`, `DECODE_COUNTER`, `ENCODE_COUNTER`,
`DECODE_HIST`, `ENCODE_HIST` are acquired at module evaluation time but
the underlying OTel SDK is a no-op when no exporter is registered, so
importing the registry has no observable runtime cost in unconfigured
environments.

View File

@@ -0,0 +1,100 @@
# `stream/stream-decoder`
`TransformStream<Uint8Array, AudioPage>` factory — composes a chunked
byte stream into an `AudioPage` stream by driving a codec's
`StreamingDecoder`.
## Purpose
The `CodecRegistry.decodeStream` method returns a
`ReadableStream<AudioPage>`. That works when callers want to read
pages directly, but does not compose into `@suaveplan/streams`
pipelines because pipelines are built from `TransformStream` nodes,
not `ReadableStream` sinks. This module's `createDecodeTransform`
fills the gap — it returns a `TransformStream<Uint8Array, AudioPage>`
suitable for `pipeThrough()` between a raw byte source and a
downstream consumer.
## Features
- `createDecodeTransform(format, registry?)` — returns a
`TransformStream<Uint8Array, AudioPage>` where each input chunk
is fed to the codec's `StreamingDecoder` and each decoded
`DecodeResult` is emitted as one `AudioPage` on the output stream.
- Lazy codec resolution — the transform's `ensure()` helper picks
the codec and constructs its decoder on first chunk, not at
transform construction. This lets callers tee the stream or
build pipeline branches without paying for codec setup on
branches that never see data.
- Default registry — when `registry` is omitted, the transform
lazily loads `WavCodec`, `AiffCodec`, and `CafCodec` into a
shared cached registry. Callers needing WASM codecs must pass
their own registry.
- Error propagation — codec failures (unknown format,
no-streaming-factory, or any thrown error from `write` / `flush`)
surface as `controller.error(...)` with an `AudioCodecsError`
carrying the original on `cause`.
## Usage: Basic Example
```ts
import { createDecodeTransform } from "@suaveplan/audio-codecs/stream/stream-decoder";
import { CodecRegistry, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec] });
const transform = createDecodeTransform("wav", registry);
const upstream: ReadableStream<Uint8Array> = await fetchAudio();
const pages = upstream.pipeThrough(transform);
for await (const page of pages) {
consumePage(page);
}
```
## Usage: Advanced Example
Compose with a downstream `@suaveplan/streams` transform:
```ts
import { createDecodeTransform } from "@suaveplan/audio-codecs/stream/stream-decoder";
import { makeNormaliser, makeRingBuffer } from "./my-pipeline";
const decodeTx = createDecodeTransform("flac", myRegistry);
await fetchAudioStream()
.pipeThrough(decodeTx)
.pipeThrough(makeNormaliser({ targetLufs: -14 }))
.pipeThrough(makeRingBuffer({ capacity: 16 }))
.pipeTo(myDestinationSink);
```
## API Reference
- `function createDecodeTransform(format: string, registry?: CodecRegistry):
TransformStream<Uint8Array, AudioPage>` — produces a transform that
emits `AudioPage` per decoded block; throws / errors with
`AUDIO_CODECS.NOT_SUPPORTED` for unknown format or
`AUDIO_CODECS.STREAM_ERROR` for codecs without `createDecoder()`.
## Implementation Notes
- The `transform()` callback awaits `ensure()` then calls
`decoder.write(chunk)`, fanning out the resulting `DecodeResult[]`
into the output controller. Each `DecodeResult.channelData` array
is wrapped in fresh `Float32Array` copies so downstream consumers
cannot accidentally mutate the codec's internal buffers.
- `startSample` accumulates across pages so the emitted
`AudioPage` sequence forms a contiguous timeline.
- The `flush()` callback drains the codec's buffered tail via
`decoder.flush()`, emits any remaining pages, then closes the
decoder. Errors during flush are translated into
`AUDIO_CODECS.STREAM_ERROR` via `controller.error()`.
- Codec-thrown `AudioCodecsError` instances pass through unchanged;
non-API errors are wrapped with `STREAM_ERROR`.
- The lazy registry initialisation (`getDefaultRegistry`) currently
registers only the three native codecs (WAV/AIFF/CAF). Callers
needing MP3/FLAC/Opus/etc must build their own registry via
`new CodecRegistry({ codecs: [...] })` and pass it explicitly.
This choice keeps the default-registry path tree-shakeable when
callers don't use streaming.

View File

@@ -0,0 +1,110 @@
# `stream/stream-encoder`
`TransformStream<readonly Float32Array[], Uint8Array>` factory —
composes a PCM-block stream into encoded bytes by driving a codec's
`StreamingEncoder`.
## Purpose
The mirror of `stream-decoder` for the encode direction. Callers
building a `@suaveplan/streams` pipeline (decode → DSP → encode)
need a `TransformStream` node for the encode stage; this module
provides it.
## Features
- `createEncodeTransform(format, sampleRate, channels, registry?)` —
returns a `TransformStream<readonly Float32Array[], Uint8Array>`
where each input `Float32Array[]` chunk represents one block of
PCM (one `Float32Array` per channel, same length per channel) and
each output `Uint8Array` carries encoded bytes from the codec's
`StreamingEncoder`.
- Lazy codec resolution — the transform constructs the codec's
encoder on first chunk (or on flush if no chunks arrived). The
encoder's `start()` is called once to emit header bytes, then
`write()` for every chunk, then `flush()` to emit any trailer.
- Sample rate + channel count are captured at construction so the
encoder's `start()` can pre-compute its header without waiting
for the first chunk.
- Empty-source handling — even when no PCM chunks arrive, `flush()`
still calls `start()` once and emits the codec's trailer (the
44-byte WAV header for the WAV codec). Downstream consumers
always receive a structurally-valid file.
- Default registry — when `registry` is omitted, the transform
lazily loads `WavCodec` into a shared cached registry. Codecs
beyond WAV that support encode (FLAC, MP3, Opus, QOA) must be
passed explicitly via the `registry` parameter.
## Usage: Basic Example
```ts
import { createEncodeTransform } from "@suaveplan/audio-codecs/stream/stream-encoder";
import { CodecRegistry, WavCodec } from "@suaveplan/audio-codecs";
const registry = new CodecRegistry({ codecs: [WavCodec] });
const transform = createEncodeTransform("wav", 44_100, 2, registry);
const pcmStream = makePcmBlockStream();
const wavBytes = pcmStream.pipeThrough(transform);
await wavBytes.pipeTo(myFileSink);
```
## Usage: Advanced Example
Decode-then-encode pipeline (transcode FLAC → WAV via streaming):
```ts
import {
createDecodeTransform,
createEncodeTransform,
} from "@suaveplan/audio-codecs/stream/stream-encoder";
const decodeTx = createDecodeTransform("flac", registry);
const encodeTx = createEncodeTransform("wav", 44_100, 2, registry);
// The mid pipeline converts AudioPage into Float32Array[] form.
const pageToBlock = new TransformStream<AudioPage, readonly Float32Array[]>({
transform(page, controller) {
controller.enqueue(page.channelData);
},
});
await fetchAudio()
.pipeThrough(decodeTx)
.pipeThrough(pageToBlock)
.pipeThrough(encodeTx)
.pipeTo(myDestination);
```
## API Reference
- `function createEncodeTransform(format: string, sampleRate: number,
channels: number, registry?: CodecRegistry):
TransformStream<readonly Float32Array[], Uint8Array>` — produces
a transform that emits encoded byte chunks; errors with
`AUDIO_CODECS.NOT_SUPPORTED` for unknown formats or
`AUDIO_CODECS.STREAM_ERROR` for codecs without `createEncoder()`.
## Implementation Notes
- The `started` flag ensures the encoder's `start(sampleRate,
channels)` is invoked exactly once even when no chunks arrive
(the `flush()` path also checks the flag and calls `start()`
if needed before invoking `flush()`).
- WAV's `start()` returns an empty `Uint8Array` because the WAV
header includes the total data-chunk size which is only known at
flush time; the transform handles that by emitting zero-byte
chunks (skipped via the `byteLength > 0` check) so downstream
consumers don't see empty data.
- Codec-thrown `AudioCodecsError` instances pass through unchanged;
non-API errors from `write` or `flush` are wrapped with
`STREAM_ERROR`.
- The default registry registers only `WavCodec`. Encoding via
FLAC, MP3, Opus, or QOA requires the caller to construct a
registry with those codecs and pass it as the fourth argument.
This choice keeps the default-registry path tree-shakeable when
callers don't use streaming.
- `cancel()` on the underlying transform stream calls
`encoder.close()` to release any internal buffers — even
half-streamed encodes leak no resources on cancellation.