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`.