Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user