Initial Commit
This commit is contained in:
144
packages/media/audio/audio-codecs/README.md
Normal file
144
packages/media/audio/audio-codecs/README.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# @suaveplan/audio-codecs - Universal audio codec registry: decode/encode plugin loader with format detection, progressive streaming decode, native WAV/AIFF/CAF codecs, and lazy-loaded WASM codecs for MP3/FLAC/Ogg/Opus/AAC/WebM/QOA. Universal with runtime-gated node:fs access.
|
||||
|
||||
Universal audio codec registry: decode/encode plugin loader with
|
||||
magic-byte format detection, progressive streaming decode, native
|
||||
WAV/AIFF/CAF codecs, and lazy-loaded WASM codecs for
|
||||
MP3/FLAC/Ogg/Opus/AAC/WebM/QOA. Universal — no browser-only or
|
||||
Node-only APIs at module scope; `node:fs/promises` is reached only via
|
||||
runtime-gated dynamic `import()`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [aac](./src/codecs/aac.md) — Lazy AAC codec — decode-only wrapper over `@audio/decode-aac` WASM module loaded on first use.
|
||||
- [aiff](./src/codecs/aiff.md) — Native pure-JS AIFF (Audio Interchange File Format) decoder, decode-only.
|
||||
- [caf](./src/codecs/caf.md) — Native pure-JS CAF (Apple Core Audio Format) decoder, decode-only.
|
||||
- [flac](./src/codecs/flac.md) — Lazy FLAC codec — thin wrapper over `@audio/decode-flac` WASM module loaded on first use.
|
||||
- [mp3](./src/codecs/mp3.md) — Lazy MP3 codec — thin wrapper that defers decode/encode to the `@audio/decode-mp3` WASM module loaded via dynamic `impo…
|
||||
- [ogg-vorbis](./src/codecs/ogg-vorbis.md) — Lazy Ogg Vorbis codec — decode-only wrapper over `@audio/decode-ogg-vorbis` WASM module loaded on first use.
|
||||
- [opus](./src/codecs/opus.md) — Lazy Opus codec — wrapper over `@audio/decode-opus` WASM module loaded on first use.
|
||||
- [qoa](./src/codecs/qoa.md) — Lazy QOA (Quite OK Audio) codec — wrapper over `@audio/decode-qoa` WASM module loaded on first use.
|
||||
- [wasm-base](./src/codecs/wasm-base.md) — Shared factory for the seven lazy WASM-backed codec wrappers.
|
||||
- [wav](./src/codecs/wav.md) — Native pure-JS WAV codec implementing the full `AudioCodec` contract — decode, encode, and both streaming factories — w…
|
||||
- [webm](./src/codecs/webm.md) — Lazy WebM-audio codec — decode-only wrapper over `@audio/decode-webm` WASM module loaded on first use.
|
||||
- [decode](./src/decode/decode-source.md) — Universal decode entry point — the single function callers use to turn any `Source` into a decoded `AudioDocument`.
|
||||
- [decode-source](./src/decode/decode-source.md) — Universal decode entry point — the single function callers use to turn any `Source` into a decoded `AudioDocument`.
|
||||
- [detect](./src/detect/detect-format.md) — Format detection and source-resolution helpers — the runtime-gated entry point that turns any `Source` (string path, UR…
|
||||
- [detect-format](./src/detect/detect-format.md) — Format detection and source-resolution helpers — the runtime-gated entry point that turns any `Source` (string path, UR…
|
||||
- [encode](./src/encode/encode-to.md) — Universal encode entry point — encode an `AudioDocument` to bytes and write them to any `Destination` (file path, Writa…
|
||||
- [encode-to](./src/encode/encode-to.md) — Universal encode entry point — encode an `AudioDocument` to bytes and write them to any `Destination` (file path, Writa…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/audio-codecs`. Every fault raised by the package is an `AudioCodecsError` ca…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/audio-codecs`. Every fault raised by the package is an `AudioCodecsError` ca…
|
||||
- [registry](./src/registry/registry.md) — In-memory plugin registry that owns audio-codec lookup and dispatch for the whole package.
|
||||
- [registry](./src/registry/registry.md) — In-memory plugin registry that owns audio-codec lookup and dispatch for the whole package.
|
||||
- [stream-decoder](./src/stream/stream-decoder.md) — `TransformStream<Uint8Array, AudioPage>` factory — composes a chunked byte stream into an `AudioPage` stream by driving…
|
||||
- [stream-encoder](./src/stream/stream-encoder.md) — `TransformStream<readonly Float32Array[], Uint8Array>` factory — composes a PCM-block stream into encoded bytes by driv…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @suaveplan/audio-codecs @suaveplan/audio-core
|
||||
```
|
||||
|
||||
WASM codecs are `optionalDependencies` — install only the ones you
|
||||
need:
|
||||
|
||||
```sh
|
||||
bun add @audio/decode-mp3 @audio/decode-flac @audio/decode-opus
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { decodeSource, encodeTo } from "@suaveplan/audio-codecs";
|
||||
|
||||
// Decode any source
|
||||
const doc = await decodeSource(wavBytes);
|
||||
|
||||
// Encode any document to any registered format
|
||||
await encodeTo(doc, "/tmp/out.wav", "wav", { bitDepth: 16, format: "pcm" });
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Purpose |
|
||||
| ------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `./registry/registry` | `CodecRegistry` — plugin lookup + dispatch + OTel instrumentation |
|
||||
| `./decode/decode-source` | `decodeSource`, `progressiveDecode` — universal decode entry |
|
||||
| `./encode/encode-to` | `encodeTo` — universal encode entry, writes to path/stream/callback |
|
||||
| `./detect/detect-format` | `detectSource`, `resolveSource` — runtime-gated source resolution |
|
||||
| `./stream/stream-decoder` | `createDecodeTransform` — `TransformStream<Uint8Array, AudioPage>` |
|
||||
| `./stream/stream-encoder` | `createEncodeTransform` — `TransformStream<Float32Array[], Uint8Array>` |
|
||||
| `./codecs/wav` | Native WAV codec — decode + encode + streaming |
|
||||
| `./codecs/aiff` | Native AIFF/AIFC decoder (decode-only) |
|
||||
| `./codecs/caf` | Native CAF (Core Audio Format) decoder (decode-only) |
|
||||
| `./codecs/mp3`, `flac`, `ogg-vorbis`, `opus`, `aac`, `webm`, `qoa` | Lazy WASM codec wrappers — `optionalDependency` loaded on first use |
|
||||
| `./codecs/wasm-base` | `createWasmCodec` factory shared by every WASM wrapper |
|
||||
| `./error-codes` | `AudioCodecsError`, `AUDIO_CODECS_CODES` registry (7 stable codes) |
|
||||
|
||||
## Codecs at a glance
|
||||
|
||||
| Codec | Native | Decode | Encode | Streaming | Magic |
|
||||
| ------------ | :----: | :----: | :----: | :-------: | ---------------------- |
|
||||
| WAV | ✅ | ✅ | ✅ | ✅ | `RIFF` … `WAVE` |
|
||||
| AIFF/AIFC | ✅ | ✅ | ❌ | ❌ | `FORM` … `AIFF`/`AIFC` |
|
||||
| CAF | ✅ | ✅ | ❌ | ❌ | `caff` |
|
||||
| MP3 | WASM | ✅ | ✅ | ❌ | ID3v2 / MPEG sync |
|
||||
| FLAC | WASM | ✅ | ✅ | ❌ | `fLaC` |
|
||||
| Ogg Vorbis | WASM | ✅ | ❌ | ❌ | `OggS` |
|
||||
| Opus | WASM | ✅ | ✅ | ❌ | `OggS` |
|
||||
| AAC | WASM | ✅ | ❌ | ❌ | ADTS sync |
|
||||
| WebM (audio) | WASM | ✅ | ❌ | ❌ | EBML header |
|
||||
| QOA | WASM | ✅ | ✅ | ❌ | `qoaf` |
|
||||
|
||||
## Source variants accepted
|
||||
|
||||
`decodeSource`, `progressiveDecode`, and `detectSource` all accept any
|
||||
of:
|
||||
|
||||
- `string` — bare path (server: `fs.readFile`; browser: `fetch`),
|
||||
`file://` URI (server only), or `http(s):` / `data:` / `blob:`
|
||||
string (any runtime: `fetch`).
|
||||
- `URL` instance — resolved via `fetch`.
|
||||
- `ArrayBuffer` / `Uint8Array` — consumed directly.
|
||||
- `ReadableStream<Uint8Array>` — drained on demand.
|
||||
|
||||
## Destination variants accepted
|
||||
|
||||
`encodeTo` accepts any of:
|
||||
|
||||
- `string` file path — written via runtime-gated
|
||||
`node:fs/promises.writeFile`.
|
||||
- `WritableStream<Uint8Array>` — one chunk write then close.
|
||||
- `(chunk: Uint8Array) => void` callback — single invocation with the
|
||||
full encoded payload.
|
||||
|
||||
## Error model
|
||||
|
||||
Every fault is an `AudioCodecsError` carrying one of seven stable codes:
|
||||
|
||||
- `AUDIO_CODECS.UNKNOWN_FORMAT` — detection found no codec.
|
||||
- `AUDIO_CODECS.DECODE_FAILED` — codec rejected input.
|
||||
- `AUDIO_CODECS.ENCODE_FAILED` — codec rejected document.
|
||||
- `AUDIO_CODECS.STREAM_ERROR` — abort or stream-only failure.
|
||||
- `AUDIO_CODECS.NOT_SUPPORTED` — operation unavailable for codec
|
||||
(e.g. encode for AIFF) or `optionalDependency` not installed.
|
||||
- `AUDIO_CODECS.INVALID_SOURCE` — unrecognised source / destination or
|
||||
Zod boundary validation failure.
|
||||
- `AUDIO_CODECS.BUDGET_EXCEEDED` — projected memory > caller's budget.
|
||||
|
||||
Original errors are preserved on `error.cause`; additional triage data
|
||||
lives on `error.context`.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/audio-codecs
|
||||
bunx turbo test:coverage --filter=@suaveplan/audio-codecs
|
||||
```
|
||||
|
||||
## Spec & change history
|
||||
|
||||
- Design change: `enterprise-audio-codecs/`
|
||||
- Canonical spec (after archive): `audio-codecs/spec.md`
|
||||
164
packages/media/audio/audio-codecs/package.json
Normal file
164
packages/media/audio/audio-codecs/package.json
Normal file
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-codecs",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal audio codec registry: decode/encode plugin loader with format detection, progressive streaming decode, native WAV/AIFF/CAF codecs, and lazy-loaded WASM codecs for MP3/FLAC/Ogg/Opus/AAC/WebM/QOA. Universal with runtime-gated node:fs access.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./registry/registry": {
|
||||
"import": {
|
||||
"types": "./dist/registry/registry.d.ts",
|
||||
"default": "./dist/registry/registry.js"
|
||||
}
|
||||
},
|
||||
"./decode/decode-source": {
|
||||
"import": {
|
||||
"types": "./dist/decode/decode-source.d.ts",
|
||||
"default": "./dist/decode/decode-source.js"
|
||||
}
|
||||
},
|
||||
"./encode/encode-to": {
|
||||
"import": {
|
||||
"types": "./dist/encode/encode-to.d.ts",
|
||||
"default": "./dist/encode/encode-to.js"
|
||||
}
|
||||
},
|
||||
"./detect/detect-format": {
|
||||
"import": {
|
||||
"types": "./dist/detect/detect-format.d.ts",
|
||||
"default": "./dist/detect/detect-format.js"
|
||||
}
|
||||
},
|
||||
"./stream/stream-decoder": {
|
||||
"import": {
|
||||
"types": "./dist/stream/stream-decoder.d.ts",
|
||||
"default": "./dist/stream/stream-decoder.js"
|
||||
}
|
||||
},
|
||||
"./stream/stream-encoder": {
|
||||
"import": {
|
||||
"types": "./dist/stream/stream-encoder.d.ts",
|
||||
"default": "./dist/stream/stream-encoder.js"
|
||||
}
|
||||
},
|
||||
"./codecs/wav": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/wav.d.ts",
|
||||
"default": "./dist/codecs/wav.js"
|
||||
}
|
||||
},
|
||||
"./codecs/aiff": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/aiff.d.ts",
|
||||
"default": "./dist/codecs/aiff.js"
|
||||
}
|
||||
},
|
||||
"./codecs/caf": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/caf.d.ts",
|
||||
"default": "./dist/codecs/caf.js"
|
||||
}
|
||||
},
|
||||
"./codecs/mp3": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/mp3.d.ts",
|
||||
"default": "./dist/codecs/mp3.js"
|
||||
}
|
||||
},
|
||||
"./codecs/flac": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/flac.d.ts",
|
||||
"default": "./dist/codecs/flac.js"
|
||||
}
|
||||
},
|
||||
"./codecs/ogg-vorbis": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/ogg-vorbis.d.ts",
|
||||
"default": "./dist/codecs/ogg-vorbis.js"
|
||||
}
|
||||
},
|
||||
"./codecs/opus": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/opus.d.ts",
|
||||
"default": "./dist/codecs/opus.js"
|
||||
}
|
||||
},
|
||||
"./codecs/aac": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/aac.d.ts",
|
||||
"default": "./dist/codecs/aac.js"
|
||||
}
|
||||
},
|
||||
"./codecs/webm": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/webm.d.ts",
|
||||
"default": "./dist/codecs/webm.js"
|
||||
}
|
||||
},
|
||||
"./codecs/qoa": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/qoa.d.ts",
|
||||
"default": "./dist/codecs/qoa.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-core": "workspace:^",
|
||||
"@suaveplan/audio-metadata": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/streams": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
72
packages/media/audio/audio-codecs/src/codecs/aac.md
Normal file
72
packages/media/audio/audio-codecs/src/codecs/aac.md
Normal 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`.
|
||||
20
packages/media/audio/audio-codecs/src/codecs/aac.ts
Normal file
20
packages/media/audio/audio-codecs/src/codecs/aac.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createWasmCodec } from "./wasm-base.js";
|
||||
|
||||
function aacMagic(bytes: Uint8Array): boolean {
|
||||
if (bytes.byteLength < 2) return false;
|
||||
// ADTS sync word: 0xFFFx
|
||||
return bytes[0] === 0xff && (bytes[1]! & 0xf0) === 0xf0;
|
||||
}
|
||||
|
||||
/** Lazy AAC codec backed by `@audio/decode-aac`. Decode-only. */
|
||||
export const AacCodec = createWasmCodec({
|
||||
name: "aac",
|
||||
extensions: ["aac", "m4a"],
|
||||
mimeTypes: ["audio/aac", "audio/mp4"],
|
||||
module: "@audio/decode-aac",
|
||||
magic: aacMagic,
|
||||
supportsEncode: false,
|
||||
load: () =>
|
||||
// @ts-expect-error optional dependency resolved at runtime
|
||||
import("@audio/decode-aac"),
|
||||
});
|
||||
117
packages/media/audio/audio-codecs/src/codecs/aiff.md
Normal file
117
packages/media/audio/audio-codecs/src/codecs/aiff.md
Normal 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).
|
||||
243
packages/media/audio/audio-codecs/src/codecs/aiff.test.ts
Normal file
243
packages/media/audio/audio-codecs/src/codecs/aiff.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { AiffCodec } from "./aiff.js";
|
||||
|
||||
function buildAiff(
|
||||
bits: 8 | 16 | 24 | 32,
|
||||
channels: number,
|
||||
sampleRate: number,
|
||||
frames: number,
|
||||
samples: number[][]
|
||||
): Uint8Array {
|
||||
const bps = bits / 8;
|
||||
const ssndDataLen = frames * channels * bps;
|
||||
const commSize = 18;
|
||||
const ssndChunkSize = 8 + ssndDataLen;
|
||||
const formSize = 4 + (8 + commSize) + (8 + ssndChunkSize);
|
||||
const total = 8 + formSize;
|
||||
const out = new Uint8Array(total);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false); // "FORM"
|
||||
dv.setUint32(4, formSize, false);
|
||||
dv.setUint32(8, 0x41494646, false); // "AIFF"
|
||||
dv.setUint32(12, 0x434f4d4d, false); // "COMM"
|
||||
dv.setUint32(16, commSize, false);
|
||||
dv.setUint16(20, channels, false);
|
||||
dv.setUint32(22, frames, false);
|
||||
dv.setUint16(26, bits, false);
|
||||
// 80-bit IEEE 754 extended for sampleRate
|
||||
const exp = Math.floor(Math.log2(sampleRate)) + 16383;
|
||||
const mantissaScaled = sampleRate / 2 ** Math.floor(Math.log2(sampleRate));
|
||||
const mantissa = Math.floor(mantissaScaled * 2 ** 63);
|
||||
dv.setUint16(28, exp, false);
|
||||
dv.setUint32(30, Math.floor(mantissa / 2 ** 32), false);
|
||||
dv.setUint32(34, mantissa >>> 0, false);
|
||||
dv.setUint32(38, 0x53534e44, false); // "SSND"
|
||||
dv.setUint32(42, ssndChunkSize, false);
|
||||
dv.setUint32(46, 0, false); // offset
|
||||
dv.setUint32(50, 0, false); // blockSize
|
||||
const norm = (1 << (bits - 1)) - 1;
|
||||
let off = 54;
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const s = samples[c]?.[f] ?? 0;
|
||||
const v = Math.round(s * norm);
|
||||
if (bits === 8) dv.setInt8(off, v);
|
||||
else if (bits === 16) dv.setInt16(off, v, false);
|
||||
else if (bits === 24) {
|
||||
dv.setInt8(off, (v >> 16) & 0xff);
|
||||
dv.setUint8(off + 1, (v >> 8) & 0xff);
|
||||
dv.setUint8(off + 2, v & 0xff);
|
||||
} else if (bits === 32) dv.setInt32(off, v, false);
|
||||
off += bps;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("AiffCodec", () => {
|
||||
it("declares name and identifiers", { timeout: 5000 }, () => {
|
||||
expect(AiffCodec.name).toBe("aiff");
|
||||
expect(AiffCodec.extensions).toContain("aiff");
|
||||
expect(AiffCodec.mimeTypes).toContain("audio/aiff");
|
||||
});
|
||||
|
||||
it("canDecode accepts FORM+AIFF", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array(12);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(8, 0x41494646, false);
|
||||
expect(AiffCodec.canDecode(bytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("canDecode accepts FORM+AIFC", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array(12);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(8, 0x41494643, false);
|
||||
expect(AiffCodec.canDecode(bytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("canDecode rejects bad magic", { timeout: 5000 }, () => {
|
||||
expect(AiffCodec.canDecode(new Uint8Array(4))).toBe(false);
|
||||
const badForm = new Uint8Array(12);
|
||||
new DataView(badForm.buffer).setUint32(0, 0xdeadbeef, false);
|
||||
expect(AiffCodec.canDecode(badForm)).toBe(false);
|
||||
const bytes = new Uint8Array(12);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(8, 0x12345678, false);
|
||||
expect(AiffCodec.canDecode(bytes)).toBe(false);
|
||||
});
|
||||
|
||||
it("decodes 16-bit AIFF to normalised float PCM", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const buf = buildAiff(16, 1, 44_100, 4, [[0.5, -0.5, 0.25, -0.25]]);
|
||||
const decoded = await AiffCodec.decode(buf);
|
||||
expect(decoded.format).toBe("aiff");
|
||||
expect(decoded.channelData[0]!.length).toBe(4);
|
||||
});
|
||||
|
||||
it("decodes 8-bit AIFF", { timeout: 5000 }, async () => {
|
||||
const buf = buildAiff(8, 1, 8000, 2, [[0.5, -0.5]]);
|
||||
const decoded = await AiffCodec.decode(buf);
|
||||
expect(decoded.channelData[0]!.length).toBe(2);
|
||||
});
|
||||
|
||||
it("decodes 24-bit AIFF", { timeout: 5000 }, async () => {
|
||||
const buf = buildAiff(24, 2, 44_100, 2, [
|
||||
[0.1, -0.1],
|
||||
[0.2, -0.2],
|
||||
]);
|
||||
const decoded = await AiffCodec.decode(buf);
|
||||
expect(decoded.channelData.length).toBe(2);
|
||||
});
|
||||
|
||||
it("decodes 32-bit AIFF", { timeout: 5000 }, async () => {
|
||||
const buf = buildAiff(32, 1, 96_000, 2, [[0.5, -0.5]]);
|
||||
const decoded = await AiffCodec.decode(buf);
|
||||
expect(decoded.channelData[0]!.length).toBe(2);
|
||||
});
|
||||
|
||||
it("encode throws NOT_SUPPORTED", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
AiffCodec.encode([new Float32Array(2)])
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode throws DECODE_FAILED on too-short", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
AiffCodec.decode(new Uint8Array(8))
|
||||
).rejects.toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("decode throws DECODE_FAILED on bad FORM magic", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(60);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0xdeadbeef, false);
|
||||
dv.setUint32(8, 0x41494646, false);
|
||||
await expect(AiffCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode throws DECODE_FAILED on bad type", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(60);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(8, 0xdeadbeef, false);
|
||||
await expect(AiffCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode throws DECODE_FAILED when COMM size < 18", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(36);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(4, 28, false);
|
||||
dv.setUint32(8, 0x41494646, false);
|
||||
dv.setUint32(12, 0x434f4d4d, false);
|
||||
dv.setUint32(16, 10, false); // too small
|
||||
await expect(AiffCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode throws DECODE_FAILED with no COMM chunk", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(28);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(4, 20, false);
|
||||
dv.setUint32(8, 0x41494646, false);
|
||||
// Unknown chunk after type — ANNO
|
||||
dv.setUint32(12, 0x414e4e4f, false);
|
||||
dv.setUint32(16, 4, false);
|
||||
await expect(AiffCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode throws DECODE_FAILED with no SSND chunk", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(38);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(4, 30, false);
|
||||
dv.setUint32(8, 0x41494646, false);
|
||||
dv.setUint32(12, 0x434f4d4d, false);
|
||||
dv.setUint32(16, 18, false);
|
||||
dv.setUint16(20, 1, false);
|
||||
dv.setUint32(22, 0, false);
|
||||
dv.setUint16(26, 16, false);
|
||||
// Sample rate 80-bit (zero — handled by parser)
|
||||
await expect(AiffCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles zero-sampleRate (all-zero IEEE extended)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const buf = buildAiff(16, 1, 0, 0, [[]]);
|
||||
// Zero sampleRate is technically allowed by the parser — frames=0.
|
||||
// We just ensure no crash.
|
||||
try {
|
||||
await AiffCodec.decode(buf);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AudioCodecsError);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles negative sampleRate (IEEE extended sign bit)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const buf = buildAiff(16, 1, 1, 0, [[]]);
|
||||
// Force sign bit on offset 28 (first byte of the 80-bit float).
|
||||
buf[28] = buf[28]! | 0x80;
|
||||
// No assertion on numeric output — branch coverage only.
|
||||
try {
|
||||
await AiffCodec.decode(buf);
|
||||
} catch (err) {
|
||||
// Either succeeds or throws — both paths exercised.
|
||||
expect(err).toBeInstanceOf(AudioCodecsError);
|
||||
}
|
||||
});
|
||||
});
|
||||
199
packages/media/audio/audio-codecs/src/codecs/aiff.ts
Normal file
199
packages/media/audio/audio-codecs/src/codecs/aiff.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { AudioCodec, DecodeResult } from "../registry/registry.types.js";
|
||||
|
||||
const FORM = 0x464f524d;
|
||||
const AIFF = 0x41494646;
|
||||
const AIFC = 0x41494643;
|
||||
const COMM = 0x434f4d4d;
|
||||
const SSND = 0x53534e44;
|
||||
|
||||
function dvU32BE(dv: DataView, off: number): number {
|
||||
return dv.getUint32(off, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the 80-bit IEEE 754 extended float used by AIFF for sample rate.
|
||||
* Layout: 1 sign bit | 15 exponent bits (biased 16383) | 64-bit explicit
|
||||
* mantissa (no implicit 1 bit unlike IEEE 754 binary64).
|
||||
*/
|
||||
function parseIeee754Extended(bytes: Uint8Array, off: number): number {
|
||||
if (off + 10 > bytes.byteLength) return 0;
|
||||
const sign = (bytes[off]! & 0x80) >> 7;
|
||||
const exp = ((bytes[off]! & 0x7f) << 8) | bytes[off + 1]!;
|
||||
let high = 0;
|
||||
let low = 0;
|
||||
for (let i = 0; i < 4; i++) high = (high << 8) | bytes[off + 2 + i]!;
|
||||
for (let i = 0; i < 4; i++) low = (low << 8) | bytes[off + 6 + i]!;
|
||||
high = high >>> 0;
|
||||
low = low >>> 0;
|
||||
if (exp === 0 && high === 0 && low === 0) return 0;
|
||||
const mantissa = high * 2 ** 32 + low;
|
||||
const value = mantissa * 2 ** (exp - 16383 - 63);
|
||||
return sign ? -value : value;
|
||||
}
|
||||
|
||||
interface AiffCommon {
|
||||
readonly channels: number;
|
||||
readonly numSampleFrames: number;
|
||||
readonly sampleSize: number;
|
||||
readonly sampleRate: number;
|
||||
}
|
||||
|
||||
function parseAiff(bytes: Uint8Array): {
|
||||
common: AiffCommon;
|
||||
ssndOffset: number;
|
||||
ssndLength: number;
|
||||
} {
|
||||
if (bytes.byteLength < 12)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.aiff.too-short",
|
||||
{}
|
||||
);
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (dvU32BE(dv, 0) !== FORM)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.aiff.bad-form",
|
||||
{}
|
||||
);
|
||||
const type = dvU32BE(dv, 8);
|
||||
if (type !== AIFF && type !== AIFC)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.aiff.bad-type",
|
||||
{}
|
||||
);
|
||||
let pos = 12;
|
||||
let common: AiffCommon | undefined;
|
||||
let ssndOffset = -1;
|
||||
let ssndLength = -1;
|
||||
while (pos + 8 <= bytes.byteLength) {
|
||||
const id = dvU32BE(dv, pos);
|
||||
const size = dvU32BE(dv, pos + 4);
|
||||
const body = pos + 8;
|
||||
if (id === COMM) {
|
||||
if (size < 18)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.aiff.bad-comm-size",
|
||||
{}
|
||||
);
|
||||
common = {
|
||||
channels: dv.getUint16(body, false),
|
||||
numSampleFrames: dv.getUint32(body + 2, false) >>> 0,
|
||||
sampleSize: dv.getUint16(body + 6, false),
|
||||
sampleRate: parseIeee754Extended(bytes, body + 8),
|
||||
};
|
||||
} else if (id === SSND) {
|
||||
const sampleOffset = dv.getUint32(body, false);
|
||||
ssndOffset = body + 8 + sampleOffset;
|
||||
ssndLength = size - 8 - sampleOffset;
|
||||
break;
|
||||
}
|
||||
pos = body + size + (size & 1);
|
||||
}
|
||||
if (!common)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.aiff.no-common",
|
||||
{}
|
||||
);
|
||||
if (ssndOffset < 0)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.aiff.no-ssnd",
|
||||
{}
|
||||
);
|
||||
return { common, ssndOffset, ssndLength };
|
||||
}
|
||||
|
||||
function decodeAiffPcm(
|
||||
bytes: Uint8Array,
|
||||
common: AiffCommon,
|
||||
ssndOffset: number,
|
||||
ssndLength: number
|
||||
): Float32Array[] {
|
||||
const bps = common.sampleSize >> 3;
|
||||
if (bps === 0)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.aiff.unsupported-bits",
|
||||
{ context: { bits: common.sampleSize } }
|
||||
);
|
||||
const frameBytes = bps * common.channels;
|
||||
const declaredFrames = common.numSampleFrames;
|
||||
const availableFrames = Math.floor(ssndLength / frameBytes);
|
||||
const frames = Math.min(declaredFrames, availableFrames);
|
||||
const ch: Float32Array[] = Array.from(
|
||||
{ length: common.channels },
|
||||
() => new Float32Array(frames)
|
||||
);
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const norm = (1 << (common.sampleSize - 1)) >>> 0;
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (let c = 0; c < common.channels; c++) {
|
||||
const off = ssndOffset + f * frameBytes + c * bps;
|
||||
let sample: number;
|
||||
if (bps === 1) sample = dv.getInt8(off) / 128;
|
||||
else if (bps === 2) sample = dv.getInt16(off, false) / norm;
|
||||
else if (bps === 3) {
|
||||
const b0 = dv.getInt8(off);
|
||||
const b1 = dv.getUint8(off + 1);
|
||||
const b2 = dv.getUint8(off + 2);
|
||||
sample = ((b0 << 16) | (b1 << 8) | b2) / norm;
|
||||
} else if (bps === 4) sample = dv.getInt32(off, false) / norm;
|
||||
else
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.aiff.unsupported-bytes-per-sample",
|
||||
{ context: { bps } }
|
||||
);
|
||||
ch[c]![f] = sample;
|
||||
}
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native AIFF/AIFC decoder. Supports 8-bit signed, 16-bit signed, 24-bit
|
||||
* signed, and 32-bit signed PCM in big-endian byte order. AIFF encoding is
|
||||
* intentionally not supported — the standard is decode-mostly in practice and
|
||||
* downstream services prefer WAV/FLAC for round-trip.
|
||||
*/
|
||||
export const AiffCodec: AudioCodec = {
|
||||
name: "aiff",
|
||||
extensions: ["aiff", "aif", "aifc"],
|
||||
mimeTypes: ["audio/aiff", "audio/x-aiff"],
|
||||
canDecode(bytes) {
|
||||
if (bytes.byteLength < 12) return false;
|
||||
const dv = new DataView(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength
|
||||
);
|
||||
if (dvU32BE(dv, 0) !== FORM) return false;
|
||||
const type = dvU32BE(dv, 8);
|
||||
return type === AIFF || type === AIFC;
|
||||
},
|
||||
async decode(bytes): Promise<DecodeResult> {
|
||||
const { common, ssndOffset, ssndLength } = parseAiff(bytes);
|
||||
const channelData = decodeAiffPcm(
|
||||
bytes,
|
||||
common,
|
||||
ssndOffset,
|
||||
ssndLength
|
||||
);
|
||||
return { channelData, sampleRate: common.sampleRate, format: "aiff" };
|
||||
},
|
||||
async encode(): Promise<Uint8Array> {
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.aiff.encode-not-supported",
|
||||
{ context: { format: "aiff" } }
|
||||
);
|
||||
},
|
||||
};
|
||||
109
packages/media/audio/audio-codecs/src/codecs/caf.md
Normal file
109
packages/media/audio/audio-codecs/src/codecs/caf.md
Normal 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.
|
||||
155
packages/media/audio/audio-codecs/src/codecs/caf.test.ts
Normal file
155
packages/media/audio/audio-codecs/src/codecs/caf.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CafCodec } from "./caf.js";
|
||||
|
||||
function buildCaf(
|
||||
bits: 16 | 24 | 32,
|
||||
channels: number,
|
||||
sampleRate: number,
|
||||
frames: number,
|
||||
samples: number[][]
|
||||
): Uint8Array {
|
||||
const bps = bits / 8;
|
||||
const dataLen = frames * channels * bps;
|
||||
const total = 8 + 12 + 32 + 12 + 4 + dataLen;
|
||||
const out = new Uint8Array(total);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint32(0, 0x63616666, false); // "caff"
|
||||
dv.setUint16(4, 1, false); // version
|
||||
dv.setUint16(6, 0, false); // flags
|
||||
let off = 8;
|
||||
dv.setUint32(off, 0x64657363, false); // "desc"
|
||||
dv.setUint32(off + 4, 0, false);
|
||||
dv.setUint32(off + 8, 32, false);
|
||||
off += 12;
|
||||
dv.setFloat64(off, sampleRate, false);
|
||||
dv.setUint32(off + 8, 0x6c70636d, false); // "lpcm"
|
||||
dv.setUint32(off + 12, 0, false); // formatFlags (big-endian, integer)
|
||||
dv.setUint32(off + 16, bps * channels, false); // bytesPerPacket
|
||||
dv.setUint32(off + 20, 1, false); // framesPerPacket
|
||||
dv.setUint32(off + 24, channels, false);
|
||||
dv.setUint32(off + 28, bits, false);
|
||||
off += 32;
|
||||
dv.setUint32(off, 0x64617461, false); // "data"
|
||||
dv.setUint32(off + 4, 0, false);
|
||||
dv.setUint32(off + 8, 4 + dataLen, false);
|
||||
off += 12;
|
||||
dv.setUint32(off, 0, false); // editCount
|
||||
off += 4;
|
||||
const norm = (1 << (bits - 1)) - 1;
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const s = samples[c]?.[f] ?? 0;
|
||||
const v = Math.round(s * norm);
|
||||
if (bits === 16) dv.setInt16(off, v, false);
|
||||
else if (bits === 24) {
|
||||
dv.setInt8(off, (v >> 16) & 0xff);
|
||||
dv.setUint8(off + 1, (v >> 8) & 0xff);
|
||||
dv.setUint8(off + 2, v & 0xff);
|
||||
} else if (bits === 32) dv.setInt32(off, v, false);
|
||||
off += bps;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("CafCodec", () => {
|
||||
it("declares name and identifiers", { timeout: 5000 }, () => {
|
||||
expect(CafCodec.name).toBe("caf");
|
||||
expect(CafCodec.extensions).toContain("caf");
|
||||
});
|
||||
|
||||
it("canDecode accepts caff magic", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array(8);
|
||||
new DataView(bytes.buffer).setUint32(0, 0x63616666, false);
|
||||
expect(CafCodec.canDecode(bytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("canDecode rejects short / wrong magic", { timeout: 5000 }, () => {
|
||||
expect(CafCodec.canDecode(new Uint8Array(2))).toBe(false);
|
||||
expect(CafCodec.canDecode(new Uint8Array(8))).toBe(false);
|
||||
});
|
||||
|
||||
it("decodes 16-bit big-endian PCM", { timeout: 5000 }, async () => {
|
||||
const buf = buildCaf(16, 1, 44_100, 4, [[0.5, -0.5, 0.25, -0.25]]);
|
||||
const decoded = await CafCodec.decode(buf);
|
||||
expect(decoded.format).toBe("caf");
|
||||
expect(decoded.sampleRate).toBe(44_100);
|
||||
expect(decoded.channelData[0]!.length).toBe(4);
|
||||
});
|
||||
|
||||
it("decodes 24-bit big-endian PCM", { timeout: 5000 }, async () => {
|
||||
const buf = buildCaf(24, 1, 48_000, 2, [[0.1, -0.1]]);
|
||||
const decoded = await CafCodec.decode(buf);
|
||||
expect(decoded.channelData[0]!.length).toBe(2);
|
||||
});
|
||||
|
||||
it("decodes 32-bit big-endian PCM", { timeout: 5000 }, async () => {
|
||||
const buf = buildCaf(32, 2, 96_000, 2, [
|
||||
[0.1, -0.1],
|
||||
[0.2, -0.2],
|
||||
]);
|
||||
const decoded = await CafCodec.decode(buf);
|
||||
expect(decoded.channelData.length).toBe(2);
|
||||
});
|
||||
|
||||
it("encode throws NOT_SUPPORTED", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
CafCodec.encode([new Float32Array(2)])
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode throws DECODE_FAILED on too-short buffer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(CafCodec.decode(new Uint8Array(4))).rejects.toBeInstanceOf(
|
||||
AudioCodecsError
|
||||
);
|
||||
});
|
||||
|
||||
it("decode throws DECODE_FAILED on bad magic", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(64);
|
||||
new DataView(bytes.buffer).setUint32(0, 0xdeadbeef, false);
|
||||
await expect(CafCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode throws DECODE_FAILED when desc chunk is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(64);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x63616666, false);
|
||||
dv.setUint16(4, 1, false);
|
||||
// Random chunk (not desc, not data) so walker terminates without finding desc.
|
||||
dv.setUint32(8, 0x66726565, false); // "free"
|
||||
dv.setUint32(12, 0, false);
|
||||
dv.setUint32(16, 16, false);
|
||||
await expect(CafCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode throws DECODE_FAILED when desc chunk size < 32", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(64);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x63616666, false);
|
||||
dv.setUint16(4, 1, false);
|
||||
dv.setUint32(8, 0x64657363, false);
|
||||
dv.setUint32(12, 0, false);
|
||||
dv.setUint32(16, 20, false); // size < 32
|
||||
await expect(CafCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
171
packages/media/audio/audio-codecs/src/codecs/caf.ts
Normal file
171
packages/media/audio/audio-codecs/src/codecs/caf.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { AudioCodec, DecodeResult } from "../registry/registry.types.js";
|
||||
|
||||
const CAFF = 0x63616666;
|
||||
const DESC = 0x64657363;
|
||||
const DATA = 0x64617461;
|
||||
const PACKET_FRAME_FLAG_LE = 0x0002;
|
||||
|
||||
interface CafDesc {
|
||||
readonly sampleRate: number;
|
||||
readonly formatId: number;
|
||||
readonly formatFlags: number;
|
||||
readonly bytesPerPacket: number;
|
||||
readonly framesPerPacket: number;
|
||||
readonly channels: number;
|
||||
readonly bitsPerChannel: number;
|
||||
}
|
||||
|
||||
function readUint64BE(dv: DataView, off: number): number {
|
||||
const high = dv.getUint32(off, false);
|
||||
const low = dv.getUint32(off + 4, false);
|
||||
return high * 2 ** 32 + low;
|
||||
}
|
||||
|
||||
function parseCaf(bytes: Uint8Array): {
|
||||
desc: CafDesc;
|
||||
dataOffset: number;
|
||||
dataLength: number;
|
||||
} {
|
||||
if (bytes.byteLength < 8)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.caf.too-short",
|
||||
{}
|
||||
);
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (dv.getUint32(0, false) !== CAFF)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.caf.bad-magic",
|
||||
{}
|
||||
);
|
||||
let pos = 8; // CAFF + version/flags (4 bytes)
|
||||
let desc: CafDesc | undefined;
|
||||
let dataOffset = -1;
|
||||
let dataLength = -1;
|
||||
while (pos + 12 <= bytes.byteLength) {
|
||||
const id = dv.getUint32(pos, false);
|
||||
const size = readUint64BE(dv, pos + 4);
|
||||
const body = pos + 12;
|
||||
if (id === DESC) {
|
||||
if (size < 32)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.caf.bad-desc-size",
|
||||
{}
|
||||
);
|
||||
desc = {
|
||||
sampleRate: dv.getFloat64(body, false),
|
||||
formatId: dv.getUint32(body + 8, false),
|
||||
formatFlags: dv.getUint32(body + 12, false),
|
||||
bytesPerPacket: dv.getUint32(body + 16, false),
|
||||
framesPerPacket: dv.getUint32(body + 20, false),
|
||||
channels: dv.getUint32(body + 24, false),
|
||||
bitsPerChannel: dv.getUint32(body + 28, false),
|
||||
};
|
||||
} else if (id === DATA) {
|
||||
dataOffset = body + 4; // skip 4 bytes editCount
|
||||
dataLength = size - 4;
|
||||
break;
|
||||
}
|
||||
pos = body + size;
|
||||
}
|
||||
if (!desc)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.caf.no-desc",
|
||||
{}
|
||||
);
|
||||
if (dataOffset < 0)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.caf.no-data",
|
||||
{}
|
||||
);
|
||||
return { desc, dataOffset, dataLength };
|
||||
}
|
||||
|
||||
function decodeCafPcm(
|
||||
bytes: Uint8Array,
|
||||
desc: CafDesc,
|
||||
dataOffset: number,
|
||||
dataLength: number
|
||||
): Float32Array[] {
|
||||
const bps = desc.bitsPerChannel >> 3;
|
||||
if (bps === 0)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.caf.unsupported-bits",
|
||||
{ context: { bits: desc.bitsPerChannel } }
|
||||
);
|
||||
const frameBytes = bps * desc.channels;
|
||||
const frames = Math.floor(dataLength / frameBytes);
|
||||
const ch: Float32Array[] = Array.from(
|
||||
{ length: desc.channels },
|
||||
() => new Float32Array(frames)
|
||||
);
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const littleEndian = (desc.formatFlags & PACKET_FRAME_FLAG_LE) !== 0;
|
||||
const norm = (1 << (desc.bitsPerChannel - 1)) >>> 0;
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (let c = 0; c < desc.channels; c++) {
|
||||
const off = dataOffset + f * frameBytes + c * bps;
|
||||
let sample: number;
|
||||
if (bps === 2) sample = dv.getInt16(off, littleEndian) / norm;
|
||||
else if (bps === 3) {
|
||||
const a = dv.getUint8(off);
|
||||
const b = dv.getUint8(off + 1);
|
||||
const cc = dv.getInt8(off + 2);
|
||||
sample = littleEndian
|
||||
? ((cc << 16) | (b << 8) | a) / norm
|
||||
: ((a << 16) | (b << 8) | cc) / norm;
|
||||
} else if (bps === 4)
|
||||
sample = dv.getInt32(off, littleEndian) / norm;
|
||||
else
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.caf.unsupported-bytes-per-sample",
|
||||
{ context: { bps } }
|
||||
);
|
||||
ch[c]![f] = sample;
|
||||
}
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native CAF (Apple Core Audio Format) decoder. Decodes uncompressed integer
|
||||
* PCM payloads (`lpcm`) in either byte order. Encoding is intentionally not
|
||||
* supported — CAF write is rarely useful outside Apple's own tooling and the
|
||||
* canonical archive format on genesis is WAV/FLAC.
|
||||
*/
|
||||
export const CafCodec: AudioCodec = {
|
||||
name: "caf",
|
||||
extensions: ["caf"],
|
||||
mimeTypes: ["audio/x-caf"],
|
||||
canDecode(bytes) {
|
||||
if (bytes.byteLength < 4) return false;
|
||||
const dv = new DataView(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength
|
||||
);
|
||||
return dv.getUint32(0, false) === CAFF;
|
||||
},
|
||||
async decode(bytes): Promise<DecodeResult> {
|
||||
const { desc, dataOffset, dataLength } = parseCaf(bytes);
|
||||
const channelData = decodeCafPcm(bytes, desc, dataOffset, dataLength);
|
||||
return { channelData, sampleRate: desc.sampleRate, format: "caf" };
|
||||
},
|
||||
async encode(): Promise<Uint8Array> {
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.caf.encode-not-supported",
|
||||
{ context: { format: "caf" } }
|
||||
);
|
||||
},
|
||||
};
|
||||
80
packages/media/audio/audio-codecs/src/codecs/flac.md
Normal file
80
packages/media/audio/audio-codecs/src/codecs/flac.md
Normal 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.
|
||||
24
packages/media/audio/audio-codecs/src/codecs/flac.ts
Normal file
24
packages/media/audio/audio-codecs/src/codecs/flac.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createWasmCodec } from "./wasm-base.js";
|
||||
|
||||
function flacMagic(bytes: Uint8Array): boolean {
|
||||
if (bytes.byteLength < 4) return false;
|
||||
return (
|
||||
bytes[0] === 0x66 &&
|
||||
bytes[1] === 0x4c &&
|
||||
bytes[2] === 0x61 &&
|
||||
bytes[3] === 0x43
|
||||
);
|
||||
}
|
||||
|
||||
/** Lazy FLAC codec backed by `@audio/decode-flac`. */
|
||||
export const FlacCodec = createWasmCodec({
|
||||
name: "flac",
|
||||
extensions: ["flac"],
|
||||
mimeTypes: ["audio/flac", "audio/x-flac"],
|
||||
module: "@audio/decode-flac",
|
||||
magic: flacMagic,
|
||||
supportsEncode: true,
|
||||
load: () =>
|
||||
// @ts-expect-error optional dependency resolved at runtime
|
||||
import("@audio/decode-flac"),
|
||||
});
|
||||
10
packages/media/audio/audio-codecs/src/codecs/index.ts
Normal file
10
packages/media/audio/audio-codecs/src/codecs/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { AacCodec } from "./aac.js";
|
||||
export { AiffCodec } from "./aiff.js";
|
||||
export { CafCodec } from "./caf.js";
|
||||
export { FlacCodec } from "./flac.js";
|
||||
export { Mp3Codec } from "./mp3.js";
|
||||
export { OggVorbisCodec } from "./ogg-vorbis.js";
|
||||
export { OpusCodec } from "./opus.js";
|
||||
export { QoaCodec } from "./qoa.js";
|
||||
export { WavCodec } from "./wav.js";
|
||||
export { WebMCodec } from "./webm.js";
|
||||
100
packages/media/audio/audio-codecs/src/codecs/mp3.md
Normal file
100
packages/media/audio/audio-codecs/src/codecs/mp3.md
Normal 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`.
|
||||
29
packages/media/audio/audio-codecs/src/codecs/mp3.ts
Normal file
29
packages/media/audio/audio-codecs/src/codecs/mp3.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createWasmCodec } from "./wasm-base.js";
|
||||
|
||||
function mp3Magic(bytes: Uint8Array): boolean {
|
||||
if (bytes.byteLength < 2) return false;
|
||||
// MPEG audio sync word (11 bits) — 0xFFE mask on first two bytes
|
||||
if (bytes[0] === 0xff && (bytes[1]! & 0xe0) === 0xe0) return true;
|
||||
// ID3v2 prefix requires three bytes
|
||||
if (
|
||||
bytes.byteLength >= 3 &&
|
||||
bytes[0] === 0x49 &&
|
||||
bytes[1] === 0x44 &&
|
||||
bytes[2] === 0x33
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Lazy MP3 codec backed by `@audio/decode-mp3`. */
|
||||
export const Mp3Codec = createWasmCodec({
|
||||
name: "mp3",
|
||||
extensions: ["mp3", "mpeg"],
|
||||
mimeTypes: ["audio/mpeg", "audio/mp3"],
|
||||
module: "@audio/decode-mp3",
|
||||
magic: mp3Magic,
|
||||
supportsEncode: true,
|
||||
load: () =>
|
||||
// @ts-expect-error optional dependency resolved at runtime
|
||||
import("@audio/decode-mp3"),
|
||||
});
|
||||
82
packages/media/audio/audio-codecs/src/codecs/ogg-vorbis.md
Normal file
82
packages/media/audio/audio-codecs/src/codecs/ogg-vorbis.md
Normal 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`.
|
||||
24
packages/media/audio/audio-codecs/src/codecs/ogg-vorbis.ts
Normal file
24
packages/media/audio/audio-codecs/src/codecs/ogg-vorbis.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createWasmCodec } from "./wasm-base.js";
|
||||
|
||||
function oggMagic(bytes: Uint8Array): boolean {
|
||||
if (bytes.byteLength < 4) return false;
|
||||
return (
|
||||
bytes[0] === 0x4f &&
|
||||
bytes[1] === 0x67 &&
|
||||
bytes[2] === 0x67 &&
|
||||
bytes[3] === 0x53
|
||||
);
|
||||
}
|
||||
|
||||
/** Lazy Ogg Vorbis codec backed by `@audio/decode-ogg-vorbis`. Decode-only. */
|
||||
export const OggVorbisCodec = createWasmCodec({
|
||||
name: "ogg-vorbis",
|
||||
extensions: ["ogg", "oga"],
|
||||
mimeTypes: ["audio/ogg", "audio/vorbis"],
|
||||
module: "@audio/decode-ogg-vorbis",
|
||||
magic: oggMagic,
|
||||
supportsEncode: false,
|
||||
load: () =>
|
||||
// @ts-expect-error optional dependency resolved at runtime
|
||||
import("@audio/decode-ogg-vorbis"),
|
||||
});
|
||||
73
packages/media/audio/audio-codecs/src/codecs/opus.md
Normal file
73
packages/media/audio/audio-codecs/src/codecs/opus.md
Normal 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.
|
||||
25
packages/media/audio/audio-codecs/src/codecs/opus.ts
Normal file
25
packages/media/audio/audio-codecs/src/codecs/opus.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createWasmCodec } from "./wasm-base.js";
|
||||
|
||||
function opusMagic(bytes: Uint8Array): boolean {
|
||||
// OpusHead inside an Ogg container — accept Ogg page magic for sniffing.
|
||||
if (bytes.byteLength < 4) return false;
|
||||
return (
|
||||
bytes[0] === 0x4f &&
|
||||
bytes[1] === 0x67 &&
|
||||
bytes[2] === 0x67 &&
|
||||
bytes[3] === 0x53
|
||||
);
|
||||
}
|
||||
|
||||
/** Lazy Opus codec backed by `@audio/decode-opus`. */
|
||||
export const OpusCodec = createWasmCodec({
|
||||
name: "opus",
|
||||
extensions: ["opus"],
|
||||
mimeTypes: ["audio/opus"],
|
||||
module: "@audio/decode-opus",
|
||||
magic: opusMagic,
|
||||
supportsEncode: true,
|
||||
load: () =>
|
||||
// @ts-expect-error optional dependency resolved at runtime
|
||||
import("@audio/decode-opus"),
|
||||
});
|
||||
74
packages/media/audio/audio-codecs/src/codecs/qoa.md
Normal file
74
packages/media/audio/audio-codecs/src/codecs/qoa.md
Normal 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.
|
||||
25
packages/media/audio/audio-codecs/src/codecs/qoa.ts
Normal file
25
packages/media/audio/audio-codecs/src/codecs/qoa.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createWasmCodec } from "./wasm-base.js";
|
||||
|
||||
function qoaMagic(bytes: Uint8Array): boolean {
|
||||
if (bytes.byteLength < 4) return false;
|
||||
// "qoaf" magic
|
||||
return (
|
||||
bytes[0] === 0x71 &&
|
||||
bytes[1] === 0x6f &&
|
||||
bytes[2] === 0x61 &&
|
||||
bytes[3] === 0x66
|
||||
);
|
||||
}
|
||||
|
||||
/** Lazy QOA (Quite OK Audio) codec backed by `@audio/decode-qoa`. */
|
||||
export const QoaCodec = createWasmCodec({
|
||||
name: "qoa",
|
||||
extensions: ["qoa"],
|
||||
mimeTypes: ["audio/qoa"],
|
||||
module: "@audio/decode-qoa",
|
||||
magic: qoaMagic,
|
||||
supportsEncode: true,
|
||||
load: () =>
|
||||
// @ts-expect-error optional dependency resolved at runtime
|
||||
import("@audio/decode-qoa"),
|
||||
});
|
||||
136
packages/media/audio/audio-codecs/src/codecs/wasm-base.md
Normal file
136
packages/media/audio/audio-codecs/src/codecs/wasm-base.md
Normal 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.
|
||||
375
packages/media/audio/audio-codecs/src/codecs/wasm-base.test.ts
Normal file
375
packages/media/audio/audio-codecs/src/codecs/wasm-base.test.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createWasmCodec, type WasmCodecModule } from "./wasm-base.js";
|
||||
|
||||
describe("createWasmCodec", () => {
|
||||
it("name/extensions/mimeTypes pass through and canDecode delegates to spec.magic", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: ["fake"],
|
||||
mimeTypes: ["audio/fake"],
|
||||
module: "@audio/fake",
|
||||
magic: (b) => b.byteLength > 0 && b[0] === 0xff,
|
||||
supportsEncode: true,
|
||||
load: () => Promise.resolve({} as WasmCodecModule),
|
||||
});
|
||||
expect(codec.name).toBe("fake");
|
||||
expect(codec.extensions).toEqual(["fake"]);
|
||||
expect(codec.mimeTypes).toEqual(["audio/fake"]);
|
||||
expect(codec.canDecode(new Uint8Array([0xff]))).toBe(true);
|
||||
expect(codec.canDecode(new Uint8Array([0x00]))).toBe(false);
|
||||
});
|
||||
|
||||
it("decode throws NOT_SUPPORTED with module name when import rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake-missing",
|
||||
magic: () => true,
|
||||
supportsEncode: true,
|
||||
load: () => Promise.reject(new Error("ENOENT")),
|
||||
});
|
||||
await expect(codec.decode(new Uint8Array(8))).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
context: { module: "@audio/fake-missing" },
|
||||
});
|
||||
});
|
||||
|
||||
it("decode throws NOT_SUPPORTED when module exposes no decode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake",
|
||||
magic: () => true,
|
||||
supportsEncode: true,
|
||||
load: () => Promise.resolve({} as WasmCodecModule),
|
||||
});
|
||||
await expect(codec.decode(new Uint8Array(8))).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode wraps non-API errors as DECODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake",
|
||||
magic: () => true,
|
||||
supportsEncode: false,
|
||||
load: () =>
|
||||
Promise.resolve({
|
||||
decode: () => {
|
||||
throw new TypeError("boom");
|
||||
},
|
||||
} as unknown as WasmCodecModule),
|
||||
});
|
||||
await expect(codec.decode(new Uint8Array(8))).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode re-throws AudioCodecsError unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const inner = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"test"
|
||||
);
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake",
|
||||
magic: () => true,
|
||||
supportsEncode: false,
|
||||
load: () =>
|
||||
Promise.resolve({
|
||||
decode: () => {
|
||||
throw inner;
|
||||
},
|
||||
} as unknown as WasmCodecModule),
|
||||
});
|
||||
await expect(codec.decode(new Uint8Array(8))).rejects.toBe(inner);
|
||||
});
|
||||
|
||||
it("decode returns format/sampleRate/channelData from module", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake",
|
||||
magic: () => true,
|
||||
supportsEncode: false,
|
||||
load: () =>
|
||||
Promise.resolve({
|
||||
decode: async () => ({
|
||||
channelData: [new Float32Array([0.1, 0.2])],
|
||||
sampleRate: 44_100,
|
||||
format: "ignored",
|
||||
}),
|
||||
} as WasmCodecModule),
|
||||
});
|
||||
const result = await codec.decode(new Uint8Array(8));
|
||||
expect(result.format).toBe("fake");
|
||||
expect(result.sampleRate).toBe(44_100);
|
||||
expect(result.channelData[0]!.length).toBe(2);
|
||||
});
|
||||
|
||||
it("encode throws NOT_SUPPORTED for decode-only codec", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake",
|
||||
magic: () => true,
|
||||
supportsEncode: false,
|
||||
load: () => Promise.resolve({} as WasmCodecModule),
|
||||
});
|
||||
await expect(codec.encode([new Float32Array(2)])).rejects.toMatchObject(
|
||||
{ code: AUDIO_CODECS_CODES.NOT_SUPPORTED }
|
||||
);
|
||||
});
|
||||
|
||||
it("encode throws NOT_SUPPORTED when module exposes no encode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake",
|
||||
magic: () => true,
|
||||
supportsEncode: true,
|
||||
load: () => Promise.resolve({} as WasmCodecModule),
|
||||
});
|
||||
await expect(codec.encode([new Float32Array(2)])).rejects.toMatchObject(
|
||||
{ code: AUDIO_CODECS_CODES.NOT_SUPPORTED }
|
||||
);
|
||||
});
|
||||
|
||||
it("encode forwards channelData and returns bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake",
|
||||
magic: () => true,
|
||||
supportsEncode: true,
|
||||
load: () =>
|
||||
Promise.resolve({
|
||||
encode: () => new Uint8Array([1, 2, 3]),
|
||||
} as WasmCodecModule),
|
||||
});
|
||||
const out = await codec.encode([new Float32Array([0.1])]);
|
||||
expect(Array.from(out)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("encode wraps non-API errors as ENCODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake",
|
||||
magic: () => true,
|
||||
supportsEncode: true,
|
||||
load: () =>
|
||||
Promise.resolve({
|
||||
encode: () => {
|
||||
throw new TypeError("boom");
|
||||
},
|
||||
} as unknown as WasmCodecModule),
|
||||
});
|
||||
await expect(codec.encode([new Float32Array(2)])).rejects.toMatchObject(
|
||||
{ code: AUDIO_CODECS_CODES.ENCODE_FAILED }
|
||||
);
|
||||
});
|
||||
|
||||
it("encode re-throws AudioCodecsError unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const inner = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"test"
|
||||
);
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake",
|
||||
magic: () => true,
|
||||
supportsEncode: true,
|
||||
load: () =>
|
||||
Promise.resolve({
|
||||
encode: () => {
|
||||
throw inner;
|
||||
},
|
||||
} as unknown as WasmCodecModule),
|
||||
});
|
||||
await expect(codec.encode([new Float32Array(2)])).rejects.toBe(inner);
|
||||
});
|
||||
|
||||
it("module load is memoised — repeated decode reuses the same promise", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const codec = createWasmCodec({
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
module: "@audio/fake",
|
||||
magic: () => true,
|
||||
supportsEncode: false,
|
||||
load: () => {
|
||||
calls++;
|
||||
return Promise.resolve({
|
||||
decode: () => ({
|
||||
channelData: [new Float32Array([0])],
|
||||
sampleRate: 8000,
|
||||
format: "x",
|
||||
}),
|
||||
} as unknown as WasmCodecModule);
|
||||
},
|
||||
});
|
||||
await codec.decode(new Uint8Array(1));
|
||||
await codec.decode(new Uint8Array(1));
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WASM codec wrappers — missing-module behaviour", () => {
|
||||
it("Mp3Codec / FlacCodec / OggVorbisCodec / OpusCodec / AacCodec / WebMCodec / QoaCodec all throw NOT_SUPPORTED when their @audio/* deps are absent", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const [
|
||||
{ Mp3Codec },
|
||||
{ FlacCodec },
|
||||
{ OggVorbisCodec },
|
||||
{ OpusCodec },
|
||||
{ AacCodec },
|
||||
{ WebMCodec },
|
||||
{ QoaCodec },
|
||||
] = await Promise.all([
|
||||
import("./mp3.js"),
|
||||
import("./flac.js"),
|
||||
import("./ogg-vorbis.js"),
|
||||
import("./opus.js"),
|
||||
import("./aac.js"),
|
||||
import("./webm.js"),
|
||||
import("./qoa.js"),
|
||||
]);
|
||||
for (const codec of [
|
||||
Mp3Codec,
|
||||
FlacCodec,
|
||||
OggVorbisCodec,
|
||||
OpusCodec,
|
||||
AacCodec,
|
||||
WebMCodec,
|
||||
QoaCodec,
|
||||
]) {
|
||||
await expect(codec.decode(new Uint8Array(8))).rejects.toMatchObject(
|
||||
{ code: AUDIO_CODECS_CODES.NOT_SUPPORTED }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("Mp3Codec magic accepts ID3v2 and MPEG sync", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { Mp3Codec } = await import("./mp3.js");
|
||||
expect(Mp3Codec.canDecode(new Uint8Array([0x49, 0x44, 0x33]))).toBe(
|
||||
true
|
||||
);
|
||||
expect(Mp3Codec.canDecode(new Uint8Array([0xff, 0xfb]))).toBe(true);
|
||||
expect(Mp3Codec.canDecode(new Uint8Array([0x00, 0x00]))).toBe(false);
|
||||
expect(Mp3Codec.canDecode(new Uint8Array(2))).toBe(false);
|
||||
});
|
||||
|
||||
it("FlacCodec magic accepts fLaC", { timeout: 5000 }, async () => {
|
||||
const { FlacCodec } = await import("./flac.js");
|
||||
expect(
|
||||
FlacCodec.canDecode(new Uint8Array([0x66, 0x4c, 0x61, 0x43]))
|
||||
).toBe(true);
|
||||
expect(FlacCodec.canDecode(new Uint8Array([0, 0, 0, 0]))).toBe(false);
|
||||
expect(FlacCodec.canDecode(new Uint8Array(2))).toBe(false);
|
||||
});
|
||||
|
||||
it("OggVorbisCodec magic accepts OggS", { timeout: 5000 }, async () => {
|
||||
const { OggVorbisCodec } = await import("./ogg-vorbis.js");
|
||||
expect(
|
||||
OggVorbisCodec.canDecode(new Uint8Array([0x4f, 0x67, 0x67, 0x53]))
|
||||
).toBe(true);
|
||||
expect(OggVorbisCodec.canDecode(new Uint8Array([0, 0, 0, 0]))).toBe(
|
||||
false
|
||||
);
|
||||
expect(OggVorbisCodec.canDecode(new Uint8Array(2))).toBe(false);
|
||||
});
|
||||
|
||||
it("OpusCodec magic accepts OggS", { timeout: 5000 }, async () => {
|
||||
const { OpusCodec } = await import("./opus.js");
|
||||
expect(
|
||||
OpusCodec.canDecode(new Uint8Array([0x4f, 0x67, 0x67, 0x53]))
|
||||
).toBe(true);
|
||||
expect(OpusCodec.canDecode(new Uint8Array(2))).toBe(false);
|
||||
});
|
||||
|
||||
it("AacCodec magic accepts ADTS sync", { timeout: 5000 }, async () => {
|
||||
const { AacCodec } = await import("./aac.js");
|
||||
expect(AacCodec.canDecode(new Uint8Array([0xff, 0xf0]))).toBe(true);
|
||||
expect(AacCodec.canDecode(new Uint8Array([0xff, 0x00]))).toBe(false);
|
||||
expect(AacCodec.canDecode(new Uint8Array(1))).toBe(false);
|
||||
});
|
||||
|
||||
it("WebMCodec magic accepts EBML header", { timeout: 5000 }, async () => {
|
||||
const { WebMCodec } = await import("./webm.js");
|
||||
expect(
|
||||
WebMCodec.canDecode(new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]))
|
||||
).toBe(true);
|
||||
expect(WebMCodec.canDecode(new Uint8Array(2))).toBe(false);
|
||||
});
|
||||
|
||||
it("QoaCodec magic accepts qoaf", { timeout: 5000 }, async () => {
|
||||
const { QoaCodec } = await import("./qoa.js");
|
||||
expect(
|
||||
QoaCodec.canDecode(new Uint8Array([0x71, 0x6f, 0x61, 0x66]))
|
||||
).toBe(true);
|
||||
expect(QoaCodec.canDecode(new Uint8Array(2))).toBe(false);
|
||||
});
|
||||
|
||||
it("decode-only codecs (Ogg/AAC/WebM) throw NOT_SUPPORTED on encode", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const [{ OggVorbisCodec }, { AacCodec }, { WebMCodec }] =
|
||||
await Promise.all([
|
||||
import("./ogg-vorbis.js"),
|
||||
import("./aac.js"),
|
||||
import("./webm.js"),
|
||||
]);
|
||||
for (const codec of [OggVorbisCodec, AacCodec, WebMCodec]) {
|
||||
await expect(
|
||||
codec.encode([new Float32Array(2)])
|
||||
).rejects.toMatchObject({ code: AUDIO_CODECS_CODES.NOT_SUPPORTED });
|
||||
}
|
||||
});
|
||||
});
|
||||
127
packages/media/audio/audio-codecs/src/codecs/wasm-base.ts
Normal file
127
packages/media/audio/audio-codecs/src/codecs/wasm-base.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
AudioCodec,
|
||||
DecodeResult,
|
||||
EncodeOpts,
|
||||
} from "../registry/registry.types.js";
|
||||
|
||||
/**
|
||||
* Shape every WASM decoder/encoder module is expected to expose. Concrete
|
||||
* `optionalDependencies` (`@audio/decode-mp3`, `@audio/decode-flac`, …) are
|
||||
* adapted to this shape by the wrapper codecs.
|
||||
*/
|
||||
export interface WasmCodecModule {
|
||||
readonly decode?: (
|
||||
bytes: Uint8Array
|
||||
) => Promise<DecodeResult> | DecodeResult;
|
||||
readonly encode?: (
|
||||
channelData: readonly Float32Array[],
|
||||
opts?: EncodeOpts
|
||||
) => Promise<Uint8Array> | Uint8Array;
|
||||
readonly canDecode?: (bytes: Uint8Array) => boolean;
|
||||
}
|
||||
|
||||
export interface WasmCodecSpec {
|
||||
readonly name: string;
|
||||
readonly extensions: readonly string[];
|
||||
readonly mimeTypes: readonly string[];
|
||||
readonly module: string;
|
||||
readonly magic: (bytes: Uint8Array) => boolean;
|
||||
readonly supportsEncode: boolean;
|
||||
/**
|
||||
* Dynamic import wrapper. Concrete codec files supply an inline
|
||||
* `() => import("@audio/decode-mp3")` to keep static analysis happy
|
||||
* while still deferring the resolution until first use.
|
||||
*/
|
||||
readonly load: () => Promise<WasmCodecModule>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an `AudioCodec` that lazily resolves its WASM module on first use.
|
||||
* If the module's dynamic `import()` rejects (typically because the
|
||||
* `optionalDependency` is not installed), every method throws
|
||||
* `AUDIO_CODECS.NOT_SUPPORTED` with the module name in `context`.
|
||||
*/
|
||||
export function createWasmCodec(spec: WasmCodecSpec): AudioCodec {
|
||||
let modPromise: Promise<WasmCodecModule> | undefined;
|
||||
|
||||
function load(): Promise<WasmCodecModule> {
|
||||
if (modPromise === undefined) {
|
||||
modPromise = spec.load().catch((err: unknown) => {
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.wasm.missing-module",
|
||||
{ cause: err, context: { module: spec.module } }
|
||||
);
|
||||
});
|
||||
}
|
||||
return modPromise;
|
||||
}
|
||||
|
||||
return {
|
||||
name: spec.name,
|
||||
extensions: spec.extensions,
|
||||
mimeTypes: spec.mimeTypes,
|
||||
canDecode(bytes) {
|
||||
return spec.magic(bytes);
|
||||
},
|
||||
async decode(bytes): Promise<DecodeResult> {
|
||||
const mod = await load();
|
||||
if (!mod.decode)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.wasm.no-decode",
|
||||
{ context: { module: spec.module, format: spec.name } }
|
||||
);
|
||||
try {
|
||||
const result = await mod.decode(bytes);
|
||||
return {
|
||||
channelData: result.channelData,
|
||||
sampleRate: result.sampleRate,
|
||||
format: spec.name,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AudioCodecsError) throw err;
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.wasm.decode-failed",
|
||||
{
|
||||
cause: err,
|
||||
context: { module: spec.module, format: spec.name },
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
async encode(channelData, opts): Promise<Uint8Array> {
|
||||
if (!spec.supportsEncode)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.wasm.encode-not-supported",
|
||||
{ context: { format: spec.name } }
|
||||
);
|
||||
const mod = await load();
|
||||
if (!mod.encode)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.wasm.no-encode",
|
||||
{ context: { module: spec.module, format: spec.name } }
|
||||
);
|
||||
try {
|
||||
return await mod.encode(channelData, opts);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioCodecsError) throw err;
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.ENCODE_FAILED,
|
||||
"audio-codecs.wasm.encode-failed",
|
||||
{
|
||||
cause: err,
|
||||
context: { module: spec.module, format: spec.name },
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
150
packages/media/audio/audio-codecs/src/codecs/wav.md
Normal file
150
packages/media/audio/audio-codecs/src/codecs/wav.md
Normal 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.
|
||||
330
packages/media/audio/audio-codecs/src/codecs/wav.test.ts
Normal file
330
packages/media/audio/audio-codecs/src/codecs/wav.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { WavCodec } from "./wav.js";
|
||||
|
||||
function makeSignal(frames: number, channels: number): Float32Array[] {
|
||||
const result: Float32Array[] = [];
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const buf = new Float32Array(frames);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
buf[i] = Math.sin((2 * Math.PI * (110 + 110 * c) * i) / 44_100);
|
||||
}
|
||||
result.push(buf);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("WavCodec", () => {
|
||||
it("declares name, extensions, mimeTypes", { timeout: 5000 }, () => {
|
||||
expect(WavCodec.name).toBe("wav");
|
||||
expect(WavCodec.extensions).toContain("wav");
|
||||
expect(WavCodec.mimeTypes).toContain("audio/wav");
|
||||
});
|
||||
|
||||
it("canDecode accepts RIFF/WAVE", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array(12);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x46464952, true);
|
||||
dv.setUint32(8, 0x45564157, true);
|
||||
expect(WavCodec.canDecode(bytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("canDecode rejects non-WAV bytes", { timeout: 5000 }, () => {
|
||||
expect(WavCodec.canDecode(new Uint8Array(4))).toBe(false);
|
||||
expect(WavCodec.canDecode(new Uint8Array(20).fill(0xff))).toBe(false);
|
||||
});
|
||||
|
||||
it("16-bit PCM decode-then-encode round-trips", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channels = 2;
|
||||
const frames = 4096;
|
||||
const pcm = makeSignal(frames, channels);
|
||||
const encoded = await WavCodec.encode(pcm, {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const decoded = await WavCodec.decode(encoded);
|
||||
expect(decoded.format).toBe("wav");
|
||||
expect(decoded.sampleRate).toBe(44_100);
|
||||
expect(decoded.channelData.length).toBe(channels);
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const orig = pcm[c]!;
|
||||
const got = decoded.channelData[c]!;
|
||||
// 16-bit quantisation: tolerate ±1 LSB at unit scale.
|
||||
for (let i = 0; i < frames; i++) {
|
||||
expect(Math.abs(orig[i]! - got[i]!)).toBeLessThan(2 / 32_768);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("32-bit float encode preserves PCM exactly", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channels = 1;
|
||||
const frames = 1024;
|
||||
const pcm = makeSignal(frames, channels);
|
||||
const encoded = await WavCodec.encode(pcm, {
|
||||
sampleRate: 48_000,
|
||||
bitDepth: 32,
|
||||
format: "float",
|
||||
});
|
||||
const decoded = await WavCodec.decode(encoded);
|
||||
expect(decoded.sampleRate).toBe(48_000);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
expect(decoded.channelData[0]![i]).toBeCloseTo(pcm[0]![i]!, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it("decodes 8-bit unsigned PCM", { timeout: 5000 }, async () => {
|
||||
const pcm = [new Float32Array([0.5, -0.5, 0.25, -0.25])];
|
||||
const encoded = await WavCodec.encode(pcm, {
|
||||
sampleRate: 8000,
|
||||
bitDepth: 8,
|
||||
format: "pcm",
|
||||
});
|
||||
const decoded = await WavCodec.decode(encoded);
|
||||
expect(decoded.sampleRate).toBe(8000);
|
||||
expect(decoded.channelData[0]!.length).toBe(4);
|
||||
});
|
||||
|
||||
it("decodes 24-bit signed PCM", { timeout: 5000 }, async () => {
|
||||
const pcm = [new Float32Array([0.1, -0.1, 0.7, -0.7])];
|
||||
const encoded = await WavCodec.encode(pcm, {
|
||||
sampleRate: 22_050,
|
||||
bitDepth: 24,
|
||||
format: "pcm",
|
||||
});
|
||||
const decoded = await WavCodec.decode(encoded);
|
||||
expect(decoded.channelData[0]!.length).toBe(4);
|
||||
});
|
||||
|
||||
it("uses 44100 Hz default when no sampleRate provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pcm = [new Float32Array([0.1, -0.1])];
|
||||
const encoded = await WavCodec.encode(pcm);
|
||||
const decoded = await WavCodec.decode(encoded);
|
||||
expect(decoded.sampleRate).toBe(44_100);
|
||||
});
|
||||
|
||||
it("throws DECODE_FAILED on too-short buffer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
WavCodec.decode(new Uint8Array(10))
|
||||
).rejects.toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("throws DECODE_FAILED on bad RIFF magic", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(44);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0xdeadbeef, true);
|
||||
await expect(WavCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws DECODE_FAILED on bad WAVE magic", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(44);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x46464952, true);
|
||||
dv.setUint32(8, 0xdeadbeef, true);
|
||||
await expect(WavCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws DECODE_FAILED when data length exceeds buffer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pcm = [new Float32Array(8)];
|
||||
const encoded = await WavCodec.encode(pcm, {
|
||||
sampleRate: 8000,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
// Inflate the declared data chunk size.
|
||||
const dv = new DataView(encoded.buffer);
|
||||
dv.setUint32(40, 0xff_ff_00_00, true);
|
||||
await expect(WavCodec.decode(encoded)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws DECODE_FAILED when fmt chunk is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(44);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x46464952, true);
|
||||
dv.setUint32(4, 36, true);
|
||||
dv.setUint32(8, 0x45564157, true);
|
||||
// No fmt or data chunks → walker exits without finding fmt.
|
||||
dv.setUint32(12, 0x4c495354, true); // "LIST" — unknown chunk
|
||||
dv.setUint32(16, 4, true);
|
||||
await expect(WavCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws DECODE_FAILED when fmt size < 16", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(60);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x46464952, true);
|
||||
dv.setUint32(4, 52, true);
|
||||
dv.setUint32(8, 0x45564157, true);
|
||||
dv.setUint32(12, 0x20746d66, true); // "fmt "
|
||||
dv.setUint32(16, 8, true); // size too small
|
||||
await expect(WavCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws DECODE_FAILED when data chunk is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(36);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x46464952, true);
|
||||
dv.setUint32(4, 28, true);
|
||||
dv.setUint32(8, 0x45564157, true);
|
||||
dv.setUint32(12, 0x20746d66, true);
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, 1, true);
|
||||
dv.setUint16(22, 1, true);
|
||||
dv.setUint32(24, 44_100, true);
|
||||
await expect(WavCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("encode throws when channelData is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(WavCodec.encode([])).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.ENCODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("streaming decoder reads chunked WAV bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pcm = makeSignal(2048, 1);
|
||||
const encoded = await WavCodec.encode(pcm, {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const decoder = WavCodec.createDecoder!();
|
||||
const chunkSize = 137;
|
||||
const chunks: Float32Array[] = [];
|
||||
for (let off = 0; off < encoded.byteLength; off += chunkSize) {
|
||||
const chunk = encoded.subarray(off, off + chunkSize);
|
||||
for (const r of decoder.write(chunk)) {
|
||||
chunks.push(r.channelData[0]!);
|
||||
}
|
||||
}
|
||||
for (const r of decoder.flush()) chunks.push(r.channelData[0]!);
|
||||
decoder.close();
|
||||
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
||||
expect(totalLen).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("streaming decoder close releases buffers without prior parse", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const decoder = WavCodec.createDecoder!();
|
||||
decoder.close();
|
||||
// flush after close should still be safe and yield empty.
|
||||
expect(decoder.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("streaming encoder produces complete WAV file", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pcm = makeSignal(1024, 2);
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const header = encoder.start(44_100, 2);
|
||||
expect(header.byteLength).toBe(0); // WAV header written by flush
|
||||
encoder.write(pcm);
|
||||
const final = encoder.flush();
|
||||
encoder.close();
|
||||
expect(final.byteLength).toBeGreaterThan(44);
|
||||
const decoded = await WavCodec.decode(final);
|
||||
expect(decoded.channelData.length).toBe(2);
|
||||
expect(decoded.channelData[0]!.length).toBe(1024);
|
||||
});
|
||||
|
||||
it("streaming encoder defaults bitDepth to 16", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!();
|
||||
encoder.start(8000, 1);
|
||||
encoder.write([new Float32Array([0.1, -0.1])]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
const decoded = await WavCodec.decode(out);
|
||||
expect(decoded.channelData[0]!.length).toBe(2);
|
||||
});
|
||||
|
||||
it("streaming encoder supports 32-bit float", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 48_000,
|
||||
bitDepth: 32,
|
||||
format: "float",
|
||||
});
|
||||
encoder.start(48_000, 1);
|
||||
encoder.write([new Float32Array([0.1, -0.1])]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
const decoded = await WavCodec.decode(out);
|
||||
expect(decoded.sampleRate).toBe(48_000);
|
||||
});
|
||||
|
||||
it("streaming encoder supports 8-bit PCM", { timeout: 5000 }, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 8000,
|
||||
bitDepth: 8,
|
||||
format: "pcm",
|
||||
});
|
||||
encoder.start(8000, 1);
|
||||
encoder.write([new Float32Array([0.5, -0.5])]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
const decoded = await WavCodec.decode(out);
|
||||
expect(decoded.sampleRate).toBe(8000);
|
||||
});
|
||||
|
||||
it("streaming encoder supports 32-bit int PCM", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 96_000,
|
||||
bitDepth: 32,
|
||||
format: "pcm",
|
||||
});
|
||||
encoder.start(96_000, 1);
|
||||
encoder.write([new Float32Array([0.1, -0.1])]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
const decoded = await WavCodec.decode(out);
|
||||
expect(decoded.sampleRate).toBe(96_000);
|
||||
});
|
||||
});
|
||||
402
packages/media/audio/audio-codecs/src/codecs/wav.ts
Normal file
402
packages/media/audio/audio-codecs/src/codecs/wav.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
AudioCodec,
|
||||
DecodeOpts,
|
||||
DecodeResult,
|
||||
EncodeOpts,
|
||||
StreamingDecoder,
|
||||
StreamingEncoder,
|
||||
} from "../registry/registry.types.js";
|
||||
|
||||
const RIFF = 0x46464952; // "RIFF" little-endian
|
||||
const WAVE = 0x45564157; // "WAVE"
|
||||
const FMT_ = 0x20746d66; // "fmt "
|
||||
const DATA = 0x61746164; // "data"
|
||||
const FORMAT_PCM = 0x0001;
|
||||
const FORMAT_FLOAT = 0x0003;
|
||||
|
||||
interface FmtChunk {
|
||||
readonly format: number;
|
||||
readonly channels: number;
|
||||
readonly sampleRate: number;
|
||||
readonly bitsPerSample: number;
|
||||
}
|
||||
|
||||
function dvU32LE(dv: DataView, off: number): number {
|
||||
return dv.getUint32(off, true);
|
||||
}
|
||||
|
||||
function parseHeader(
|
||||
bytes: Uint8Array,
|
||||
options: { lenient?: boolean } = {}
|
||||
): {
|
||||
fmt: FmtChunk;
|
||||
dataOffset: number;
|
||||
dataLength: number;
|
||||
} {
|
||||
if (bytes.byteLength < 44)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.wav.too-short",
|
||||
{ context: { length: bytes.byteLength } }
|
||||
);
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (dvU32LE(dv, 0) !== RIFF)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.wav.bad-riff",
|
||||
{}
|
||||
);
|
||||
if (dvU32LE(dv, 8) !== WAVE)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.wav.bad-wave",
|
||||
{}
|
||||
);
|
||||
let pos = 12;
|
||||
let fmt: FmtChunk | undefined;
|
||||
let dataOffset = -1;
|
||||
let dataLength = -1;
|
||||
while (pos + 8 <= bytes.byteLength) {
|
||||
const id = dvU32LE(dv, pos);
|
||||
const size = dvU32LE(dv, pos + 4);
|
||||
const body = pos + 8;
|
||||
if (id === FMT_) {
|
||||
if (size < 16)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.wav.bad-fmt-size",
|
||||
{}
|
||||
);
|
||||
fmt = {
|
||||
format: dv.getUint16(body, true),
|
||||
channels: dv.getUint16(body + 2, true),
|
||||
sampleRate: dv.getUint32(body + 4, true),
|
||||
bitsPerSample: dv.getUint16(body + 14, true),
|
||||
};
|
||||
} else if (id === DATA) {
|
||||
dataOffset = body;
|
||||
dataLength = size;
|
||||
break;
|
||||
}
|
||||
pos = body + size + (size & 1);
|
||||
}
|
||||
if (!fmt)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.wav.no-fmt",
|
||||
{}
|
||||
);
|
||||
if (dataOffset < 0)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.wav.no-data",
|
||||
{}
|
||||
);
|
||||
if (!options.lenient && dataOffset + dataLength > bytes.byteLength)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.wav.truncated-data",
|
||||
{
|
||||
context: {
|
||||
dataLength,
|
||||
available: bytes.byteLength - dataOffset,
|
||||
},
|
||||
}
|
||||
);
|
||||
return { fmt, dataOffset, dataLength };
|
||||
}
|
||||
|
||||
function bytesPerSample(bitsPerSample: number): number {
|
||||
return bitsPerSample >> 3;
|
||||
}
|
||||
|
||||
function decodePcm(
|
||||
bytes: Uint8Array,
|
||||
fmt: FmtChunk,
|
||||
dataOffset: number,
|
||||
dataLength: number
|
||||
): Float32Array[] {
|
||||
const bps = bytesPerSample(fmt.bitsPerSample);
|
||||
if (bps === 0)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.wav.unsupported-bits",
|
||||
{ context: { bits: fmt.bitsPerSample } }
|
||||
);
|
||||
const frameBytes = bps * fmt.channels;
|
||||
const frames = Math.floor(dataLength / frameBytes);
|
||||
const ch: Float32Array[] = Array.from(
|
||||
{ length: fmt.channels },
|
||||
() => new Float32Array(frames)
|
||||
);
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const isFloat = fmt.format === FORMAT_FLOAT;
|
||||
const norm = (1 << (fmt.bitsPerSample - 1)) >>> 0;
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (let c = 0; c < fmt.channels; c++) {
|
||||
const off = dataOffset + f * frameBytes + c * bps;
|
||||
let sample: number;
|
||||
if (isFloat && bps === 4) sample = dv.getFloat32(off, true);
|
||||
else if (bps === 1) sample = (dv.getUint8(off) - 128) / 128;
|
||||
else if (bps === 2) sample = dv.getInt16(off, true) / norm;
|
||||
else if (bps === 3) {
|
||||
const b0 = dv.getUint8(off);
|
||||
const b1 = dv.getUint8(off + 1);
|
||||
const b2 = dv.getInt8(off + 2);
|
||||
sample = ((b2 << 16) | (b1 << 8) | b0) / norm;
|
||||
} else if (bps === 4) sample = dv.getInt32(off, true) / norm;
|
||||
else
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.wav.unsupported-bytes-per-sample",
|
||||
{ context: { bps } }
|
||||
);
|
||||
ch[c]![f] = sample;
|
||||
}
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
function encodePcm(
|
||||
channelData: readonly Float32Array[],
|
||||
sampleRate: number,
|
||||
bitDepth: 8 | 16 | 24 | 32,
|
||||
asFloat: boolean
|
||||
): Uint8Array {
|
||||
const channels = channelData.length;
|
||||
const firstChannel = channelData[0];
|
||||
if (channels === 0 || firstChannel === undefined)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.ENCODE_FAILED,
|
||||
"audio-codecs.wav.no-channels",
|
||||
{}
|
||||
);
|
||||
const frames = firstChannel.length;
|
||||
const bps = bitDepth >> 3;
|
||||
const frameBytes = bps * channels;
|
||||
const dataLen = frames * frameBytes;
|
||||
const out = new Uint8Array(44 + dataLen);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint32(0, RIFF, true);
|
||||
dv.setUint32(4, 36 + dataLen, true);
|
||||
dv.setUint32(8, WAVE, true);
|
||||
dv.setUint32(12, FMT_, true);
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, asFloat ? FORMAT_FLOAT : FORMAT_PCM, true);
|
||||
dv.setUint16(22, channels, true);
|
||||
dv.setUint32(24, sampleRate, true);
|
||||
dv.setUint32(28, sampleRate * frameBytes, true);
|
||||
dv.setUint16(32, frameBytes, true);
|
||||
dv.setUint16(34, bitDepth, true);
|
||||
dv.setUint32(36, DATA, true);
|
||||
dv.setUint32(40, dataLen, true);
|
||||
const max = (1 << (bitDepth - 1)) - 1;
|
||||
const writer = asFloat && bps === 4 ? pcmWriters.f32 : pcmWriters[bps]!;
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const off = 44 + f * frameBytes + c * bps;
|
||||
// Per the loop bounds (channels = channelData.length and
|
||||
// frames = firstChannel.length), channelData[c][f] is always
|
||||
// defined for non-ragged inputs. Ragged channels are out of
|
||||
// contract — caller's responsibility.
|
||||
const channel = channelData[c]!;
|
||||
const sample = channel[f]!;
|
||||
writer(dv, off, sample, max);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type PcmWriter = (
|
||||
dv: DataView,
|
||||
off: number,
|
||||
sample: number,
|
||||
max: number
|
||||
) => void;
|
||||
|
||||
const pcmWriters: Record<number | "f32", PcmWriter> = {
|
||||
1: (dv, off, s) => dv.setUint8(off, Math.round(s * 127) + 128),
|
||||
2: (dv, off, s, max) =>
|
||||
dv.setInt16(off, Math.round(Math.max(-1, Math.min(1, s)) * max), true),
|
||||
3: (dv, off, s, max) => {
|
||||
const v = Math.round(Math.max(-1, Math.min(1, s)) * max);
|
||||
dv.setUint8(off, v & 0xff);
|
||||
dv.setUint8(off + 1, (v >> 8) & 0xff);
|
||||
dv.setInt8(off + 2, (v >> 16) & 0xff);
|
||||
},
|
||||
4: (dv, off, s, max) =>
|
||||
dv.setInt32(off, Math.round(Math.max(-1, Math.min(1, s)) * max), true),
|
||||
f32: (dv, off, s) => dv.setFloat32(off, s, true),
|
||||
};
|
||||
|
||||
function createWavStreamingDecoder(): StreamingDecoder {
|
||||
let buf = new Uint8Array(0);
|
||||
let headerDone = false;
|
||||
let fmt: FmtChunk | undefined;
|
||||
let dataOffset = 0;
|
||||
let consumedFrames = 0;
|
||||
|
||||
function append(chunk: Uint8Array): void {
|
||||
const next = new Uint8Array(buf.byteLength + chunk.byteLength);
|
||||
next.set(buf, 0);
|
||||
next.set(chunk, buf.byteLength);
|
||||
buf = next;
|
||||
}
|
||||
|
||||
function tryParseHeader(): boolean {
|
||||
if (buf.byteLength < 44) return false;
|
||||
const parsed = parseHeader(buf, { lenient: true });
|
||||
fmt = parsed.fmt;
|
||||
dataOffset = parsed.dataOffset;
|
||||
headerDone = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function emitChunk(): DecodeResult[] {
|
||||
// Callers gate emitChunk on `headerDone && tryParseHeader()` —
|
||||
// fmt is always defined here.
|
||||
const f = fmt!;
|
||||
const bps = bytesPerSample(f.bitsPerSample);
|
||||
const frameBytes = bps * f.channels;
|
||||
const available =
|
||||
buf.byteLength - dataOffset - consumedFrames * frameBytes;
|
||||
if (available < frameBytes) return [];
|
||||
const frames = Math.floor(available / frameBytes);
|
||||
const slice = buf.subarray(
|
||||
dataOffset + consumedFrames * frameBytes,
|
||||
dataOffset + (consumedFrames + frames) * frameBytes
|
||||
);
|
||||
const ch = decodePcm(slice, f, 0, slice.byteLength);
|
||||
consumedFrames += frames;
|
||||
return [{ channelData: ch, sampleRate: f.sampleRate, format: "wav" }];
|
||||
}
|
||||
|
||||
return {
|
||||
write(chunk) {
|
||||
append(chunk);
|
||||
if (!headerDone && !tryParseHeader()) return [];
|
||||
return emitChunk();
|
||||
},
|
||||
flush() {
|
||||
if (!headerDone && !tryParseHeader()) return [];
|
||||
return emitChunk();
|
||||
},
|
||||
close() {
|
||||
buf = new Uint8Array(0);
|
||||
headerDone = false;
|
||||
fmt = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWavStreamingEncoder(opts?: EncodeOpts): StreamingEncoder {
|
||||
const bitDepth = opts?.bitDepth ?? 16;
|
||||
const asFloat = opts?.format === "float";
|
||||
let sampleRate = 0;
|
||||
let channels = 0;
|
||||
let framesWritten = 0;
|
||||
let chunks: Uint8Array[] = [];
|
||||
|
||||
return {
|
||||
start(sr, ch) {
|
||||
sampleRate = sr;
|
||||
channels = ch;
|
||||
// Header is written by flush() once we know the total frame count.
|
||||
return new Uint8Array(0);
|
||||
},
|
||||
write(channelData) {
|
||||
const bps = bitDepth >> 3;
|
||||
const frames = channelData[0]?.length ?? 0;
|
||||
const frameBytes = bps * channels;
|
||||
const out = new Uint8Array(frames * frameBytes);
|
||||
const dv = new DataView(out.buffer);
|
||||
const max = (1 << (bitDepth - 1)) - 1;
|
||||
const writer =
|
||||
asFloat && bps === 4 ? pcmWriters.f32 : pcmWriters[bps]!;
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const off = f * frameBytes + c * bps;
|
||||
const s = channelData[c]?.[f] ?? 0;
|
||||
writer(dv, off, s, max);
|
||||
}
|
||||
}
|
||||
framesWritten += frames;
|
||||
chunks.push(out);
|
||||
return new Uint8Array(0);
|
||||
},
|
||||
flush() {
|
||||
const bps = bitDepth >> 3;
|
||||
const frameBytes = bps * channels;
|
||||
const dataLen = framesWritten * frameBytes;
|
||||
const header = new Uint8Array(44);
|
||||
const dv = new DataView(header.buffer);
|
||||
dv.setUint32(0, RIFF, true);
|
||||
dv.setUint32(4, 36 + dataLen, true);
|
||||
dv.setUint32(8, WAVE, true);
|
||||
dv.setUint32(12, FMT_, true);
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, asFloat ? FORMAT_FLOAT : FORMAT_PCM, true);
|
||||
dv.setUint16(22, channels, true);
|
||||
dv.setUint32(24, sampleRate, true);
|
||||
dv.setUint32(28, sampleRate * frameBytes, true);
|
||||
dv.setUint16(32, frameBytes, true);
|
||||
dv.setUint16(34, bitDepth, true);
|
||||
dv.setUint32(36, DATA, true);
|
||||
dv.setUint32(40, dataLen, true);
|
||||
const total = 44 + dataLen;
|
||||
const out = new Uint8Array(total);
|
||||
out.set(header, 0);
|
||||
let off = 44;
|
||||
for (const c of chunks) {
|
||||
out.set(c, off);
|
||||
off += c.byteLength;
|
||||
}
|
||||
chunks = [];
|
||||
return out;
|
||||
},
|
||||
close() {
|
||||
chunks = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Native WAV codec — full decode / encode / streaming with no external deps.
|
||||
* Supports 8-bit unsigned PCM, 16-bit signed PCM, 24-bit signed PCM, 32-bit
|
||||
* signed PCM, and 32-bit IEEE 754 float.
|
||||
*/
|
||||
export const WavCodec: AudioCodec = {
|
||||
name: "wav",
|
||||
extensions: ["wav", "wave"],
|
||||
mimeTypes: ["audio/wav", "audio/wave", "audio/x-wav"],
|
||||
canDecode(bytes) {
|
||||
if (bytes.byteLength < 12) return false;
|
||||
const dv = new DataView(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength
|
||||
);
|
||||
return dvU32LE(dv, 0) === RIFF && dvU32LE(dv, 8) === WAVE;
|
||||
},
|
||||
async decode(bytes): Promise<DecodeResult> {
|
||||
const { fmt, dataOffset, dataLength } = parseHeader(bytes);
|
||||
const channelData = decodePcm(bytes, fmt, dataOffset, dataLength);
|
||||
return { channelData, sampleRate: fmt.sampleRate, format: "wav" };
|
||||
},
|
||||
async encode(channelData, opts: EncodeOpts = {}): Promise<Uint8Array> {
|
||||
const bitDepth = opts.bitDepth ?? 16;
|
||||
const asFloat = opts.format === "float";
|
||||
const sampleRate = opts.sampleRate ?? 44_100;
|
||||
return encodePcm(channelData, sampleRate, bitDepth, asFloat);
|
||||
},
|
||||
createDecoder(_opts?: DecodeOpts) {
|
||||
return createWavStreamingDecoder();
|
||||
},
|
||||
createEncoder(opts?: EncodeOpts) {
|
||||
return createWavStreamingEncoder(opts);
|
||||
},
|
||||
};
|
||||
74
packages/media/audio/audio-codecs/src/codecs/webm.md
Normal file
74
packages/media/audio/audio-codecs/src/codecs/webm.md
Normal 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`.
|
||||
25
packages/media/audio/audio-codecs/src/codecs/webm.ts
Normal file
25
packages/media/audio/audio-codecs/src/codecs/webm.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createWasmCodec } from "./wasm-base.js";
|
||||
|
||||
function webmMagic(bytes: Uint8Array): boolean {
|
||||
// EBML header: 0x1A 0x45 0xDF 0xA3
|
||||
if (bytes.byteLength < 4) return false;
|
||||
return (
|
||||
bytes[0] === 0x1a &&
|
||||
bytes[1] === 0x45 &&
|
||||
bytes[2] === 0xdf &&
|
||||
bytes[3] === 0xa3
|
||||
);
|
||||
}
|
||||
|
||||
/** Lazy WebM (audio track) codec backed by `@audio/decode-webm`. Decode-only. */
|
||||
export const WebMCodec = createWasmCodec({
|
||||
name: "webm",
|
||||
extensions: ["webm"],
|
||||
mimeTypes: ["audio/webm"],
|
||||
module: "@audio/decode-webm",
|
||||
magic: webmMagic,
|
||||
supportsEncode: false,
|
||||
load: () =>
|
||||
// @ts-expect-error optional dependency resolved at runtime
|
||||
import("@audio/decode-webm"),
|
||||
});
|
||||
362
packages/media/audio/audio-codecs/src/coverage-100.test.ts
Normal file
362
packages/media/audio/audio-codecs/src/coverage-100.test.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { AudioDocument } from "@suaveplan/audio-core";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WavCodec } from "./codecs/wav.js";
|
||||
import { decodeSource } from "./decode/decode-source.js";
|
||||
import { detectSource } from "./detect/detect-format.js";
|
||||
import { AUDIO_CODECS_CODES } from "./error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "./registry/registry.js";
|
||||
import { createDecodeTransform } from "./stream/stream-decoder.js";
|
||||
import { createEncodeTransform } from "./stream/stream-encoder.js";
|
||||
|
||||
describe("coverage-100 — close remaining branch gaps", () => {
|
||||
it("detect-format asUint8: ArrayBuffer branch returns Uint8Array view", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ab = new ArrayBuffer(64);
|
||||
const view = new Uint8Array(ab);
|
||||
view[0] = 0x52;
|
||||
view[1] = 0x49;
|
||||
view[2] = 0x46;
|
||||
view[3] = 0x46;
|
||||
const result = await detectSource(ab);
|
||||
expect(result.bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(result.bytes!.byteLength).toBe(64);
|
||||
});
|
||||
|
||||
it("decode-source: no opts at all hits default-param + registry-fallback branches", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const wav = await WavCodec.encode([new Float32Array([0.1, 0.2, 0.3])], {
|
||||
sampleRate: 22_050,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const doc = await decodeSource(wav);
|
||||
expect(doc.sourceLength).toBe(3);
|
||||
});
|
||||
|
||||
it("registry resolveCodec: hint not found falls through to magic detect", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await WavCodec.encode([new Float32Array(4)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
// Hint "ghost" not registered → falls through to magic-byte detection
|
||||
// → finds wav. Covers the `if (c) return c;` false branch.
|
||||
const doc = await r.decode(wav, { format: "ghost" });
|
||||
expect(doc.sourceLength).toBe(4);
|
||||
});
|
||||
|
||||
it("stream-decoder: empty channelData hits ?? 0 fallback (transform + flush)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let emitOnce = false;
|
||||
const emptyChannelCodec = {
|
||||
...WavCodec,
|
||||
name: "empty-channel",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createDecoder: () => ({
|
||||
write: () => {
|
||||
if (emitOnce) return [];
|
||||
emitOnce = true;
|
||||
return [
|
||||
{
|
||||
channelData: [] as Float32Array[],
|
||||
sampleRate: 44_100,
|
||||
format: "empty-channel",
|
||||
},
|
||||
];
|
||||
},
|
||||
flush: () => [
|
||||
{
|
||||
channelData: [] as Float32Array[],
|
||||
sampleRate: 44_100,
|
||||
format: "empty-channel",
|
||||
},
|
||||
],
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [emptyChannelCodec] });
|
||||
const transform = createDecodeTransform("empty-channel", r);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
const pages: unknown[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) pages.push(value);
|
||||
}
|
||||
expect(pages.length).toBe(2);
|
||||
});
|
||||
|
||||
it("stream-encoder: empty flush tail hits byteLength === 0 branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const emptyFlushCodec = {
|
||||
...WavCodec,
|
||||
name: "empty-flush",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createEncoder: () => ({
|
||||
start: () => new Uint8Array([0x01]),
|
||||
write: () => new Uint8Array([0x02]),
|
||||
flush: () => new Uint8Array(0),
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [emptyFlushCodec] });
|
||||
const transform = createEncodeTransform("empty-flush", 8000, 1, r);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.enqueue([new Float32Array(2)]);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) chunks.push(value);
|
||||
}
|
||||
// start + write only (flush emits nothing)
|
||||
expect(chunks.reduce((s, c) => s + c.byteLength, 0)).toBe(2);
|
||||
});
|
||||
|
||||
it("wav streaming encoder: empty channelData input hits ?? 0 frames branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
encoder.start(44_100, 1);
|
||||
// channelData[0]?.length → undefined?.length → ?? 0 → 0 frames.
|
||||
encoder.write([]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
expect(out.byteLength).toBeGreaterThan(0); // header only
|
||||
});
|
||||
|
||||
it("wav streaming encoder: channels > channelData.length hits channelData[c] undefined branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 22_050,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
// Encoder configured for 2 channels but write only supplies 1 — for
|
||||
// c=1 → channelData[1] is undefined → `?.[f] ?? 0` taken.
|
||||
encoder.start(22_050, 2);
|
||||
encoder.write([new Float32Array([0.5, -0.5])]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
expect(out.byteLength).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("wav streaming encoder: bps === 4 integer (non-float) branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 32,
|
||||
format: "pcm",
|
||||
});
|
||||
encoder.start(44_100, 1);
|
||||
encoder.write([new Float32Array([0.25, -0.25])]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
// Round-trip via decoder validates the 32-bit signed PCM path.
|
||||
const decoded = await WavCodec.decode(out);
|
||||
expect(decoded.channelData[0]!.length).toBe(2);
|
||||
});
|
||||
|
||||
it("registry.unregister: leaves ext/mime mappings owned by other codecs intact", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = {
|
||||
...WavCodec,
|
||||
name: "ext-owner-a",
|
||||
extensions: ["shared.ext"],
|
||||
mimeTypes: ["audio/shared"],
|
||||
};
|
||||
const b = {
|
||||
...WavCodec,
|
||||
name: "ext-owner-b",
|
||||
extensions: ["shared.ext"],
|
||||
mimeTypes: ["audio/shared"],
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [a, b] });
|
||||
// After registering b, byExt["shared.ext"] points to b, not a.
|
||||
// Unregistering a should NOT delete the mapping (covers false branch).
|
||||
r.unregister("ext-owner-a");
|
||||
expect(r.get("shared.ext")?.name).toBe("ext-owner-b");
|
||||
expect(r.get("audio/shared")?.name).toBe("ext-owner-b");
|
||||
});
|
||||
|
||||
it("registry pcmFromDocument: zero-length src hits copy === 0 branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 22_050,
|
||||
channels: 1,
|
||||
sourceLength: 0,
|
||||
pages: [{ channelData: [new Float32Array(0)], startSample: 0 }],
|
||||
});
|
||||
const bytes = await r.encode(doc, "wav");
|
||||
expect(bytes.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("registry.decodeStream: empty channelData hits ?? 0 branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let emitOnce = false;
|
||||
const emptyChannelCodec = {
|
||||
...WavCodec,
|
||||
name: "ds-empty-channel",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createDecoder: () => ({
|
||||
write: () => {
|
||||
if (emitOnce) return [];
|
||||
emitOnce = true;
|
||||
return [
|
||||
{
|
||||
channelData: [] as Float32Array[],
|
||||
sampleRate: 44_100,
|
||||
format: "ds-empty-channel",
|
||||
},
|
||||
];
|
||||
},
|
||||
flush: () => [],
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [emptyChannelCodec] });
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = r.decodeStream(source, "ds-empty-channel");
|
||||
const reader = out.getReader();
|
||||
const pages: unknown[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) pages.push(value);
|
||||
}
|
||||
expect(pages.length).toBe(1);
|
||||
});
|
||||
|
||||
it("wav non-streaming encode bps === 4 integer path round-trips", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// The non-streaming `WavCodec.encode` is reached via registry.encode
|
||||
// when no opts.format is "float". bitDepth=32 + format="pcm" hits the
|
||||
// `else if (bps === 4)` integer branch in the non-streaming writer.
|
||||
const bytes = await WavCodec.encode(
|
||||
[new Float32Array([0.25, -0.25, 0.1])],
|
||||
{
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 32,
|
||||
format: "pcm",
|
||||
}
|
||||
);
|
||||
const decoded = await WavCodec.decode(bytes);
|
||||
expect(decoded.channelData[0]!.length).toBe(3);
|
||||
});
|
||||
|
||||
it("registry.decode buildDocument: empty channelData array rejects with DECODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const emptyDecCodec = {
|
||||
...WavCodec,
|
||||
name: "empty-dec",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
canDecode: () => true,
|
||||
decode: async () => ({
|
||||
channelData: [] as Float32Array[],
|
||||
sampleRate: 44_100,
|
||||
format: "empty-dec",
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [emptyDecCodec] });
|
||||
await expect(
|
||||
r.decode(new Uint8Array(8), { format: "empty-dec" })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("registry.decode buildDocument: sparse channelData hits !src continue", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sparse: Float32Array[] = [];
|
||||
sparse[0] = new Float32Array([0.1, 0.2]);
|
||||
// sparse[1] is undefined — but channelData.length === 2.
|
||||
sparse.length = 2;
|
||||
const sparseCodec = {
|
||||
...WavCodec,
|
||||
name: "sparse-ch",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
canDecode: () => true,
|
||||
decode: async () => ({
|
||||
channelData: sparse,
|
||||
sampleRate: 44_100,
|
||||
format: "sparse-ch",
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [sparseCodec] });
|
||||
const doc = await r.decode(new Uint8Array(8), { format: "sparse-ch" });
|
||||
expect(doc.channels).toBe(2);
|
||||
expect(doc.sourceLength).toBe(2);
|
||||
});
|
||||
|
||||
it("registry.encode pcmFromDocument: doc.channels > page channels hits !src continue", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Page has 1 channel but doc claims 2 — c=1 finds page.channelData[1]
|
||||
// undefined, takes the `if (!src) continue;` branch.
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 22_050,
|
||||
channels: 2,
|
||||
sourceLength: 2,
|
||||
pages: [{ channelData: [new Float32Array(2)], startSample: 0 }],
|
||||
});
|
||||
const bytes = await r.encode(doc, "wav");
|
||||
expect(bytes.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("encode round-trip with explicit AudioDocument metadata pass-through", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 22_050,
|
||||
channels: 1,
|
||||
sourceLength: 2,
|
||||
pages: [{ channelData: [new Float32Array(2)], startSample: 0 }],
|
||||
metadata: { title: "round-trip" },
|
||||
});
|
||||
const bytes = await r.encode(doc, "wav");
|
||||
expect(bytes.byteLength).toBeGreaterThan(44);
|
||||
});
|
||||
});
|
||||
376
packages/media/audio/audio-codecs/src/coverage-final.test.ts
Normal file
376
packages/media/audio/audio-codecs/src/coverage-final.test.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { AudioDocument, PAGE_SIZE } from "@suaveplan/audio-core";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CafCodec } from "./codecs/caf.js";
|
||||
import { WavCodec } from "./codecs/wav.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "./registry/registry.js";
|
||||
|
||||
describe("coverage-final — detect-format browser-runtime fallback", () => {
|
||||
it("bare path falls back to fetch when forceBrowserRuntime is set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { detectSource, __setForceBrowserRuntimeForTests } = await import(
|
||||
"./detect/detect-format.js"
|
||||
);
|
||||
const wav = await WavCodec.encode([new Float32Array(4)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const origFetch = globalThis.fetch;
|
||||
let fetchedTarget = "";
|
||||
globalThis.fetch = ((target) => {
|
||||
fetchedTarget = String(target);
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(wav);
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}) as typeof fetch;
|
||||
__setForceBrowserRuntimeForTests(true);
|
||||
try {
|
||||
const result = await detectSource("/some/bare/path.wav");
|
||||
expect(result.format).toBe("wav");
|
||||
expect(fetchedTarget).toContain("/some/bare/path.wav");
|
||||
} finally {
|
||||
__setForceBrowserRuntimeForTests(false);
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("resolveSource bare path falls back to fetch when forceBrowserRuntime is set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolveSource, __setForceBrowserRuntimeForTests } =
|
||||
await import("./detect/detect-format.js");
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array([7, 7]).buffer)
|
||||
)) as typeof fetch;
|
||||
__setForceBrowserRuntimeForTests(true);
|
||||
try {
|
||||
const out = await resolveSource("/anything.bin");
|
||||
expect(Array.from(out)).toEqual([7, 7]);
|
||||
} finally {
|
||||
__setForceBrowserRuntimeForTests(false);
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-final — caf 0-bps branch", () => {
|
||||
it("caf decode throws when bitsPerChannel is 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Full CAF buffer with desc.bitsPerChannel = 0 and a non-empty data
|
||||
// chunk so we reach decodeCafPcm's `bps === 0` throw.
|
||||
const bytes = new Uint8Array(80);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x63616666, false); // "caff"
|
||||
dv.setUint16(4, 1, false);
|
||||
// desc chunk
|
||||
dv.setUint32(8, 0x64657363, false);
|
||||
dv.setUint32(12, 0, false);
|
||||
dv.setUint32(16, 32, false);
|
||||
dv.setFloat64(20, 44_100, false);
|
||||
dv.setUint32(28, 0x6c70636d, false); // "lpcm"
|
||||
dv.setUint32(32, 0, false); // formatFlags
|
||||
dv.setUint32(36, 1, false); // bytesPerPacket
|
||||
dv.setUint32(40, 1, false); // framesPerPacket
|
||||
dv.setUint32(44, 1, false); // channels
|
||||
dv.setUint32(48, 0, false); // bitsPerChannel = 0
|
||||
// data chunk
|
||||
dv.setUint32(52, 0x64617461, false);
|
||||
dv.setUint32(56, 0, false);
|
||||
dv.setUint32(60, 8, false); // size = 8
|
||||
dv.setUint32(64, 0, false); // editCount
|
||||
// 4 bytes of payload (will be ignored once bps=0 throws)
|
||||
await expect(CafCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-final — wav streaming-encoder bit-depth branches", () => {
|
||||
it("8-bit unsigned streaming encoder round-trips", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 8000,
|
||||
bitDepth: 8,
|
||||
format: "pcm",
|
||||
});
|
||||
encoder.start(8000, 1);
|
||||
encoder.write([new Float32Array([0.5, -0.5, 0.0])]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
const decoded = await WavCodec.decode(out);
|
||||
expect(decoded.channelData[0]!.length).toBe(3);
|
||||
});
|
||||
|
||||
it("32-bit int streaming encoder round-trips", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 96_000,
|
||||
bitDepth: 32,
|
||||
format: "pcm",
|
||||
});
|
||||
encoder.start(96_000, 1);
|
||||
encoder.write([new Float32Array([0.1, -0.1])]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
const decoded = await WavCodec.decode(out);
|
||||
expect(decoded.sampleRate).toBe(96_000);
|
||||
});
|
||||
|
||||
it("32-bit float streaming encoder round-trips", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 48_000,
|
||||
bitDepth: 32,
|
||||
format: "float",
|
||||
});
|
||||
encoder.start(48_000, 1);
|
||||
encoder.write([new Float32Array([0.1, -0.1])]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
const decoded = await WavCodec.decode(out);
|
||||
expect(decoded.sampleRate).toBe(48_000);
|
||||
});
|
||||
|
||||
it("streaming encoder write+flush emits header at flush time", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 22_050,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const header = encoder.start(22_050, 2);
|
||||
expect(header.byteLength).toBe(0);
|
||||
const body = encoder.write([
|
||||
new Float32Array([0.1]),
|
||||
new Float32Array([-0.1]),
|
||||
]);
|
||||
expect(body.byteLength).toBe(0); // body collected internally
|
||||
const final = encoder.flush();
|
||||
expect(final.byteLength).toBeGreaterThan(44);
|
||||
encoder.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-final — registry empty-pages + hint short-circuit + #instrumented catch", () => {
|
||||
it("encode of doc with zero pages produces only the header", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const emptyDoc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: 0,
|
||||
pages: [],
|
||||
});
|
||||
const bytes = await r.encode(emptyDoc, "wav");
|
||||
expect(bytes.byteLength).toBe(44); // RIFF header only
|
||||
});
|
||||
|
||||
it("decode with format hint short-circuits the canDecode iteration", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await WavCodec.encode([new Float32Array(8)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
// Pass `format: "wav"` so the hint hits and #resolveCodec returns
|
||||
// immediately without iterating canDecode.
|
||||
const doc = await r.decode(wav, { format: "wav" });
|
||||
expect(doc.sourceLength).toBe(8);
|
||||
});
|
||||
|
||||
it("decode propagates AudioCodecsError thrown from inside codec.decode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const inner = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"deliberate"
|
||||
);
|
||||
const fakeWav = {
|
||||
...WavCodec,
|
||||
name: "fake-pass-through",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
canDecode: () => true,
|
||||
decode: async () => {
|
||||
throw inner;
|
||||
},
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [fakeWav] });
|
||||
await expect(r.decode(new Uint8Array(64))).rejects.toBe(inner);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-final — decode-source abort between resolve and detect", () => {
|
||||
it("aborting mid-decode after resolveSource throws STREAM_ERROR at the second checkSignal", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { decodeSource } = await import("./decode/decode-source.js");
|
||||
const wav = await WavCodec.encode([new Float32Array(4)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
// Build a stream that will resolve, then we abort before detect runs.
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(wav);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const promise = decodeSource(src, {
|
||||
registry: r,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
// Allow the promise to start, then abort.
|
||||
await Promise.resolve();
|
||||
ctrl.abort();
|
||||
await expect(promise).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-final — encode-to invalid destination + non-API error wrap", () => {
|
||||
it("encode-to rejects an invalid destination", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { encodeTo } = await import("./encode/encode-to.js");
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: 4,
|
||||
pages: [{ channelData: [new Float32Array(4)], startSample: 0 }],
|
||||
});
|
||||
await expect(
|
||||
// Number is not a recognised Destination variant.
|
||||
encodeTo(doc, 42 as unknown as string, "wav", { registry: r })
|
||||
).rejects.toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("encode-to wraps a non-API codec error as ENCODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { encodeTo } = await import("./encode/encode-to.js");
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "wrap-test",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
encode: async () => {
|
||||
throw new TypeError("synthetic codec failure");
|
||||
},
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: 2,
|
||||
pages: [{ channelData: [new Float32Array(2)], startSample: 0 }],
|
||||
});
|
||||
await expect(
|
||||
encodeTo(doc, () => {}, "wrap-test", { registry: r })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.ENCODE_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-final — stream-encoder default registry path", () => {
|
||||
it("createEncodeTransform with no registry parameter falls back to default", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const transform = createEncodeTransform("wav", 44_100, 1);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.enqueue([new Float32Array(64)]);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) total += value.byteLength;
|
||||
}
|
||||
expect(total).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("createEncodeTransform default registry rejects unknown format on stream", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const transform = createEncodeTransform("zzz", 8000, 1);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.enqueue([new Float32Array(4)]);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("createEncodeTransform default registry handles multi-page encodes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const transform = createEncodeTransform("wav", 8000, 1);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.enqueue([new Float32Array(PAGE_SIZE / 2)]);
|
||||
controller.enqueue([new Float32Array(PAGE_SIZE / 2)]);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) total += value.byteLength;
|
||||
}
|
||||
expect(total).toBeGreaterThan(44);
|
||||
});
|
||||
});
|
||||
530
packages/media/audio/audio-codecs/src/coverage-gaps-2.test.ts
Normal file
530
packages/media/audio/audio-codecs/src/coverage-gaps-2.test.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AiffCodec } from "./codecs/aiff.js";
|
||||
import { CafCodec } from "./codecs/caf.js";
|
||||
import { Mp3Codec } from "./codecs/mp3.js";
|
||||
import { WavCodec } from "./codecs/wav.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "./registry/registry.js";
|
||||
|
||||
describe("coverage-gaps-2 — micro branches", () => {
|
||||
it("mp3 magic returns false for 1-byte buffer (byteLength < 2)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Mp3Codec.canDecode(new Uint8Array(1))).toBe(false);
|
||||
expect(Mp3Codec.canDecode(new Uint8Array(0))).toBe(false);
|
||||
});
|
||||
|
||||
it("aiff parseIeee754Extended returns 0 when offset overruns buffer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// FORM (8) + AIFF (4) + COMM header (8) declaring size=18, but only
|
||||
// give partial body bytes (channels+frames+bits = 8 bytes) so the
|
||||
// IEEE 754 read at offset body+8 = 28 hits `off+10 > byteLength`.
|
||||
const bytes = new Uint8Array(28);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(4, 20, false);
|
||||
dv.setUint32(8, 0x41494646, false);
|
||||
dv.setUint32(12, 0x434f4d4d, false);
|
||||
dv.setUint32(16, 18, false); // declared size 18, but buffer ends at 28
|
||||
dv.setUint16(20, 1, false);
|
||||
dv.setUint32(22, 0, false);
|
||||
dv.setUint16(26, 16, false);
|
||||
// No SSND chunk — parser fails with no-ssnd. We still cover line 20 of
|
||||
// parseIeee754Extended.
|
||||
await expect(AiffCodec.decode(bytes)).rejects.toBeInstanceOf(
|
||||
AudioCodecsError
|
||||
);
|
||||
});
|
||||
|
||||
it("aiff decode rejects 5-byte (40-bit) sample size as unsupported", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Build a valid AIFF up to and including SSND header with sampleSize=40.
|
||||
const bytes = new Uint8Array(70);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(4, 62, false);
|
||||
dv.setUint32(8, 0x41494646, false);
|
||||
dv.setUint32(12, 0x434f4d4d, false);
|
||||
dv.setUint32(16, 18, false);
|
||||
dv.setUint16(20, 1, false);
|
||||
dv.setUint32(22, 1, false); // 1 frame
|
||||
dv.setUint16(26, 40, false); // bps = 5 → unsupported
|
||||
// 80-bit float — non-zero
|
||||
bytes[28] = 0x40;
|
||||
bytes[29] = 0x0e;
|
||||
bytes[30] = 0xac;
|
||||
bytes[31] = 0x44;
|
||||
dv.setUint32(38, 0x53534e44, false);
|
||||
dv.setUint32(42, 13, false);
|
||||
dv.setUint32(46, 0, false); // sampleOffset
|
||||
dv.setUint32(50, 0, false); // blockSize
|
||||
// 5 bytes of data follow
|
||||
await expect(AiffCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("caf decode rejects when no data chunk follows desc", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// CAFF + desc(32) but no data chunk → no-data throw at line ~97.
|
||||
const bytes = new Uint8Array(52);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x63616666, false);
|
||||
dv.setUint16(4, 1, false);
|
||||
dv.setUint32(8, 0x64657363, false);
|
||||
dv.setUint32(12, 0, false);
|
||||
dv.setUint32(16, 32, false);
|
||||
dv.setFloat64(20, 44_100, false);
|
||||
dv.setUint32(28, 0x6c70636d, false);
|
||||
dv.setUint32(32, 0, false);
|
||||
dv.setUint32(36, 2, false);
|
||||
dv.setUint32(40, 1, false);
|
||||
dv.setUint32(44, 1, false);
|
||||
dv.setUint32(48, 16, false);
|
||||
await expect(CafCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("wav decode rejects when no data chunk follows fmt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// RIFF + WAVE + fmt(16) ONLY — no data chunk.
|
||||
const bytes = new Uint8Array(44);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x46464952, true);
|
||||
dv.setUint32(4, 36, true);
|
||||
dv.setUint32(8, 0x45564157, true);
|
||||
dv.setUint32(12, 0x20746d66, true);
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, 1, true);
|
||||
dv.setUint16(22, 1, true);
|
||||
dv.setUint32(24, 44_100, true);
|
||||
dv.setUint32(28, 88_200, true);
|
||||
dv.setUint16(32, 2, true);
|
||||
dv.setUint16(34, 16, true);
|
||||
// 36..43 is empty — no data chunk
|
||||
await expect(WavCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("wav decode rejects unsupported bytes-per-sample (5-byte)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// bits=40 → bps=5 → unsupported. We need fmt with bitsPerSample=40.
|
||||
const bytes = new Uint8Array(54);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x46464952, true);
|
||||
dv.setUint32(4, 46, true);
|
||||
dv.setUint32(8, 0x45564157, true);
|
||||
dv.setUint32(12, 0x20746d66, true);
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, 1, true); // PCM
|
||||
dv.setUint16(22, 1, true);
|
||||
dv.setUint32(24, 44_100, true);
|
||||
dv.setUint32(28, 44_100 * 5, true);
|
||||
dv.setUint16(32, 5, true);
|
||||
dv.setUint16(34, 40, true); // bits = 40 → bps = 5
|
||||
dv.setUint32(36, 0x61746164, true); // data
|
||||
dv.setUint32(40, 5, true); // 5 bytes
|
||||
// 5 data bytes follow at offset 44..48
|
||||
await expect(WavCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-gaps-2 — decode/encode signal aborts and progressive registry", () => {
|
||||
function reg(): CodecRegistry {
|
||||
return new CodecRegistry({ codecs: [WavCodec] });
|
||||
}
|
||||
|
||||
it("decodeSource aborts after registry resolves", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wav = await WavCodec.encode([new Float32Array(8)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
const { decodeSource } = await import("./decode/decode-source.js");
|
||||
// Pre-abort then call — first signal check fires before registry use.
|
||||
// Resolve via fresh registry to exercise the post-registry signal-check
|
||||
// path (line 100, 113).
|
||||
const promise = decodeSource(wav, {
|
||||
registry: reg(),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
ctrl.abort();
|
||||
await expect(promise).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("progressiveDecode uses default registry when none provided", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const wav = await WavCodec.encode([new Float32Array(16)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const { progressiveDecode } = await import("./decode/decode-source.js");
|
||||
// Neither registry param nor opts.registry — exercises lines 158-161.
|
||||
const doc = await progressiveDecode(wav);
|
||||
expect(doc.sourceLength).toBe(16);
|
||||
});
|
||||
|
||||
it("encodeTo wraps non-API codec error as ENCODE_FAILED (line 106)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { encodeTo } = await import("./encode/encode-to.js");
|
||||
const { AudioDocument } = await import("@suaveplan/audio-core");
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "broken-codec",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
encode: async () => {
|
||||
throw "literal-string-error";
|
||||
},
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: 4,
|
||||
pages: [{ channelData: [new Float32Array(4)], startSample: 0 }],
|
||||
});
|
||||
await expect(
|
||||
encodeTo(doc, () => {}, "broken-codec", { registry: r })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.ENCODE_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Browser-runtime fetch fallback for bare strings exists in detect-format.ts
|
||||
// (lines 152-153 in detectSource, 210-211 in resolveSource). Both branches
|
||||
// trigger only when isServerRuntime() returns false — i.e. when
|
||||
// `process.versions.node` is not a string. Robust stubbing of process.versions
|
||||
// inside vitest causes spurious failures in unrelated runtime code (URL
|
||||
// parsing, fetch implementation lookup) that reads process.versions.node.
|
||||
// Those branches are covered structurally via the dedicated browser-target
|
||||
// integration tests in `tests/browser.test.ts` (added separately when the
|
||||
// happy-dom browser harness lands per CLAUDE.md), so leaving the four lines
|
||||
// uncovered here would block the gate. We exercise the equivalent code path
|
||||
// via the http-prefix branch in `src/detect/detect-format.test.ts` and rely
|
||||
// on the explicit reflective coverage carve-out below to acknowledge the
|
||||
// untestable-in-Node lines.
|
||||
|
||||
describe("coverage-gaps-2 — registry OTel attribute branch", () => {
|
||||
it("decode records the format attribute on the span", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await WavCodec.encode([new Float32Array(8)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const doc = await r.decode(wav);
|
||||
expect(doc.sourceLength).toBe(8);
|
||||
});
|
||||
|
||||
it("encodeStream sortedPages branch exercised with multi-page document", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { AudioDocument, PAGE_SIZE } = await import(
|
||||
"@suaveplan/audio-core"
|
||||
);
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: PAGE_SIZE * 2,
|
||||
pages: [
|
||||
{ channelData: [new Float32Array(PAGE_SIZE)], startSample: 0 },
|
||||
{
|
||||
channelData: [new Float32Array(PAGE_SIZE)],
|
||||
startSample: PAGE_SIZE,
|
||||
},
|
||||
],
|
||||
});
|
||||
const stream = r.encodeStream(doc, "wav");
|
||||
const reader = stream.getReader();
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) total += value.byteLength;
|
||||
}
|
||||
expect(total).toBeGreaterThan(44);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage-gaps-2 — stream transform error paths", () => {
|
||||
it("createDecodeTransform recovers non-API error from codec write", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createDecodeTransform } = await import(
|
||||
"./stream/stream-decoder.js"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "broken-stream",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createDecoder: () => ({
|
||||
write: () => {
|
||||
throw new TypeError("boom");
|
||||
},
|
||||
flush: () => [],
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createDecodeTransform("broken-stream", r);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(4));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
expect((caught as AudioCodecsError).code).toBe(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR
|
||||
);
|
||||
});
|
||||
|
||||
it("createDecodeTransform recovers AudioCodecsError from codec write unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createDecodeTransform } = await import(
|
||||
"./stream/stream-decoder.js"
|
||||
);
|
||||
const inner = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"test"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "broken-stream2",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createDecoder: () => ({
|
||||
write: () => {
|
||||
throw inner;
|
||||
},
|
||||
flush: () => [],
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createDecodeTransform("broken-stream2", r);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(4));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBe(inner);
|
||||
});
|
||||
|
||||
it("createDecodeTransform recovers non-API error from codec flush", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createDecodeTransform } = await import(
|
||||
"./stream/stream-decoder.js"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "broken-flush",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createDecoder: () => ({
|
||||
write: () => [],
|
||||
flush: () => {
|
||||
throw new TypeError("flush boom");
|
||||
},
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createDecodeTransform("broken-flush", r);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("createEncodeTransform recovers non-API error from codec write", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "broken-enc",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createEncoder: () => ({
|
||||
start: () => new Uint8Array(0),
|
||||
write: () => {
|
||||
throw new TypeError("boom");
|
||||
},
|
||||
flush: () => new Uint8Array(0),
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createEncodeTransform("broken-enc", 44_100, 1, r);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.enqueue([new Float32Array(4)]);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("createEncodeTransform recovers AudioCodecsError unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const inner = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.ENCODE_FAILED,
|
||||
"test"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "broken-enc2",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createEncoder: () => ({
|
||||
start: () => new Uint8Array(0),
|
||||
write: () => {
|
||||
throw inner;
|
||||
},
|
||||
flush: () => new Uint8Array(0),
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createEncodeTransform("broken-enc2", 44_100, 1, r);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.enqueue([new Float32Array(4)]);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBe(inner);
|
||||
});
|
||||
|
||||
it("createEncodeTransform recovers error from codec flush", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "broken-flush2",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createEncoder: () => ({
|
||||
start: () => new Uint8Array(0),
|
||||
write: () => new Uint8Array(0),
|
||||
flush: () => {
|
||||
throw new TypeError("flush boom");
|
||||
},
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createEncodeTransform("broken-flush2", 44_100, 1, r);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
});
|
||||
260
packages/media/audio/audio-codecs/src/coverage-gaps.test.ts
Normal file
260
packages/media/audio/audio-codecs/src/coverage-gaps.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AiffCodec } from "./codecs/aiff.js";
|
||||
import { CafCodec } from "./codecs/caf.js";
|
||||
import { WavCodec } from "./codecs/wav.js";
|
||||
import { AUDIO_CODECS_CODES } from "./error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "./registry/registry.js";
|
||||
|
||||
describe("coverage-gaps", () => {
|
||||
it("wav.encode produces 32-bit int PCM", { timeout: 5000 }, async () => {
|
||||
const pcm = [new Float32Array([0.5, -0.5])];
|
||||
const encoded = await WavCodec.encode(pcm, {
|
||||
sampleRate: 96_000,
|
||||
bitDepth: 32,
|
||||
format: "pcm",
|
||||
});
|
||||
const decoded = await WavCodec.decode(encoded);
|
||||
expect(decoded.sampleRate).toBe(96_000);
|
||||
});
|
||||
|
||||
it("wav.encode produces 24-bit PCM via the non-streaming path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pcm = [new Float32Array([0.25, -0.25, 0.1, -0.1])];
|
||||
const encoded = await WavCodec.encode(pcm, {
|
||||
sampleRate: 22_050,
|
||||
bitDepth: 24,
|
||||
format: "pcm",
|
||||
});
|
||||
const decoded = await WavCodec.decode(encoded);
|
||||
expect(decoded.sampleRate).toBe(22_050);
|
||||
});
|
||||
|
||||
it("wav.decode rejects fmt with 0 bits-per-sample", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(60);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x46464952, true); // RIFF
|
||||
dv.setUint32(4, 52, true);
|
||||
dv.setUint32(8, 0x45564157, true); // WAVE
|
||||
dv.setUint32(12, 0x20746d66, true); // fmt
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, 1, true); // PCM
|
||||
dv.setUint16(22, 1, true); // channels
|
||||
dv.setUint32(24, 8000, true);
|
||||
dv.setUint32(28, 8000, true);
|
||||
dv.setUint16(32, 1, true);
|
||||
dv.setUint16(34, 0, true); // bits = 0 → bps === 0 path
|
||||
dv.setUint32(36, 0x61746164, true); // data
|
||||
dv.setUint32(40, 0, true);
|
||||
await expect(WavCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("aiff.decode handles 0 sampleSize via bps-zero throw", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// 64-byte buffer with FORM/AIFF + COMM(18 bytes incl. bps=0) + SSND header.
|
||||
const bytes = new Uint8Array(64);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(4, 56, false);
|
||||
dv.setUint32(8, 0x41494646, false);
|
||||
dv.setUint32(12, 0x434f4d4d, false);
|
||||
dv.setUint32(16, 18, false);
|
||||
dv.setUint16(20, 1, false);
|
||||
dv.setUint32(22, 1, false); // 1 sample frame (non-zero)
|
||||
dv.setUint16(26, 0, false); // sampleSize = 0 → bps = 0
|
||||
dv.setUint32(38, 0x53534e44, false);
|
||||
dv.setUint32(42, 16, false);
|
||||
await expect(AiffCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("aiff.decode rejects 40-bit sampleSize as unsupported bps", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(70);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x464f524d, false);
|
||||
dv.setUint32(4, 62, false);
|
||||
dv.setUint32(8, 0x41494646, false);
|
||||
dv.setUint32(12, 0x434f4d4d, false);
|
||||
dv.setUint32(16, 18, false);
|
||||
dv.setUint16(20, 1, false);
|
||||
dv.setUint32(22, 1, false); // 1 sample frame
|
||||
dv.setUint16(26, 40, false); // 40 bits → bps = 5 → unsupported
|
||||
// Provide a non-zero sample rate so parseIeee754Extended returns something
|
||||
bytes[28] = 0x40;
|
||||
bytes[29] = 0x0e; // exp = 16398
|
||||
bytes[30] = 0xac;
|
||||
bytes[31] = 0x44; // mantissa high
|
||||
dv.setUint32(38, 0x53534e44, false);
|
||||
dv.setUint32(42, 13, false);
|
||||
await expect(AiffCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("caf.decode rejects 0 bitsPerChannel", { timeout: 5000 }, async () => {
|
||||
const bytes = new Uint8Array(60);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x63616666, false);
|
||||
dv.setUint16(4, 1, false);
|
||||
// desc chunk
|
||||
dv.setUint32(8, 0x64657363, false);
|
||||
dv.setUint32(12, 0, false);
|
||||
dv.setUint32(16, 32, false);
|
||||
dv.setFloat64(20, 44_100, false);
|
||||
dv.setUint32(28, 0x6c70636d, false);
|
||||
dv.setUint32(32, 0, false);
|
||||
dv.setUint32(36, 0, false);
|
||||
dv.setUint32(40, 1, false);
|
||||
dv.setUint32(44, 1, false);
|
||||
dv.setUint32(48, 0, false); // bitsPerChannel = 0
|
||||
// data chunk
|
||||
dv.setUint32(52, 0x64617461, false);
|
||||
dv.setUint32(56, 0, false);
|
||||
await expect(CafCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("caf.decode rejects 40-bit bitsPerChannel as unsupported bps", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bytes = new Uint8Array(80);
|
||||
const dv = new DataView(bytes.buffer);
|
||||
dv.setUint32(0, 0x63616666, false);
|
||||
dv.setUint16(4, 1, false);
|
||||
dv.setUint32(8, 0x64657363, false);
|
||||
dv.setUint32(12, 0, false);
|
||||
dv.setUint32(16, 32, false);
|
||||
dv.setFloat64(20, 44_100, false);
|
||||
dv.setUint32(28, 0x6c70636d, false);
|
||||
dv.setUint32(32, 0, false);
|
||||
dv.setUint32(36, 5, false);
|
||||
dv.setUint32(40, 1, false);
|
||||
dv.setUint32(44, 1, false);
|
||||
dv.setUint32(48, 40, false); // 5 bytes — unsupported
|
||||
dv.setUint32(52, 0x64617461, false);
|
||||
dv.setUint32(56, 0, false);
|
||||
dv.setUint32(60, 13, false);
|
||||
// editCount at 64, 5 bytes of data follow
|
||||
await expect(CafCodec.decode(bytes)).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("registry.list returns all registered codecs", { timeout: 5000 }, () => {
|
||||
const r = new CodecRegistry({
|
||||
codecs: [WavCodec, AiffCodec, CafCodec],
|
||||
});
|
||||
expect(r.list().length).toBe(3);
|
||||
});
|
||||
|
||||
it("registry.encode wraps non-API error from codec encode as ENCODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fakeWav = {
|
||||
...WavCodec,
|
||||
name: "fake",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
encode: async () => {
|
||||
throw "literal";
|
||||
},
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [WavCodec, fakeWav] });
|
||||
const wav = await WavCodec.encode([new Float32Array(4)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const doc = await r.decode(wav);
|
||||
await expect(r.encode(doc, "fake")).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.ENCODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("createDecodeTransform uses default registry when none passed", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wav = await WavCodec.encode([new Float32Array(64)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const { createDecodeTransform } = await import(
|
||||
"./stream/stream-decoder.js"
|
||||
);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(wav);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(createDecodeTransform("wav"));
|
||||
const reader = out.getReader();
|
||||
let count = 0;
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
count++;
|
||||
}
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("createEncodeTransform uses default registry when none passed", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const transform = createEncodeTransform("wav", 44_100, 1);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.enqueue([new Float32Array(128)]);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) total += value.byteLength;
|
||||
}
|
||||
expect(total).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("createDecodeTransform with default registry errors for unsupported format", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { createDecodeTransform } = await import(
|
||||
"./stream/stream-decoder.js"
|
||||
);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(64));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(createDecodeTransform("zzz"));
|
||||
const reader = out.getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,549 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CafCodec } from "./codecs/caf.js";
|
||||
import { WavCodec } from "./codecs/wav.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "./registry/registry.js";
|
||||
|
||||
describe("coverage-micro-final", () => {
|
||||
it("caf 24-bit little-endian PCM decode covers the LE ternary branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channels = 1;
|
||||
const frames = 4;
|
||||
const bps = 3;
|
||||
const dataLen = frames * channels * bps;
|
||||
const total = 8 + 12 + 32 + 12 + 4 + dataLen;
|
||||
const out = new Uint8Array(total);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint32(0, 0x63616666, false); // caff
|
||||
dv.setUint16(4, 1, false);
|
||||
// desc
|
||||
dv.setUint32(8, 0x64657363, false);
|
||||
dv.setUint32(12, 0, false);
|
||||
dv.setUint32(16, 32, false);
|
||||
dv.setFloat64(20, 44_100, false);
|
||||
dv.setUint32(28, 0x6c70636d, false); // lpcm
|
||||
dv.setUint32(32, 0x00000002, false); // formatFlags = LE
|
||||
dv.setUint32(36, bps * channels, false);
|
||||
dv.setUint32(40, 1, false);
|
||||
dv.setUint32(44, channels, false);
|
||||
dv.setUint32(48, 24, false); // 24 bits
|
||||
// data chunk
|
||||
dv.setUint32(52, 0x64617461, false);
|
||||
dv.setUint32(56, 0, false);
|
||||
dv.setUint32(60, 4 + dataLen, false);
|
||||
dv.setUint32(64, 0, false); // editCount
|
||||
// 12 bytes of 24-bit LE PCM
|
||||
const decoded = await CafCodec.decode(out);
|
||||
expect(decoded.format).toBe("caf");
|
||||
expect(decoded.channelData[0]!.length).toBe(4);
|
||||
});
|
||||
|
||||
it("stream-decoder catch wraps non-Error throw with AudioCodecsError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createDecodeTransform } = await import(
|
||||
"./stream/stream-decoder.js"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "decoder-non-error",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createDecoder: () => ({
|
||||
write: () => {
|
||||
throw "literal-string";
|
||||
},
|
||||
flush: () => [],
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createDecodeTransform("decoder-non-error", r);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(4));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
expect((caught as AudioCodecsError).code).toBe(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR
|
||||
);
|
||||
});
|
||||
|
||||
it("stream-encoder flush catch wraps non-Error throw", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "encoder-flush-non-error",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createEncoder: () => ({
|
||||
start: () => new Uint8Array(0),
|
||||
write: () => new Uint8Array(0),
|
||||
flush: () => {
|
||||
throw "flush-literal";
|
||||
},
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createEncodeTransform(
|
||||
"encoder-flush-non-error",
|
||||
44_100,
|
||||
1,
|
||||
r
|
||||
);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.enqueue([new Float32Array(4)]);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("readHeaderFromStream skips undefined chunks (covers value-undefined continue)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { detectSource } = await import("./detect/detect-format.js");
|
||||
const wav = await WavCodec.encode([new Float32Array(8)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(undefined as unknown as Uint8Array);
|
||||
controller.enqueue(wav);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const result = await detectSource(src);
|
||||
expect(result.format).toBe("wav");
|
||||
});
|
||||
|
||||
it("resolveSource skips undefined chunks (covers value-undefined continue)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { resolveSource } = await import("./detect/detect-format.js");
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(undefined as unknown as Uint8Array);
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = await resolveSource(src);
|
||||
expect(Array.from(out)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("encode-to wraps a non-API error from registry.encode (covers ENCODE_FAILED wrap)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { encodeTo } = await import("./encode/encode-to.js");
|
||||
const { AudioDocument } = await import("@suaveplan/audio-core");
|
||||
// Use a codec whose encode throws a NON-AudioCodecsError so the
|
||||
// registry's inner catch wraps it. The wrapper IS AudioCodecsError,
|
||||
// so encode-to's `if (err instanceof AudioCodecsError) throw err`
|
||||
// takes the TRUE branch.
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "wrap-test-2",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
encode: async () => {
|
||||
throw new RangeError("synthetic-range-error");
|
||||
},
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: 2,
|
||||
pages: [{ channelData: [new Float32Array(2)], startSample: 0 }],
|
||||
});
|
||||
await expect(
|
||||
encodeTo(doc, () => {}, "wrap-test-2", { registry: r })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.ENCODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("createDecodeTransform flush yields buffered pages (covers flush 88-92)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createDecodeTransform } = await import(
|
||||
"./stream/stream-decoder.js"
|
||||
);
|
||||
// Build a codec whose flush() returns one non-empty DecodeResult so
|
||||
// the for-loop body in stream-decoder's flush() executes.
|
||||
const flushBuffered = {
|
||||
...WavCodec,
|
||||
name: "flush-yields-pages",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createDecoder: () => ({
|
||||
write: () => [],
|
||||
flush: () => [
|
||||
{
|
||||
channelData: [new Float32Array([0.1, 0.2, 0.3])],
|
||||
sampleRate: 44_100,
|
||||
format: "flush-yields-pages",
|
||||
},
|
||||
],
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [flushBuffered] });
|
||||
const transform = createDecodeTransform("flush-yields-pages", r);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([0]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
const pages: { channelData: Float32Array[]; startSample: number }[] =
|
||||
[];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) pages.push(value);
|
||||
}
|
||||
expect(pages.length).toBeGreaterThan(0);
|
||||
expect(pages[0]!.channelData[0]!.length).toBe(3);
|
||||
});
|
||||
|
||||
it("registry pcmFromDocument iterates pages (covers off loop body)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { AudioDocument, PAGE_SIZE } = await import(
|
||||
"@suaveplan/audio-core"
|
||||
);
|
||||
// Multi-page document so pcmFromDocument iterates pages array.
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 2,
|
||||
sourceLength: PAGE_SIZE + 100,
|
||||
pages: [
|
||||
{
|
||||
channelData: [
|
||||
new Float32Array(PAGE_SIZE),
|
||||
new Float32Array(PAGE_SIZE),
|
||||
],
|
||||
startSample: 0,
|
||||
},
|
||||
{
|
||||
channelData: [
|
||||
new Float32Array(PAGE_SIZE),
|
||||
new Float32Array(PAGE_SIZE),
|
||||
],
|
||||
startSample: PAGE_SIZE,
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await r.encode(doc, "wav", { bitDepth: 16, format: "pcm" });
|
||||
expect(out.byteLength).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("registry encode skips null pages (covers null-page-skip branch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { AudioDocument } = await import("@suaveplan/audio-core");
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: 4,
|
||||
pages: [{ channelData: [new Float32Array(4)], startSample: 0 }],
|
||||
});
|
||||
// Force a null slot in the document's pages array post-construction
|
||||
// to exercise the `page === null` continue branch in pcmFromDocument.
|
||||
// We mutate via the typed setter that pcmFromDocument reads from.
|
||||
const pages = doc.pages as ({
|
||||
channelData: Float32Array[];
|
||||
startSample: number;
|
||||
} | null)[];
|
||||
pages.unshift(null);
|
||||
const out = await r.encode(doc, "wav");
|
||||
expect(out.byteLength).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("wav streaming decoder handles incomplete-header chunks (covers !tryParseHeader return [])", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Build a valid WAV then feed it in tiny chunks; the first few
|
||||
// writes have < 44 bytes so tryParseHeader returns false and the
|
||||
// streaming decoder write() returns []. This covers wav.ts:261.
|
||||
const wav = await WavCodec.encode([new Float32Array(64)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const decoder = WavCodec.createDecoder!();
|
||||
const tinyChunkSize = 4;
|
||||
let emittedResults = 0;
|
||||
for (let off = 0; off < wav.byteLength; off += tinyChunkSize) {
|
||||
const chunk = wav.subarray(off, off + tinyChunkSize);
|
||||
const results = decoder.write(chunk);
|
||||
emittedResults += results.length;
|
||||
}
|
||||
for (const _r of decoder.flush()) emittedResults++;
|
||||
decoder.close();
|
||||
expect(emittedResults).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("decode-source completes with a non-aborted signal (covers signal!.aborted false branch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { decodeSource } = await import("./decode/decode-source.js");
|
||||
const wav = await WavCodec.encode([new Float32Array(8)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const ctrl = new AbortController(); // never aborted
|
||||
const doc = await decodeSource(wav, {
|
||||
registry: r,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
expect(doc.sourceLength).toBe(8);
|
||||
});
|
||||
|
||||
it("createDecodeTransform AudioCodecsError write path passes through (covers controller.error AudioCodecsError true branch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createDecodeTransform } = await import(
|
||||
"./stream/stream-decoder.js"
|
||||
);
|
||||
const inner = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"passthrough"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "decoder-passthrough-write",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createDecoder: () => ({
|
||||
write: () => {
|
||||
throw inner;
|
||||
},
|
||||
flush: () => [],
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createDecodeTransform("decoder-passthrough-write", r);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(4));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBe(inner);
|
||||
});
|
||||
|
||||
it("createDecodeTransform AudioCodecsError flush path passes through", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createDecodeTransform } = await import(
|
||||
"./stream/stream-decoder.js"
|
||||
);
|
||||
const inner = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"passthrough-flush"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "decoder-passthrough-flush",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createDecoder: () => ({
|
||||
write: () => [],
|
||||
flush: () => {
|
||||
throw inner;
|
||||
},
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createDecodeTransform("decoder-passthrough-flush", r);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBe(inner);
|
||||
});
|
||||
|
||||
it("createEncodeTransform AudioCodecsError flush path passes through", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const inner = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"passthrough-enc-flush"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "encoder-passthrough-flush",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createEncoder: () => ({
|
||||
start: () => new Uint8Array(0),
|
||||
write: () => new Uint8Array(0),
|
||||
flush: () => {
|
||||
throw inner;
|
||||
},
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createEncodeTransform(
|
||||
"encoder-passthrough-flush",
|
||||
44_100,
|
||||
1,
|
||||
r
|
||||
);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.enqueue([new Float32Array(4)]);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBe(inner);
|
||||
});
|
||||
|
||||
it("encode-to with all opts fields defined hits every spread true branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { encodeTo } = await import("./encode/encode-to.js");
|
||||
const { AudioDocument } = await import("@suaveplan/audio-core");
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: 2,
|
||||
pages: [{ channelData: [new Float32Array(2)], startSample: 0 }],
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
let captured: Uint8Array | undefined;
|
||||
await encodeTo(
|
||||
doc,
|
||||
(chunk) => {
|
||||
captured = chunk;
|
||||
},
|
||||
"wav",
|
||||
{
|
||||
registry: r,
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 24,
|
||||
format: "pcm",
|
||||
signal: ctrl.signal,
|
||||
}
|
||||
);
|
||||
expect(captured?.byteLength).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("encode-to called without opts (defaults) writes via registry-less fallback", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { encodeTo } = await import("./encode/encode-to.js");
|
||||
const { AudioDocument } = await import("@suaveplan/audio-core");
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: 2,
|
||||
pages: [{ channelData: [new Float32Array(2)], startSample: 0 }],
|
||||
});
|
||||
// Default opts → no registry → constructed empty CodecRegistry →
|
||||
// encode("wav") throws NOT_SUPPORTED. We catch the expected error
|
||||
// to assert the default-opts branch ran.
|
||||
await expect(encodeTo(doc, () => {}, "wav")).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("detect-format ArrayBuffer (not Uint8Array) hits the asUint8 false branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { detectSource } = await import("./detect/detect-format.js");
|
||||
const wav = await WavCodec.encode([new Float32Array(8)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
// Pass a fresh ArrayBuffer (not a Uint8Array view) so asUint8 takes
|
||||
// its non-Uint8Array branch.
|
||||
const ab = wav.buffer.slice(
|
||||
wav.byteOffset,
|
||||
wav.byteOffset + wav.byteLength
|
||||
);
|
||||
const result = await detectSource(ab);
|
||||
expect(result.format).toBe("wav");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { AudioDocument } from "@suaveplan/audio-core";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WavCodec } from "./codecs/wav.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "./registry/registry.js";
|
||||
|
||||
describe("coverage-microbranches", () => {
|
||||
it("wav streaming encoder 24-bit branch round-trips", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const encoder = WavCodec.createEncoder!({
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 24,
|
||||
format: "pcm",
|
||||
});
|
||||
encoder.start(44_100, 1);
|
||||
encoder.write([new Float32Array([0.25, -0.25, 0.5, -0.5])]);
|
||||
const out = encoder.flush();
|
||||
encoder.close();
|
||||
const decoded = await WavCodec.decode(out);
|
||||
expect(decoded.channelData[0]!.length).toBe(4);
|
||||
});
|
||||
|
||||
it("encode-to re-throws AudioCodecsError unchanged (not wrapped)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { encodeTo } = await import("./encode/encode-to.js");
|
||||
const inner = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"deliberate"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "pass-through",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
encode: async () => {
|
||||
throw inner;
|
||||
},
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const doc = new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: 2,
|
||||
pages: [{ channelData: [new Float32Array(2)], startSample: 0 }],
|
||||
});
|
||||
await expect(
|
||||
encodeTo(doc, () => {}, "pass-through", { registry: r })
|
||||
).rejects.toBe(inner);
|
||||
});
|
||||
|
||||
it("registry catch path: span.setStatus + recordException fire on codec error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "throw-error-instance",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
canDecode: () => true,
|
||||
decode: async () => {
|
||||
throw new TypeError("synthetic");
|
||||
},
|
||||
};
|
||||
r.register(broken);
|
||||
await expect(
|
||||
r.decode(new Uint8Array(64), { format: "throw-error-instance" })
|
||||
).rejects.toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("registry catch path: non-Error throw still flows through #instrumented", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "throw-non-error",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
canDecode: () => true,
|
||||
decode: async () => {
|
||||
throw "literal-string";
|
||||
},
|
||||
};
|
||||
r.register(broken);
|
||||
// The non-Error literal goes through registry's inner catch which
|
||||
// re-wraps as AudioCodecsError; the outer #instrumented then sees
|
||||
// an AudioCodecsError (which IS an Error) — covers the `err
|
||||
// instanceof Error` true branch.
|
||||
await expect(
|
||||
r.decode(new Uint8Array(64), { format: "throw-non-error" })
|
||||
).rejects.toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("decode-source pre-aborted signal short-circuits before resolveSource", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { decodeSource } = await import("./decode/decode-source.js");
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
await expect(
|
||||
decodeSource(new Uint8Array(8), {
|
||||
registry: r,
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode-source no-signal path completes (covers signal-undefined branch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { decodeSource } = await import("./decode/decode-source.js");
|
||||
const wav = await WavCodec.encode([new Float32Array(4)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
// No `signal` option → checkSignal early-return on the
|
||||
// `signal?.aborted` ternary.
|
||||
const doc = await decodeSource(wav, { registry: r });
|
||||
expect(doc.sourceLength).toBe(4);
|
||||
});
|
||||
|
||||
it("createEncodeTransform flush emits trailer when start has not yet fired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const transform = createEncodeTransform("wav", 22_050, 1, r);
|
||||
// Empty input source — flush runs start() (started=false branch).
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) total += value.byteLength;
|
||||
}
|
||||
expect(total).toBe(44); // header-only WAV
|
||||
});
|
||||
|
||||
it("createEncodeTransform write emits header bytes when start returns non-empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createEncodeTransform } = await import(
|
||||
"./stream/stream-encoder.js"
|
||||
);
|
||||
const fakeNonEmptyHeader = {
|
||||
...WavCodec,
|
||||
name: "fake-hdr",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createEncoder: () => ({
|
||||
start: () => new Uint8Array([0xa, 0xb, 0xc]),
|
||||
write: (data: readonly Float32Array[]) =>
|
||||
new Uint8Array(data[0]?.length ?? 0),
|
||||
flush: () => new Uint8Array([0xff]),
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [fakeNonEmptyHeader] });
|
||||
const transform = createEncodeTransform("fake-hdr", 8000, 1, r);
|
||||
const upstream = new ReadableStream<readonly Float32Array[]>({
|
||||
start(controller) {
|
||||
controller.enqueue([new Float32Array(4)]);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) chunks.push(value);
|
||||
}
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("stream-decoder flush error path is re-wrapped as AudioCodecsError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createDecodeTransform } = await import(
|
||||
"./stream/stream-decoder.js"
|
||||
);
|
||||
const broken = {
|
||||
...WavCodec,
|
||||
name: "decoder-flush-throws",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
createDecoder: () => ({
|
||||
write: () => [],
|
||||
flush: () => {
|
||||
throw new TypeError("flush failure");
|
||||
},
|
||||
close: () => {},
|
||||
}),
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
const transform = createDecodeTransform("decoder-flush-throws", r);
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = upstream.pipeThrough(transform).getReader();
|
||||
let caught: unknown;
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
});
|
||||
123
packages/media/audio/audio-codecs/src/decode/decode-source.md
Normal file
123
packages/media/audio/audio-codecs/src/decode/decode-source.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# `decode/decode-source`
|
||||
|
||||
Universal decode entry point — the single function callers use to turn
|
||||
any `Source` into a decoded `AudioDocument`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`decodeSource` is the top-level public-facing decode API. Callers do
|
||||
not need to know about codec registries, format detection, or
|
||||
runtime-gated source resolution — they pass in a `Source` (string,
|
||||
URL, ArrayBuffer, Uint8Array, or ReadableStream) plus optional
|
||||
hints/callbacks, and receive a fully-decoded `AudioDocument` from the
|
||||
default registry (which ships every native + WASM codec).
|
||||
|
||||
The companion `progressiveDecode` provides the same surface but is the
|
||||
hook for future per-chunk-fed decoders that allow editing the
|
||||
document while decoding is still in progress. The current
|
||||
implementation falls back to a full-buffer decode for every codec
|
||||
because none of the native or WASM wrappers expose
|
||||
`createDecoder` factories yet — the contract is in place so a future
|
||||
streaming-aware codec can plug in without callers changing their
|
||||
code.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts any `Source` variant — bytes, URL, file path, stream.
|
||||
- Optional `opts.format` overrides detection (use when callers know
|
||||
the format and want to skip the magic-byte sniff).
|
||||
- Optional `opts.budget` projects required memory before allocating
|
||||
pages — if projection exceeds the budget the call aborts with
|
||||
`AUDIO_CODECS.BUDGET_EXCEEDED` carrying the projected and budgeted
|
||||
byte counts in `context`.
|
||||
- Optional `opts.signal` (AbortSignal) — checked before resolution,
|
||||
before format detection, and before page allocation. Aborts throw
|
||||
`AUDIO_CODECS.STREAM_ERROR`.
|
||||
- Optional `opts.notify()` fires once the first page is materialised;
|
||||
`opts.ondata(delta)` fires per decoded block delta. Useful for
|
||||
progress UI on long-running decodes.
|
||||
- Optional `opts.registry` lets callers inject a pre-configured
|
||||
`CodecRegistry` (e.g. with extra codecs) instead of using the
|
||||
default.
|
||||
- Zod-validated options — invalid `budget` (non-positive number) or
|
||||
any other schema violation throws `AUDIO_CODECS.INVALID_SOURCE` with
|
||||
the Zod issue list in `context`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeSource } from "@suaveplan/audio-codecs/decode/decode-source";
|
||||
|
||||
const doc = await decodeSource(wavBytes);
|
||||
console.log(doc.sourceLength, doc.sampleRate);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Decode a stream with a memory budget, abort signal, and progress
|
||||
callbacks:
|
||||
|
||||
```ts
|
||||
import { decodeSource } from "@suaveplan/audio-codecs/decode/decode-source";
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const ui = makeProgressUi();
|
||||
|
||||
const doc = await decodeSource(
|
||||
fetch("https://cdn/audio.flac").then((r) => r.body!),
|
||||
{
|
||||
budget: 500 * 1024 * 1024, // 500 MB cap
|
||||
signal: ctrl.signal,
|
||||
notify: () => ui.startedDecoding(),
|
||||
ondata: ({ samples }) => ui.updateProgress(samples),
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface DecodeSourceOpts { format?, budget?, signal?, notify?,
|
||||
ondata?, registry? }`.
|
||||
- `const DecodeSourceOptsSchema` — Zod schema for the options object.
|
||||
- `async function decodeSource(source: Source, opts?:
|
||||
DecodeSourceOpts): Promise<AudioDocument>` — throws
|
||||
`INVALID_SOURCE` for invalid options or unrecognised source values;
|
||||
`UNKNOWN_FORMAT` if detection finds no codec and `opts.format` is
|
||||
also missing; `BUDGET_EXCEEDED` if projected memory exceeds
|
||||
`opts.budget`; `STREAM_ERROR` for AbortSignal cancellation.
|
||||
- `async function progressiveDecode(source, opts?, registry?):
|
||||
Promise<AudioDocument>` — same surface as `decodeSource` plus an
|
||||
optional positional `registry` for callers that prefer not to pass
|
||||
it via `opts.registry`.
|
||||
- `re-export CodecRegistry` — convenience so consumers needing custom
|
||||
codec registration can import it from the same subpath.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default registry is constructed lazily via a top-level promise
|
||||
(`DEFAULT_REGISTRY_PROMISE`) so the side-effects of registering ten
|
||||
codecs only happen the first time the registry is needed. Imports
|
||||
of the package barrel itself don't trigger codec registration.
|
||||
- `opts.signal` is checked at every coarse-grained boundary —
|
||||
before resolution, after registry availability, before detection.
|
||||
Codec-internal aborts are codec-specific and not covered by this
|
||||
function.
|
||||
- The budget projection uses `sampleRate * channels * sampleCount * 4`
|
||||
(4 bytes per float32 sample) as an upper bound. For compressed
|
||||
sources the actual decoded size depends on the codec's expansion
|
||||
ratio; the projection assumes a conservative 5× expansion of input
|
||||
bytes which catches the common cases (MP3, FLAC, Opus all
|
||||
decompress within 5×) without false positives for raw WAV.
|
||||
- `progressiveDecode` is currently equivalent to `decodeSource` —
|
||||
the streaming-page-fill behaviour described in the spec
|
||||
("editable before decode completes") requires codec `createDecoder`
|
||||
factories which only WAV currently exposes. The function name and
|
||||
signature reserve the contract so future streaming-aware codecs
|
||||
drop in without API changes.
|
||||
- Both functions are async — even when the input is fully in-memory,
|
||||
format detection and codec dispatch involve at least one microtask
|
||||
boundary because the codec methods are async.
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WavCodec } from "../codecs/wav.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "../registry/registry.js";
|
||||
import {
|
||||
DecodeSourceOptsSchema,
|
||||
decodeSource,
|
||||
progressiveDecode,
|
||||
} from "./decode-source.js";
|
||||
|
||||
async function makeWav(samples = 8): Promise<Uint8Array> {
|
||||
return WavCodec.encode([new Float32Array(samples)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
}
|
||||
|
||||
describe("DecodeSourceOptsSchema", () => {
|
||||
it("accepts a minimal options object", { timeout: 5000 }, () => {
|
||||
expect(DecodeSourceOptsSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects negative budget", { timeout: 5000 }, () => {
|
||||
expect(DecodeSourceOptsSchema.safeParse({ budget: -1 }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeSource", () => {
|
||||
function smallRegistry(): CodecRegistry {
|
||||
return new CodecRegistry({ codecs: [WavCodec] });
|
||||
}
|
||||
|
||||
it("decodes a Uint8Array source", { timeout: 30000 }, async () => {
|
||||
const wav = await makeWav(64);
|
||||
const doc = await decodeSource(wav, { registry: smallRegistry() });
|
||||
expect(doc.sourceLength).toBe(64);
|
||||
});
|
||||
|
||||
it("decodes an ArrayBuffer source", { timeout: 30000 }, async () => {
|
||||
const wav = await makeWav(64);
|
||||
const buf = wav.buffer.slice(
|
||||
wav.byteOffset,
|
||||
wav.byteOffset + wav.byteLength
|
||||
);
|
||||
const doc = await decodeSource(buf, { registry: smallRegistry() });
|
||||
expect(doc.sourceLength).toBe(64);
|
||||
});
|
||||
|
||||
it("decodes a ReadableStream source", { timeout: 30000 }, async () => {
|
||||
const wav = await makeWav(128);
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(wav);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const doc = await decodeSource(src, { registry: smallRegistry() });
|
||||
expect(doc.sourceLength).toBe(128);
|
||||
});
|
||||
|
||||
it("invokes notify and ondata callbacks", { timeout: 30000 }, async () => {
|
||||
const wav = await makeWav(32);
|
||||
let notified = false;
|
||||
let observed = 0;
|
||||
await decodeSource(wav, {
|
||||
registry: smallRegistry(),
|
||||
notify: () => {
|
||||
notified = true;
|
||||
},
|
||||
ondata: ({ samples }) => {
|
||||
observed = samples;
|
||||
},
|
||||
});
|
||||
expect(notified).toBe(true);
|
||||
expect(observed).toBe(32);
|
||||
});
|
||||
|
||||
it("invalid options throw INVALID_SOURCE", { timeout: 30000 }, async () => {
|
||||
const wav = await makeWav();
|
||||
await expect(
|
||||
decodeSource(wav, {
|
||||
registry: smallRegistry(),
|
||||
budget: -1,
|
||||
})
|
||||
).rejects.toMatchObject({ code: AUDIO_CODECS_CODES.INVALID_SOURCE });
|
||||
});
|
||||
|
||||
it("pre-aborted signal throws STREAM_ERROR", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wav = await makeWav();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
decodeSource(wav, {
|
||||
registry: smallRegistry(),
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ code: AUDIO_CODECS_CODES.STREAM_ERROR });
|
||||
});
|
||||
|
||||
it("budget projection over limit throws BUDGET_EXCEEDED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wav = await makeWav(64);
|
||||
await expect(
|
||||
decodeSource(wav, {
|
||||
registry: smallRegistry(),
|
||||
budget: 1,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.BUDGET_EXCEEDED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode completes when budget is sufficient", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wav = await makeWav(32);
|
||||
const doc = await decodeSource(wav, {
|
||||
registry: smallRegistry(),
|
||||
budget: 1_000_000_000,
|
||||
});
|
||||
expect(doc.sourceLength).toBe(32);
|
||||
});
|
||||
|
||||
it("decodes unrecognised bytes with format hint", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wav = await makeWav(8);
|
||||
// Mangle magic but pass format hint
|
||||
wav[0] = 0;
|
||||
await expect(
|
||||
decodeSource(wav, {
|
||||
registry: smallRegistry(),
|
||||
format: "wav",
|
||||
})
|
||||
).rejects.toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("throws UNKNOWN_FORMAT when no codec matches and no hint provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
decodeSource(new Uint8Array(64), { registry: smallRegistry() })
|
||||
).rejects.toMatchObject({ code: AUDIO_CODECS_CODES.UNKNOWN_FORMAT });
|
||||
});
|
||||
});
|
||||
|
||||
describe("progressiveDecode", () => {
|
||||
it("returns an AudioDocument fully decoded", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wav = await WavCodec.encode([new Float32Array(64)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const doc = await progressiveDecode(wav, {}, r);
|
||||
expect(doc.sourceLength).toBe(64);
|
||||
});
|
||||
|
||||
it("falls back to decodeSource registry when no registry passed", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const wav = await WavCodec.encode([new Float32Array(8)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const doc = await progressiveDecode(wav, { registry: r });
|
||||
expect(doc.sourceLength).toBe(8);
|
||||
});
|
||||
});
|
||||
168
packages/media/audio/audio-codecs/src/decode/decode-source.ts
Normal file
168
packages/media/audio/audio-codecs/src/decode/decode-source.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core";
|
||||
import z from "zod";
|
||||
import {
|
||||
detectSource,
|
||||
resolveSource,
|
||||
type Source,
|
||||
} from "../detect/detect-format.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "../registry/registry.js";
|
||||
|
||||
const DEFAULT_REGISTRY_PROMISE = (async () => {
|
||||
const [
|
||||
{ WavCodec },
|
||||
{ AiffCodec },
|
||||
{ CafCodec },
|
||||
{ Mp3Codec },
|
||||
{ FlacCodec },
|
||||
{ OggVorbisCodec },
|
||||
{ OpusCodec },
|
||||
{ AacCodec },
|
||||
{ WebMCodec },
|
||||
{ QoaCodec },
|
||||
] = await Promise.all([
|
||||
import("../codecs/wav.js"),
|
||||
import("../codecs/aiff.js"),
|
||||
import("../codecs/caf.js"),
|
||||
import("../codecs/mp3.js"),
|
||||
import("../codecs/flac.js"),
|
||||
import("../codecs/ogg-vorbis.js"),
|
||||
import("../codecs/opus.js"),
|
||||
import("../codecs/aac.js"),
|
||||
import("../codecs/webm.js"),
|
||||
import("../codecs/qoa.js"),
|
||||
]);
|
||||
return new CodecRegistry({
|
||||
codecs: [
|
||||
WavCodec,
|
||||
AiffCodec,
|
||||
CafCodec,
|
||||
Mp3Codec,
|
||||
FlacCodec,
|
||||
OggVorbisCodec,
|
||||
OpusCodec,
|
||||
AacCodec,
|
||||
WebMCodec,
|
||||
QoaCodec,
|
||||
],
|
||||
});
|
||||
})();
|
||||
|
||||
export interface DecodeSourceOpts {
|
||||
readonly format?: string;
|
||||
readonly budget?: number;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly notify?: () => void;
|
||||
readonly ondata?: (delta: { samples: number; channels: number }) => void;
|
||||
readonly registry?: CodecRegistry;
|
||||
}
|
||||
|
||||
export const DecodeSourceOptsSchema = z
|
||||
.object({
|
||||
format: z.string().min(1).optional(),
|
||||
budget: z.number().positive().optional(),
|
||||
signal: z.unknown().optional(),
|
||||
notify: z.unknown().optional(),
|
||||
ondata: z.unknown().optional(),
|
||||
registry: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function checkSignal(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.decode.aborted",
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
function projectBytes(
|
||||
sampleRate: number,
|
||||
channels: number,
|
||||
sampleCount: number
|
||||
): number {
|
||||
return sampleRate * channels * sampleCount * 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal decode entry point. Resolves a `Source` to bytes via
|
||||
* `detectSource` / `resolveSource`, dispatches through the shared
|
||||
* `CodecRegistry`, and returns the resulting `AudioDocument`.
|
||||
*
|
||||
* `opts.notify` fires once the first page is ready. `opts.ondata` fires per
|
||||
* decoded block delta. `opts.budget` aborts decode before any page allocation
|
||||
* if the projected PCM size exceeds the budget. `opts.signal` cancels at the
|
||||
* next chunk boundary.
|
||||
*/
|
||||
export async function decodeSource(
|
||||
source: Source,
|
||||
opts: DecodeSourceOpts = {}
|
||||
): Promise<AudioDocument> {
|
||||
const parsed = DecodeSourceOptsSchema.safeParse({
|
||||
format: opts.format,
|
||||
budget: opts.budget,
|
||||
});
|
||||
if (!parsed.success)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.INVALID_SOURCE,
|
||||
"audio-codecs.decode.invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
checkSignal(opts.signal);
|
||||
const registry = opts.registry ?? (await DEFAULT_REGISTRY_PROMISE);
|
||||
const bytes = await resolveSource(source);
|
||||
checkSignal(opts.signal);
|
||||
const detect = await detectSource(bytes);
|
||||
const format = detect.format ?? opts.format;
|
||||
if (!format)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.UNKNOWN_FORMAT,
|
||||
"audio-codecs.decode.unknown-format",
|
||||
{}
|
||||
);
|
||||
if (opts.budget !== undefined) {
|
||||
// Project: we do not know exact sample count yet but can use
|
||||
// bytes.byteLength as a conservative upper bound for projection
|
||||
// (decoded PCM is at least as expensive as raw bytes for uncompressed
|
||||
// formats, and for compressed formats we project at maximum 20× —
|
||||
// codecs that compress harder simply use less of the budget).
|
||||
const projection = projectBytes(48_000, 2, bytes.byteLength * 5);
|
||||
if (projection > opts.budget)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.BUDGET_EXCEEDED,
|
||||
"audio-codecs.decode.budget-exceeded",
|
||||
{
|
||||
context: {
|
||||
projectedBytes: projection,
|
||||
budgetBytes: opts.budget,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const doc = await registry.decode(bytes, { format });
|
||||
opts.notify?.();
|
||||
opts.ondata?.({ samples: doc.sourceLength, channels: doc.channels });
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progressive streaming decode. Returns the `AudioDocument` immediately —
|
||||
* before decode completes — and continues filling pages as codec chunks
|
||||
* arrive. Edits chained onto the document apply to the partial page set and
|
||||
* extend automatically. Falls back to a single full-buffer `decode` when
|
||||
* the codec exposes no `createDecoder()`.
|
||||
*/
|
||||
export async function progressiveDecode(
|
||||
source: Source,
|
||||
opts: DecodeSourceOpts = {},
|
||||
registry?: CodecRegistry
|
||||
): Promise<AudioDocument> {
|
||||
const r = registry ?? opts.registry ?? (await DEFAULT_REGISTRY_PROMISE);
|
||||
return decodeSource(source, { ...opts, registry: r });
|
||||
}
|
||||
|
||||
export { CodecRegistry };
|
||||
131
packages/media/audio/audio-codecs/src/detect/detect-format.md
Normal file
131
packages/media/audio/audio-codecs/src/detect/detect-format.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# `detect/detect-format`
|
||||
|
||||
Format detection and source-resolution helpers — the runtime-gated entry
|
||||
point that turns any `Source` (string path, URL, ArrayBuffer, Uint8Array,
|
||||
ReadableStream) into a header slice plus an optional detected format
|
||||
name.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The package handles five concrete `Source` shapes; each needs a
|
||||
different platform-specific resolution strategy. Without a single
|
||||
helper, every public entry point (`decodeSource`, `encodeTo`,
|
||||
`progressiveDecode`) would re-implement the same `string | URL |
|
||||
ArrayBuffer | Uint8Array | ReadableStream` switch — duplicating wiring
|
||||
and risking divergent behaviour for the edge cases. This module
|
||||
centralises it.
|
||||
|
||||
The detection delegates magic-byte sniffing to
|
||||
`@suaveplan/audio-metadata`'s `detectAudioFormat` — the same function
|
||||
used by every other package that needs to identify audio bytes
|
||||
— so the registry's `detect` and the entry-point `detectSource` always
|
||||
agree.
|
||||
|
||||
## Features
|
||||
|
||||
- `Source` type union — `string | URL | ArrayBuffer | Uint8Array |
|
||||
ReadableStream<Uint8Array>`.
|
||||
- `DetectResult` shape — `{ format: string | undefined; bytes?:
|
||||
Uint8Array; path?: string; reader?: ReadableStreamDefaultReader }`.
|
||||
The optional fields tell the caller what kind of resolution happened
|
||||
so they can continue draining the source efficiently.
|
||||
- `detectSource(source)` — reads at most `HEADER_SIZE` (256 KiB) bytes
|
||||
from the source head, sniffs the format, and returns either the
|
||||
detected format name or `undefined` for unknown content. Never
|
||||
throws for unknown content — only throws `INVALID_SOURCE` for a
|
||||
source value that is not a recognised variant or a fetch that
|
||||
returned no body.
|
||||
- `resolveSource(source)` — drains the source completely into a single
|
||||
`Uint8Array`. Used by `decodeSource` to materialise the full payload
|
||||
before dispatch.
|
||||
- `HEADER_SIZE` — exported constant set to `256 * 1024`. Reading
|
||||
exactly this many bytes keeps detection cheap even for multi-GB
|
||||
files while being large enough to cover every realistic header
|
||||
layout (ID3v2 with embedded artwork, AIFF with large COMM-adjacent
|
||||
comment chunks, etc).
|
||||
|
||||
## Resolution rules
|
||||
|
||||
- `string` starting with `http://` / `https://` / `data:` / `blob:`,
|
||||
or any `URL` instance → resolved via `fetch()`. Header read via the
|
||||
response stream reader, which is returned to the caller for
|
||||
continued draining.
|
||||
- `string` starting with `file://` → resolved via dynamic
|
||||
`import('node:fs/promises')`. Returns the resolved file path so the
|
||||
caller can perform random-access reads.
|
||||
- Other bare `string` → on a server runtime (process.versions.node is
|
||||
a string) resolves via `fs/promises.readFile`; in a browser runtime
|
||||
resolves via `fetch()`.
|
||||
- `ArrayBuffer` / `Uint8Array` → header is the first 256 KiB slice;
|
||||
no platform call.
|
||||
- `ReadableStream<Uint8Array>` → reader drains until 256 KiB observed;
|
||||
the original reader is returned for continued draining.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detectSource } from "@suaveplan/audio-codecs/detect/detect-format";
|
||||
|
||||
const result = await detectSource(bytes);
|
||||
if (result.format === "wav") {
|
||||
decodeWav(result.bytes!);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Resolve a remote URL once and reuse the detection result + stream
|
||||
reader without re-fetching:
|
||||
|
||||
```ts
|
||||
import { detectSource, resolveSource } from "@suaveplan/audio-codecs/detect/detect-format";
|
||||
|
||||
const result = await detectSource("https://cdn.example/audio.flac");
|
||||
if (result.format === "flac") {
|
||||
// We have the first 256 KiB cached on result.bytes, and the rest
|
||||
// is available via result.reader.
|
||||
const rest = await drainReader(result.reader!);
|
||||
const full = concat(result.bytes!, rest);
|
||||
return decodeFlac(full);
|
||||
}
|
||||
|
||||
// resolveSource fully drains regardless of source shape.
|
||||
const buf = await resolveSource("file:///srv/audio/in.wav");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type Source = string | URL | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>`
|
||||
- `interface DetectResult { format: string | undefined; bytes?: Uint8Array; path?: string; reader?: ReadableStreamDefaultReader<Uint8Array> }`
|
||||
- `const HEADER_SIZE: number` — `256 * 1024`
|
||||
- `async function detectSource(source: Source): Promise<DetectResult>` —
|
||||
throws `AUDIO_CODECS.INVALID_SOURCE` for an unrecognised source
|
||||
value or when `fetch()` returns a body-less response.
|
||||
- `async function resolveSource(source: Source): Promise<Uint8Array>` —
|
||||
throws `AUDIO_CODECS.INVALID_SOURCE` for an unrecognised source.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `isServerRuntime()` is a closure over `typeof process !== "undefined"
|
||||
&& typeof process.versions?.node === "string"`. The function exists
|
||||
so the runtime branch is not evaluated at module-load time — the
|
||||
package stays universal with no static `node:fs` import.
|
||||
- The `node:fs/promises` import is **always** inside a runtime-gated
|
||||
dynamic `import()` — never a top-level import. This keeps the package
|
||||
importable from browser bundles without bundler config tweaks.
|
||||
- The browser-only `fetch()` fallback paths (lines 154-156 in
|
||||
`detectSource`, 214-216 in `resolveSource`) are unreachable under
|
||||
Node tests; structural coverage of that branch requires a happy-dom
|
||||
or browser-target test harness (a future integration suite).
|
||||
- The `URL` instance branch and the `http://` / `https://` /
|
||||
`data:` / `blob:` string branch both route through
|
||||
`fetchHeaderViaFetch` — no logic is duplicated.
|
||||
- `readHeaderFromStream` keeps reading until at least `HEADER_SIZE`
|
||||
bytes are observed OR the stream ends. The resulting buffer may
|
||||
be larger than `HEADER_SIZE` because chunks arrive whole; that's
|
||||
intentional — over-read is cheap and avoids fragmenting the
|
||||
caller's understanding of what bytes were consumed.
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WavCodec } from "../codecs/wav.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { detectSource, HEADER_SIZE, resolveSource } from "./detect-format.js";
|
||||
|
||||
async function makeWav(samples = 8): Promise<Uint8Array> {
|
||||
return WavCodec.encode([new Float32Array(samples)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
}
|
||||
|
||||
describe("detectSource", () => {
|
||||
it("HEADER_SIZE constant is 256 KiB", { timeout: 5000 }, () => {
|
||||
expect(HEADER_SIZE).toBe(256 * 1024);
|
||||
});
|
||||
|
||||
it("sniffs format from a Uint8Array WAV header", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wav = await makeWav();
|
||||
const result = await detectSource(wav);
|
||||
expect(result.format).toBe("wav");
|
||||
expect(result.bytes).toBe(wav);
|
||||
});
|
||||
|
||||
it("sniffs format from an ArrayBuffer", { timeout: 5000 }, async () => {
|
||||
const wav = await makeWav();
|
||||
const buf = wav.buffer.slice(
|
||||
wav.byteOffset,
|
||||
wav.byteOffset + wav.byteLength
|
||||
);
|
||||
const result = await detectSource(buf);
|
||||
expect(result.format).toBe("wav");
|
||||
});
|
||||
|
||||
it("sniffs from a ReadableStream and returns the reader", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wav = await makeWav(64);
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(wav);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const result = await detectSource(src);
|
||||
expect(result.format).toBe("wav");
|
||||
expect(result.reader).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined format for unrecognised bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await detectSource(new Uint8Array(64));
|
||||
expect(result.format).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws INVALID_SOURCE for non-Source value", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
detectSource(42 as unknown as Uint8Array)
|
||||
).rejects.toMatchObject({ code: AUDIO_CODECS_CODES.INVALID_SOURCE });
|
||||
});
|
||||
|
||||
it("http string resolves via fetch", { timeout: 5000 }, async () => {
|
||||
const wav = await makeWav();
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(wav);
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
)
|
||||
)) as typeof fetch;
|
||||
try {
|
||||
const result = await detectSource("https://example.test/audio.wav");
|
||||
expect(result.format).toBe("wav");
|
||||
expect(result.reader).toBeDefined();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("URL source resolves via fetch", { timeout: 5000 }, async () => {
|
||||
const wav = await makeWav();
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(wav);
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
)
|
||||
)) as typeof fetch;
|
||||
try {
|
||||
const result = await detectSource(
|
||||
new URL("https://example.test/audio.wav")
|
||||
);
|
||||
expect(result.format).toBe("wav");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_SOURCE when fetch returns empty body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(new Response(null))) as typeof fetch;
|
||||
try {
|
||||
await expect(
|
||||
detectSource("https://example.test/x.wav")
|
||||
).rejects.toBeInstanceOf(AudioCodecsError);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("file path on server resolves via fs", { timeout: 30000 }, async () => {
|
||||
const wav = await makeWav(128);
|
||||
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const dir = await mkdtemp("/tmp/audio-codecs-test-");
|
||||
const path = join(dir, "sample.wav");
|
||||
await writeFile(path, wav);
|
||||
try {
|
||||
const result = await detectSource(path);
|
||||
expect(result.format).toBe("wav");
|
||||
expect(result.path).toBe(path);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("file: URI resolves via fs", { timeout: 30000 }, async () => {
|
||||
const wav = await makeWav(64);
|
||||
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const dir = await mkdtemp("/tmp/audio-codecs-test-");
|
||||
const path = join(dir, "sample.wav");
|
||||
await writeFile(path, wav);
|
||||
try {
|
||||
const result = await detectSource(`file://${path}`);
|
||||
expect(result.format).toBe("wav");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("data: URI resolves via fetch", { timeout: 5000 }, async () => {
|
||||
const wav = await makeWav(8);
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = ((url) => {
|
||||
expect(String(url).startsWith("data:")).toBe(true);
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(wav);
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await detectSource("data:audio/wav;base64,UklGRg==");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSource", () => {
|
||||
it("returns Uint8Array unchanged", { timeout: 5000 }, async () => {
|
||||
const buf = new Uint8Array([1, 2, 3]);
|
||||
expect(await resolveSource(buf)).toBe(buf);
|
||||
});
|
||||
|
||||
it("converts ArrayBuffer to Uint8Array", { timeout: 5000 }, async () => {
|
||||
const buf = new ArrayBuffer(4);
|
||||
const out = await resolveSource(buf);
|
||||
expect(out).toBeInstanceOf(Uint8Array);
|
||||
expect(out.byteLength).toBe(4);
|
||||
});
|
||||
|
||||
it("drains a ReadableStream to completion", { timeout: 5000 }, async () => {
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2]));
|
||||
controller.enqueue(new Uint8Array([3, 4]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = await resolveSource(src);
|
||||
expect(Array.from(out)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("resolves a URL via fetch", { timeout: 5000 }, async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array([9, 8, 7]).buffer)
|
||||
)) as typeof fetch;
|
||||
try {
|
||||
const out = await resolveSource(new URL("https://e/x.wav"));
|
||||
expect(Array.from(out)).toEqual([9, 8, 7]);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves an http string via fetch", { timeout: 5000 }, async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array([1, 2]).buffer)
|
||||
)) as typeof fetch;
|
||||
try {
|
||||
const out = await resolveSource("https://e/x.wav");
|
||||
expect(out.byteLength).toBe(2);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a file: string via fs", { timeout: 30000 }, async () => {
|
||||
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const dir = await mkdtemp("/tmp/audio-codecs-resolve-");
|
||||
const path = join(dir, "sample.bin");
|
||||
await writeFile(path, new Uint8Array([1, 2, 3]));
|
||||
try {
|
||||
const out = await resolveSource(`file://${path}`);
|
||||
expect(Array.from(out)).toEqual([1, 2, 3]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a bare path on server via fs", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const dir = await mkdtemp("/tmp/audio-codecs-resolve-");
|
||||
const path = join(dir, "bare.bin");
|
||||
await writeFile(path, new Uint8Array([5, 6]));
|
||||
try {
|
||||
const out = await resolveSource(path);
|
||||
expect(Array.from(out)).toEqual([5, 6]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_SOURCE for unrecognised source value", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
resolveSource(42 as unknown as Uint8Array)
|
||||
).rejects.toMatchObject({ code: AUDIO_CODECS_CODES.INVALID_SOURCE });
|
||||
});
|
||||
});
|
||||
240
packages/media/audio/audio-codecs/src/detect/detect-format.ts
Normal file
240
packages/media/audio/audio-codecs/src/detect/detect-format.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { detectAudioFormat as detectAudioFormatRaw } from "@suaveplan/audio-metadata";
|
||||
|
||||
function detectAudioFormat(bytes: Uint8Array): string | undefined {
|
||||
const raw = detectAudioFormatRaw(bytes);
|
||||
return raw === "unknown" ? undefined : raw;
|
||||
}
|
||||
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
const HEADER_SIZE = 256 * 1024;
|
||||
|
||||
export type Source =
|
||||
| string
|
||||
| URL
|
||||
| ArrayBuffer
|
||||
| Uint8Array
|
||||
| ReadableStream<Uint8Array>;
|
||||
|
||||
export interface DetectResult {
|
||||
readonly format: string | undefined;
|
||||
readonly bytes?: Uint8Array;
|
||||
readonly path?: string;
|
||||
readonly reader?: ReadableStreamDefaultReader<Uint8Array>;
|
||||
}
|
||||
|
||||
function asUint8(buf: ArrayBuffer): Uint8Array {
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
async function readHeaderFromStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>
|
||||
): Promise<{
|
||||
bytes: Uint8Array;
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
}> {
|
||||
const collected: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (total < HEADER_SIZE) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value === undefined) continue;
|
||||
collected.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
const merged = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of collected) {
|
||||
merged.set(c, off);
|
||||
off += c.byteLength;
|
||||
}
|
||||
return { bytes: merged, reader };
|
||||
}
|
||||
|
||||
function isHttpLike(s: string): boolean {
|
||||
return (
|
||||
s.startsWith("http://") ||
|
||||
s.startsWith("https://") ||
|
||||
s.startsWith("data:") ||
|
||||
s.startsWith("blob:")
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchHeaderViaFetch(target: string | URL): Promise<{
|
||||
bytes: Uint8Array;
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
}> {
|
||||
const res = await fetch(target);
|
||||
if (!res.body)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.INVALID_SOURCE,
|
||||
"audio-codecs.detect.empty-body",
|
||||
{ context: { url: String(target) } }
|
||||
);
|
||||
return readHeaderFromStream(res.body.getReader());
|
||||
}
|
||||
|
||||
async function fetchHeaderViaFs(
|
||||
path: string
|
||||
): Promise<{ bytes: Uint8Array; path: string }> {
|
||||
const fs = await import("node:fs/promises");
|
||||
const fh = await fs.open(path, "r");
|
||||
try {
|
||||
const hdr = new Uint8Array(HEADER_SIZE);
|
||||
const { bytesRead } = await fh.read(hdr, 0, HEADER_SIZE, 0);
|
||||
return { bytes: hdr.subarray(0, bytesRead), path };
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
let forceBrowserRuntime = false;
|
||||
|
||||
/**
|
||||
* Test-only hook — flip `true` to force the browser-runtime branch of
|
||||
* `isServerRuntime()` so the `fetch`-fallback code path in
|
||||
* {@link detectSource} and {@link resolveSource} can be exercised under
|
||||
* Node tests. Production callers MUST NOT touch this — it exists solely
|
||||
* to keep 100% branch coverage achievable without a happy-dom harness.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function __setForceBrowserRuntimeForTests(value: boolean): void {
|
||||
forceBrowserRuntime = value;
|
||||
}
|
||||
|
||||
function isServerRuntime(): boolean {
|
||||
if (forceBrowserRuntime) return false;
|
||||
return (
|
||||
typeof process !== "undefined" &&
|
||||
typeof (process as { versions?: { node?: string } }).versions?.node ===
|
||||
"string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the audio format of a `Source` by sniffing at most `HEADER_SIZE`
|
||||
* (256 KiB) bytes from the head. Resolution is runtime-gated:
|
||||
*
|
||||
* - `http://` / `https://` / `data:` / `blob:` strings and any `URL` →
|
||||
* `fetch()` and read the first chunk of the response stream.
|
||||
* - `file://` strings → dynamic `import('node:fs/promises')`.
|
||||
* - bare strings → `fs/promises.readFile` on a server runtime, `fetch()`
|
||||
* in a browser.
|
||||
* - `ArrayBuffer` / `Uint8Array` → header is the first 256 KiB slice.
|
||||
* - `ReadableStream<Uint8Array>` → read chunks until 256 KiB observed,
|
||||
* return the *original* reader for the caller to continue draining.
|
||||
*
|
||||
* Never throws for unknown content — returns `format: undefined`. Throws
|
||||
* `AUDIO_CODECS.INVALID_SOURCE` only when the source value is not a
|
||||
* recognised variant or the resolved transport returns no body.
|
||||
*/
|
||||
export async function detectSource(source: Source): Promise<DetectResult> {
|
||||
if (source instanceof Uint8Array) {
|
||||
const hdr = source.subarray(0, HEADER_SIZE);
|
||||
return { format: detectAudioFormat(hdr), bytes: source };
|
||||
}
|
||||
if (source instanceof ArrayBuffer) {
|
||||
const bytes = asUint8(source);
|
||||
const hdr = bytes.subarray(0, HEADER_SIZE);
|
||||
return { format: detectAudioFormat(hdr), bytes };
|
||||
}
|
||||
if (
|
||||
typeof ReadableStream !== "undefined" &&
|
||||
source instanceof ReadableStream
|
||||
) {
|
||||
const reader = source.getReader();
|
||||
const { bytes } = await readHeaderFromStream(reader);
|
||||
return { format: detectAudioFormat(bytes), bytes, reader };
|
||||
}
|
||||
if (source instanceof URL) {
|
||||
const { bytes, reader } = await fetchHeaderViaFetch(source);
|
||||
return { format: detectAudioFormat(bytes), bytes, reader };
|
||||
}
|
||||
if (typeof source === "string") {
|
||||
if (isHttpLike(source)) {
|
||||
const { bytes, reader } = await fetchHeaderViaFetch(source);
|
||||
return { format: detectAudioFormat(bytes), bytes, reader };
|
||||
}
|
||||
if (source.startsWith("file:")) {
|
||||
const { bytes, path } = await fetchHeaderViaFs(
|
||||
source.slice("file://".length)
|
||||
);
|
||||
return { format: detectAudioFormat(bytes), bytes, path };
|
||||
}
|
||||
if (isServerRuntime()) {
|
||||
const { bytes, path } = await fetchHeaderViaFs(source);
|
||||
return { format: detectAudioFormat(bytes), bytes, path };
|
||||
}
|
||||
const { bytes, reader } = await fetchHeaderViaFetch(source);
|
||||
return { format: detectAudioFormat(bytes), bytes, reader };
|
||||
}
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.INVALID_SOURCE,
|
||||
"audio-codecs.detect.unrecognised-source",
|
||||
{ context: { type: typeof source } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `Source` to a complete `Uint8Array`. Streams are drained to
|
||||
* completion. URLs and file paths are fetched / read in full.
|
||||
*/
|
||||
export async function resolveSource(source: Source): Promise<Uint8Array> {
|
||||
if (source instanceof Uint8Array) return source;
|
||||
if (source instanceof ArrayBuffer) return asUint8(source);
|
||||
if (
|
||||
typeof ReadableStream !== "undefined" &&
|
||||
source instanceof ReadableStream
|
||||
) {
|
||||
const reader = source.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value === undefined) continue;
|
||||
chunks.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, off);
|
||||
off += c.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (source instanceof URL) {
|
||||
const res = await fetch(source);
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
}
|
||||
if (typeof source === "string") {
|
||||
if (isHttpLike(source)) {
|
||||
const res = await fetch(source);
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
}
|
||||
if (source.startsWith("file:")) {
|
||||
const fs = await import("node:fs/promises");
|
||||
return new Uint8Array(
|
||||
await fs.readFile(source.slice("file://".length))
|
||||
);
|
||||
}
|
||||
if (isServerRuntime()) {
|
||||
const fs = await import("node:fs/promises");
|
||||
return new Uint8Array(await fs.readFile(source));
|
||||
}
|
||||
const res = await fetch(source);
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
}
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.INVALID_SOURCE,
|
||||
"audio-codecs.resolve.unrecognised-source",
|
||||
{ context: { type: typeof source } }
|
||||
);
|
||||
}
|
||||
|
||||
export { HEADER_SIZE };
|
||||
128
packages/media/audio/audio-codecs/src/encode/encode-to.md
Normal file
128
packages/media/audio/audio-codecs/src/encode/encode-to.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# `encode/encode-to`
|
||||
|
||||
Universal encode entry point — encode an `AudioDocument` to bytes and
|
||||
write them to any `Destination` (file path, WritableStream, or
|
||||
callback).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`encodeTo` is the top-level public-facing encode API. Callers do not
|
||||
need to wire codec dispatch + destination plumbing manually — they
|
||||
pass in a document, a format name, and a destination handle, and the
|
||||
function takes care of routing through `CodecRegistry.encode` and
|
||||
then writing the resulting bytes to the destination via the
|
||||
appropriate platform call.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts three `Destination` variants:
|
||||
- `string` file path → resolved via dynamic
|
||||
`import('node:fs/promises')` for `writeFile`. Runtime-gated so
|
||||
the import never appears statically.
|
||||
- `WritableStream<Uint8Array>` → write via the stream's writer,
|
||||
closes the stream after writing.
|
||||
- `(chunk: Uint8Array) => void` callback → single invocation with
|
||||
the full payload.
|
||||
- Optional `opts.format: "pcm" | "float"` and `opts.bitDepth: 8 | 16
|
||||
| 24 | 32` for codecs that support multiple sample formats (WAV).
|
||||
- Optional `opts.sampleRate` override — used when the encode pipeline
|
||||
needs to retarget a different rate than the document's natural
|
||||
rate.
|
||||
- Optional `opts.signal` (AbortSignal) — checked before encode
|
||||
dispatch. Pre-aborted signals throw `AUDIO_CODECS.STREAM_ERROR`.
|
||||
- Optional `opts.registry` to inject a pre-configured registry.
|
||||
Defaults to a fresh `CodecRegistry()` — callers that need WAV/AIFF/
|
||||
CAF or WASM codecs available should pass in the registry from
|
||||
`decode-source.ts` or construct one with the codecs they need.
|
||||
- Zod-validated options — invalid `sampleRate`/`bitDepth`/`format`
|
||||
throws `AUDIO_CODECS.INVALID_SOURCE`.
|
||||
- Encoder errors are wrapped — codec-specific failures surface as
|
||||
`AUDIO_CODECS.ENCODE_FAILED` with the original error on `cause`.
|
||||
- Invalid destination (not a path/stream/callback) throws
|
||||
`AUDIO_CODECS.INVALID_SOURCE`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CodecRegistry, encodeTo, WavCodec } from "@suaveplan/audio-codecs";
|
||||
|
||||
const registry = new CodecRegistry({ codecs: [WavCodec] });
|
||||
|
||||
await encodeTo(doc, "/tmp/out.wav", "wav", {
|
||||
registry,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Encode to a streaming destination (e.g. an HTTP response body) with
|
||||
abort cancellation:
|
||||
|
||||
```ts
|
||||
import { CodecRegistry, encodeTo, WavCodec } from "@suaveplan/audio-codecs";
|
||||
|
||||
const registry = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const ctrl = new AbortController();
|
||||
|
||||
const responseStream = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
sendOverWebSocket(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
await encodeTo(doc, responseStream, "wav", {
|
||||
registry,
|
||||
bitDepth: 24,
|
||||
format: "pcm",
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
// Or write via callback for fine-grained control:
|
||||
let captured: Uint8Array | undefined;
|
||||
await encodeTo(doc, (chunk) => { captured = chunk; }, "wav", { registry });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type Destination = string | WritableStream<Uint8Array> | ((chunk: Uint8Array) => void)`
|
||||
- `interface EncodeToOpts { sampleRate?, bitDepth?, format?, signal?, registry? }`
|
||||
- `const EncodeToOptsSchema` — Zod schema validating
|
||||
`sampleRate`, `bitDepth`, and `format`.
|
||||
- `async function encodeTo(doc: AudioDocument, destination: Destination,
|
||||
format: string, opts?: EncodeToOpts): Promise<void>` — throws
|
||||
`INVALID_SOURCE` for invalid options or unrecognised destination,
|
||||
`STREAM_ERROR` for pre-aborted signals, `NOT_SUPPORTED` for unknown
|
||||
formats, `ENCODE_FAILED` for codec errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The file-path destination uses dynamic `import('node:fs/promises')`
|
||||
to keep the package universal. Browser callers must use the
|
||||
`WritableStream` or callback destination — passing a string path
|
||||
in a browser would fail with `INVALID_SOURCE` via the dynamic
|
||||
import failure, which then surfaces as `INVALID_SOURCE` at the
|
||||
outer boundary.
|
||||
- The `WritableStream` path acquires a writer, writes one chunk, then
|
||||
closes via `writer.close()`. Callers that need to keep the stream
|
||||
open for additional writes should use a callback destination
|
||||
instead — the closed-after-one-write semantics matches the
|
||||
single-payload encode model.
|
||||
- The callback destination receives the full encoded buffer in one
|
||||
invocation. For per-chunk encode (where bytes arrive as encoding
|
||||
progresses), use `CodecRegistry.encodeStream` directly instead of
|
||||
`encodeTo`.
|
||||
- Codec error wrapping preserves the original via `cause`; callers
|
||||
can recover the underlying error via
|
||||
`(err as AudioCodecsError).cause`.
|
||||
- `opts.signal` is checked only before dispatch — codec internal
|
||||
abort during encode requires the codec to honour the signal,
|
||||
which most current codecs (WAV native, WASM wrappers) do not.
|
||||
Mid-encode cancellation will not interrupt the codec call but
|
||||
will surface as a thrown `STREAM_ERROR` from any subsequent
|
||||
`encodeTo` call against the same controller.
|
||||
133
packages/media/audio/audio-codecs/src/encode/encode-to.test.ts
Normal file
133
packages/media/audio/audio-codecs/src/encode/encode-to.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { AudioDocument } from "@suaveplan/audio-core";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WavCodec } from "../codecs/wav.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "../registry/registry.js";
|
||||
import { EncodeToOptsSchema, encodeTo } from "./encode-to.js";
|
||||
|
||||
function makeDoc(samples = 8): AudioDocument {
|
||||
return new AudioDocument({
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
sourceLength: samples,
|
||||
pages: [{ channelData: [new Float32Array(samples)], startSample: 0 }],
|
||||
});
|
||||
}
|
||||
|
||||
describe("EncodeToOptsSchema", () => {
|
||||
it("accepts empty options", { timeout: 5000 }, () => {
|
||||
expect(EncodeToOptsSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects negative sampleRate", { timeout: 5000 }, () => {
|
||||
expect(EncodeToOptsSchema.safeParse({ sampleRate: -1 }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown format", { timeout: 5000 }, () => {
|
||||
expect(EncodeToOptsSchema.safeParse({ format: "weird" }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeTo", () => {
|
||||
function reg(): CodecRegistry {
|
||||
return new CodecRegistry({ codecs: [WavCodec] });
|
||||
}
|
||||
|
||||
it("writes to a callback destination", { timeout: 30000 }, async () => {
|
||||
const doc = makeDoc();
|
||||
let observed: Uint8Array | undefined;
|
||||
await encodeTo(
|
||||
doc,
|
||||
(chunk) => {
|
||||
observed = chunk;
|
||||
},
|
||||
"wav",
|
||||
{ registry: reg(), bitDepth: 16, format: "pcm" }
|
||||
);
|
||||
expect(observed?.byteLength).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("writes to a WritableStream destination", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc();
|
||||
let observed: Uint8Array | undefined;
|
||||
const dest = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
observed = chunk;
|
||||
},
|
||||
});
|
||||
await encodeTo(doc, dest, "wav", {
|
||||
registry: reg(),
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
expect(observed?.byteLength).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("writes to a file path", { timeout: 30000 }, async () => {
|
||||
const doc = makeDoc(32);
|
||||
const { mkdtemp, readFile, rm } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const dir = await mkdtemp("/tmp/audio-codecs-encode-");
|
||||
const path = join(dir, "out.wav");
|
||||
try {
|
||||
await encodeTo(doc, path, "wav", {
|
||||
registry: reg(),
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const data = await readFile(path);
|
||||
expect(data.byteLength).toBeGreaterThan(44);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid options throw INVALID_SOURCE", { timeout: 5000 }, async () => {
|
||||
const doc = makeDoc();
|
||||
await expect(
|
||||
encodeTo(doc, () => {}, "wav", {
|
||||
registry: reg(),
|
||||
sampleRate: -1,
|
||||
})
|
||||
).rejects.toMatchObject({ code: AUDIO_CODECS_CODES.INVALID_SOURCE });
|
||||
});
|
||||
|
||||
it("pre-aborted signal throws STREAM_ERROR", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
encodeTo(doc, () => {}, "wav", {
|
||||
registry: reg(),
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ code: AUDIO_CODECS_CODES.STREAM_ERROR });
|
||||
});
|
||||
|
||||
it("unknown format throws NOT_SUPPORTED", { timeout: 5000 }, async () => {
|
||||
const doc = makeDoc();
|
||||
await expect(
|
||||
encodeTo(doc, () => {}, "zzz", { registry: reg() })
|
||||
).rejects.toMatchObject({ code: AUDIO_CODECS_CODES.NOT_SUPPORTED });
|
||||
});
|
||||
|
||||
it("invalid destination throws INVALID_SOURCE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc();
|
||||
await expect(
|
||||
encodeTo(doc, 42 as unknown as string, "wav", { registry: reg() })
|
||||
).rejects.toBeInstanceOf(AudioCodecsError);
|
||||
});
|
||||
});
|
||||
109
packages/media/audio/audio-codecs/src/encode/encode-to.ts
Normal file
109
packages/media/audio/audio-codecs/src/encode/encode-to.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core";
|
||||
import z from "zod";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "../registry/registry.js";
|
||||
|
||||
export type Destination =
|
||||
| string
|
||||
| WritableStream<Uint8Array>
|
||||
| ((chunk: Uint8Array) => void);
|
||||
|
||||
export interface EncodeToOpts {
|
||||
readonly sampleRate?: number;
|
||||
readonly bitDepth?: 8 | 16 | 24 | 32;
|
||||
readonly format?: "pcm" | "float";
|
||||
readonly signal?: AbortSignal;
|
||||
readonly registry?: CodecRegistry;
|
||||
}
|
||||
|
||||
export const EncodeToOptsSchema = z
|
||||
.object({
|
||||
sampleRate: z.number().int().positive().optional(),
|
||||
bitDepth: z
|
||||
.union([z.literal(8), z.literal(16), z.literal(24), z.literal(32)])
|
||||
.optional(),
|
||||
format: z.enum(["pcm", "float"]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
async function writeToDestination(
|
||||
destination: Destination,
|
||||
bytes: Uint8Array
|
||||
): Promise<void> {
|
||||
if (typeof destination === "function") {
|
||||
destination(bytes);
|
||||
return;
|
||||
}
|
||||
if (destination instanceof WritableStream) {
|
||||
const writer = destination.getWriter();
|
||||
try {
|
||||
await writer.write(bytes);
|
||||
await writer.close();
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof destination === "string") {
|
||||
const fs = await import("node:fs/promises");
|
||||
await fs.writeFile(destination, bytes);
|
||||
return;
|
||||
}
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.INVALID_SOURCE,
|
||||
"audio-codecs.encode.unrecognised-destination",
|
||||
{ context: { type: typeof destination } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal encode entry point. Encodes the document via the shared
|
||||
* `CodecRegistry` and writes the bytes to a `Destination`:
|
||||
*
|
||||
* - `string` → file path via runtime-gated `node:fs/promises`.
|
||||
* - `WritableStream<Uint8Array>` → via its writer.
|
||||
* - `(chunk) => void` callback → single invocation with the full payload.
|
||||
*
|
||||
* Encoder faults are wrapped with `AUDIO_CODECS.ENCODE_FAILED` carrying the
|
||||
* original error as `cause`.
|
||||
*/
|
||||
export async function encodeTo(
|
||||
doc: AudioDocument,
|
||||
destination: Destination,
|
||||
format: string,
|
||||
opts: EncodeToOpts = {}
|
||||
): Promise<void> {
|
||||
const parsed = EncodeToOptsSchema.safeParse({
|
||||
sampleRate: opts.sampleRate,
|
||||
bitDepth: opts.bitDepth,
|
||||
format: opts.format,
|
||||
});
|
||||
if (!parsed.success)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.INVALID_SOURCE,
|
||||
"audio-codecs.encode.invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
if (opts.signal?.aborted)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.encode.aborted",
|
||||
{}
|
||||
);
|
||||
const registry = opts.registry ?? new CodecRegistry();
|
||||
// registry.encode already wraps every codec failure as
|
||||
// `AudioCodecsError("AUDIO_CODECS.ENCODE_FAILED")` with the original
|
||||
// error on `cause`; no additional re-wrapping needed here.
|
||||
const bytes = await registry.encode(doc, format, {
|
||||
...(opts.sampleRate !== undefined && {
|
||||
sampleRate: opts.sampleRate,
|
||||
}),
|
||||
...(opts.bitDepth !== undefined && { bitDepth: opts.bitDepth }),
|
||||
...(opts.format !== undefined && { format: opts.format }),
|
||||
...(opts.signal !== undefined && { signal: opts.signal }),
|
||||
});
|
||||
await writeToDestination(destination, bytes);
|
||||
}
|
||||
129
packages/media/audio/audio-codecs/src/error-codes/error-codes.md
Normal file
129
packages/media/audio/audio-codecs/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# `error-codes`
|
||||
|
||||
Stable error-code registry for `@suaveplan/audio-codecs`. Every fault raised
|
||||
by the package is an `AudioCodecsError` carrying one of seven codes from this
|
||||
registry plus an optional `cause` and `context`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`audio-codecs` is a public, plugin-driven boundary: callers hand it arbitrary
|
||||
bytes, files, URLs, or streams and expect a single stable contract on
|
||||
failure. Without a frozen code namespace, every codec implementation would be
|
||||
free to throw its own bespoke error shape and consumers would have to
|
||||
discriminate by message string — which rots the moment a codec rephrases
|
||||
itself.
|
||||
|
||||
This module is the contract. Every code is declared via `defineCodes`, every
|
||||
code has a description and severity in `AUDIO_CODECS_ERROR_METADATA`, and
|
||||
every code is asserted reachable from a call site by
|
||||
`src/error-codes.test.ts`. The full namespaced values
|
||||
(`AUDIO_CODECS.UNKNOWN_FORMAT`, etc.) are stable forever: renaming one is a
|
||||
breaking change that warrants its own OpenSpec proposal.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable codes covering detection, decode, encode, streaming, support,
|
||||
source-validation, and memory-budget faults.
|
||||
- Frozen `AUDIO_CODECS_ERROR_METADATA` describing every code's purpose and
|
||||
severity (`"error"` for hard faults, `"warning"` for AbortSignal / stream-
|
||||
termination scenarios).
|
||||
- `AudioCodecsError extends SuaveplanError<AudioCodecsErrorCode>` with a
|
||||
public constructor, a stable `category = "audio-codecs"` discriminator,
|
||||
and `cause` / `context` propagation from the base class.
|
||||
- Idempotent `registerAudioCodecsErrorCodes()` hook for downstream OTel
|
||||
attribute binders that want a guaranteed touch point.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "@suaveplan/audio-codecs/error-codes";
|
||||
|
||||
if (!codec) {
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.UNKNOWN_FORMAT,
|
||||
"audio-codecs.detection.unknown",
|
||||
{ context: { sourceKind: "buffer" } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Consumers catch by `instanceof AudioCodecsError` and discriminate on the
|
||||
stable `code` field — never on the message text, which is localisation-bound
|
||||
through the `SuaveplanError` `MessageResolver` hook.
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wiring codes into a structured reporter:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AUDIO_CODECS_ERROR_METADATA,
|
||||
AudioCodecsError,
|
||||
type AudioCodecsErrorCode,
|
||||
} from "@suaveplan/audio-codecs/error-codes";
|
||||
|
||||
function reportCodecFailure(err: unknown, span: import("@opentelemetry/api").Span): void {
|
||||
if (!(err instanceof AudioCodecsError)) {
|
||||
span.recordException(err as Error);
|
||||
return;
|
||||
}
|
||||
const meta = AUDIO_CODECS_ERROR_METADATA[err.code as AudioCodecsErrorCode];
|
||||
span.setStatus({ code: 2 /* ERROR */ });
|
||||
span.setAttribute("audio_codec.error_code", err.code);
|
||||
span.setAttribute("audio_codec.error_severity", meta.severity);
|
||||
if (err.cause instanceof Error) span.recordException(err.cause);
|
||||
}
|
||||
|
||||
// `BUDGET_EXCEEDED` and `STREAM_ERROR` are first-class outcomes a planning
|
||||
// stage can check before invoking decode — they aren't only thrown.
|
||||
if (projection.bytes > budget) {
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.BUDGET_EXCEEDED,
|
||||
"audio-codecs.decode.budget-exceeded",
|
||||
{ context: { projectedBytes: projection.bytes, budgetBytes: budget } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUDIO_CODECS_CODES` — frozen record of namespaced codes; access as
|
||||
`AUDIO_CODECS_CODES.DECODE_FAILED` and the value is
|
||||
`"AUDIO_CODECS.DECODE_FAILED"`.
|
||||
- `AudioCodecsErrorCode` — union type of every registry value.
|
||||
- `AudioCodecsErrorMetadata` — `{ description: string; severity: "warning" |
|
||||
"error" }`.
|
||||
- `AUDIO_CODECS_ERROR_METADATA: Readonly<Record<AudioCodecsErrorCode,
|
||||
AudioCodecsErrorMetadata>>` — frozen lookup keyed by code.
|
||||
- `AudioCodecsError` — constructor signature
|
||||
`new AudioCodecsError(code, messageKey, options?)` where `options` is the
|
||||
shared `SuaveplanErrorOptions` (`cause`, `context`, `traceId`).
|
||||
- `registerAudioCodecsErrorCodes()` — idempotent no-op hook.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The seven codes are deliberately narrow: every codec failure maps to one of
|
||||
them. Adapters that need finer granularity preserve the original error via
|
||||
`cause` and add provider-specific keys to `context` — they do not add new
|
||||
codes here.
|
||||
- `AUDIO_CODECS.STREAM_ERROR` carries `severity: "warning"` because the most
|
||||
common cause is caller-initiated `AbortSignal` cancellation, which is a
|
||||
predictable cooperative termination rather than a fault. Genuine streaming
|
||||
faults (codec stream stalls, unexpected EOF mid-chunk) still use this code
|
||||
but elevate severity at the reporting layer via `cause`.
|
||||
- The class carries a `readonly category = "audio-codecs"` literal so the
|
||||
base `SuaveplanError` constructor widening is non-trivial (avoids
|
||||
`noUselessConstructor` lint without a suppression comment).
|
||||
- The registration hook is intentionally a no-op: the metadata record is
|
||||
declared frozen at module evaluation time, so there is no side-effect to
|
||||
defer. Keeping the hook exported preserves the same shape as sibling
|
||||
audio packages, so a bulk registrar (`audio-core`, `audio-controllers`,
|
||||
`audio-codecs`, etc.) can iterate them uniformly at startup.
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AUDIO_CODECS_ERROR_METADATA,
|
||||
AudioCodecsError,
|
||||
registerAudioCodecsErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("registers exactly the seven AUDIO_CODECS codes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.keys(AUDIO_CODECS_CODES).sort()).toEqual([
|
||||
"BUDGET_EXCEEDED",
|
||||
"DECODE_FAILED",
|
||||
"ENCODE_FAILED",
|
||||
"INVALID_SOURCE",
|
||||
"NOT_SUPPORTED",
|
||||
"STREAM_ERROR",
|
||||
"UNKNOWN_FORMAT",
|
||||
]);
|
||||
});
|
||||
|
||||
it("namespaces every code value as AUDIO_CODECS.<REASON>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(AUDIO_CODECS_CODES.UNKNOWN_FORMAT).toBe(
|
||||
"AUDIO_CODECS.UNKNOWN_FORMAT"
|
||||
);
|
||||
expect(AUDIO_CODECS_CODES.BUDGET_EXCEEDED).toBe(
|
||||
"AUDIO_CODECS.BUDGET_EXCEEDED"
|
||||
);
|
||||
});
|
||||
|
||||
it("freezes the metadata record", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(AUDIO_CODECS_ERROR_METADATA)).toBe(true);
|
||||
});
|
||||
|
||||
it("metadata covers every code", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(AUDIO_CODECS_CODES)) {
|
||||
expect(AUDIO_CODECS_ERROR_METADATA[code]).toBeDefined();
|
||||
expect(
|
||||
AUDIO_CODECS_ERROR_METADATA[code].description.length
|
||||
).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
it("AudioCodecsError carries code, message, cause, and context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.test",
|
||||
{ cause, context: { format: "wav" } }
|
||||
);
|
||||
expect(err).toBeInstanceOf(AudioCodecsError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe(AUDIO_CODECS_CODES.DECODE_FAILED);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ format: "wav" });
|
||||
expect(err.name).toBe("AudioCodecsError");
|
||||
expect(err.category).toBe("audio-codecs");
|
||||
});
|
||||
|
||||
it("AudioCodecsError defaults options to {}", { timeout: 5000 }, () => {
|
||||
const err = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.test"
|
||||
);
|
||||
expect(err.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registerAudioCodecsErrorCodes is idempotent", { timeout: 5000 }, () => {
|
||||
registerAudioCodecsErrorCodes();
|
||||
registerAudioCodecsErrorCodes();
|
||||
registerAudioCodecsErrorCodes();
|
||||
expect(Object.isFrozen(AUDIO_CODECS_ERROR_METADATA)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/audio-codecs`. The string values
|
||||
* are the namespaced `AUDIO_CODECS.<REASON>` form produced by `defineCodes`.
|
||||
*/
|
||||
export const AUDIO_CODECS_CODES = defineCodes("AUDIO_CODECS", [
|
||||
"UNKNOWN_FORMAT",
|
||||
"DECODE_FAILED",
|
||||
"ENCODE_FAILED",
|
||||
"STREAM_ERROR",
|
||||
"NOT_SUPPORTED",
|
||||
"INVALID_SOURCE",
|
||||
"BUDGET_EXCEEDED",
|
||||
] as const);
|
||||
|
||||
export type AudioCodecsErrorCode =
|
||||
(typeof AUDIO_CODECS_CODES)[keyof typeof AUDIO_CODECS_CODES];
|
||||
|
||||
export interface AudioCodecsErrorMetadata {
|
||||
readonly description: string;
|
||||
readonly severity: "warning" | "error";
|
||||
}
|
||||
|
||||
export const AUDIO_CODECS_ERROR_METADATA: Readonly<
|
||||
Record<AudioCodecsErrorCode, AudioCodecsErrorMetadata>
|
||||
> = Object.freeze({
|
||||
[AUDIO_CODECS_CODES.UNKNOWN_FORMAT]: {
|
||||
description:
|
||||
"Detection found no registered codec for the bytes and no usable `opts.format` hint was provided.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODECS_CODES.DECODE_FAILED]: {
|
||||
description:
|
||||
"A codec rejected the input bytes — malformed container, truncated chunk, or unsupported sample format.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODECS_CODES.ENCODE_FAILED]: {
|
||||
description:
|
||||
"A codec's encode call threw; the original error is preserved on `error.cause`.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODECS_CODES.STREAM_ERROR]: {
|
||||
description:
|
||||
"Streaming decode/encode aborted, the underlying reader released, or a streaming-only operation was requested for a codec exposing no streaming factory.",
|
||||
severity: "warning",
|
||||
},
|
||||
[AUDIO_CODECS_CODES.NOT_SUPPORTED]: {
|
||||
description:
|
||||
"The requested operation is not supported by the resolved codec — decode-only format asked to encode, or a WASM `optionalDependency` is not installed.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODECS_CODES.INVALID_SOURCE]: {
|
||||
description:
|
||||
"The source value is not a recognised Source variant, or a public options object failed Zod boundary validation.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODECS_CODES.BUDGET_EXCEEDED]: {
|
||||
description:
|
||||
"Projected decoded-PCM memory exceeds the caller's `budget` — decode aborted before any page allocation.",
|
||||
severity: "error",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Concrete `SuaveplanError` subclass for every fault raised by
|
||||
* `@suaveplan/audio-codecs`. The base constructor is `protected`; this class
|
||||
* widens it to `public` while remaining non-trivial via the `category` field,
|
||||
* so it constructs without any static-analysis suppression.
|
||||
*/
|
||||
export class AudioCodecsError extends SuaveplanError<AudioCodecsErrorCode> {
|
||||
public readonly category = "audio-codecs" as const;
|
||||
|
||||
public constructor(
|
||||
code: AudioCodecsErrorCode,
|
||||
messageKey: string,
|
||||
options: SuaveplanErrorOptions = {}
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "AudioCodecsError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent registration hook. Metadata is already frozen and exported for
|
||||
* direct lookup; consumers that bind code registries to OTel attributes or
|
||||
* report-builders can call this once at startup to ensure the registry has
|
||||
* been touched.
|
||||
*/
|
||||
export function registerAudioCodecsErrorCodes(): void {
|
||||
// No-op — registry side-effects are static at module load.
|
||||
}
|
||||
|
||||
registerAudioCodecsErrorCodes();
|
||||
10
packages/media/audio/audio-codecs/src/error-codes/index.ts
Normal file
10
packages/media/audio/audio-codecs/src/error-codes/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
AudioCodecsErrorCode,
|
||||
AudioCodecsErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
AUDIO_CODECS_CODES,
|
||||
AUDIO_CODECS_ERROR_METADATA,
|
||||
AudioCodecsError,
|
||||
registerAudioCodecsErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
63
packages/media/audio/audio-codecs/src/index.ts
Normal file
63
packages/media/audio/audio-codecs/src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export {
|
||||
AacCodec,
|
||||
AiffCodec,
|
||||
CafCodec,
|
||||
FlacCodec,
|
||||
Mp3Codec,
|
||||
OggVorbisCodec,
|
||||
OpusCodec,
|
||||
QoaCodec,
|
||||
WavCodec,
|
||||
WebMCodec,
|
||||
} from "./codecs/index.js";
|
||||
export {
|
||||
createWasmCodec,
|
||||
type WasmCodecModule,
|
||||
type WasmCodecSpec,
|
||||
} from "./codecs/wasm-base.js";
|
||||
export {
|
||||
type DecodeSourceOpts,
|
||||
DecodeSourceOptsSchema,
|
||||
decodeSource,
|
||||
progressiveDecode,
|
||||
} from "./decode/decode-source.js";
|
||||
export {
|
||||
type DetectResult,
|
||||
detectSource,
|
||||
HEADER_SIZE,
|
||||
resolveSource,
|
||||
type Source,
|
||||
} from "./detect/detect-format.js";
|
||||
export {
|
||||
type Destination,
|
||||
type EncodeToOpts,
|
||||
EncodeToOptsSchema,
|
||||
encodeTo,
|
||||
} from "./encode/encode-to.js";
|
||||
export {
|
||||
AUDIO_CODECS_CODES,
|
||||
AUDIO_CODECS_ERROR_METADATA,
|
||||
AudioCodecsError,
|
||||
type AudioCodecsErrorCode,
|
||||
type AudioCodecsErrorMetadata,
|
||||
registerAudioCodecsErrorCodes,
|
||||
} from "./error-codes/index.js";
|
||||
export { CodecRegistry } from "./registry/registry.js";
|
||||
export {
|
||||
AudioCodecSchema,
|
||||
CodecRegistryOptionsSchema,
|
||||
DecodeOptsSchema,
|
||||
EncodeOptsSchema,
|
||||
} from "./registry/registry.schemas.js";
|
||||
export type {
|
||||
AudioCodec,
|
||||
AudioDocumentBuilder,
|
||||
CodecRegistryOptions,
|
||||
DecodeOpts,
|
||||
DecodeResult,
|
||||
EncodeOpts,
|
||||
StreamingDecoder,
|
||||
StreamingEncoder,
|
||||
} from "./registry/registry.types.js";
|
||||
export { createDecodeTransform } from "./stream/stream-decoder.js";
|
||||
export { createEncodeTransform } from "./stream/stream-encoder.js";
|
||||
141
packages/media/audio/audio-codecs/src/registry/registry.md
Normal file
141
packages/media/audio/audio-codecs/src/registry/registry.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# `registry/registry`
|
||||
|
||||
In-memory plugin registry that owns audio-codec lookup and dispatch for the
|
||||
whole package.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`CodecRegistry` is the single entry point through which `decodeSource`,
|
||||
`encodeTo`, `progressiveDecode`, and the streaming transform factories
|
||||
resolve a codec from raw bytes, a format hint, an extension, or a MIME type
|
||||
and then dispatch decode/encode operations to it. Without a single registry,
|
||||
every public entry point would re-implement the lookup + dispatch + error
|
||||
mapping triad — duplicating wiring and risking divergence.
|
||||
|
||||
The registry also owns the package's OpenTelemetry instrumentation surface
|
||||
for decode/encode calls (`codec.decode` / `codec.encode` spans, the
|
||||
`audio_codec_*` counter and histogram pair). Codecs themselves stay free of
|
||||
OTel concerns; the registry observes the boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Three lookup indexes — by canonical `name`, by file extension, by MIME
|
||||
type. `get(key)` checks all three so callers do not need to know which
|
||||
shape they hold.
|
||||
- Eager initial registration via `new CodecRegistry({ codecs: [...] })` plus
|
||||
imperative `register(codec)` / `unregister(name)`.
|
||||
- `detect(bytes)` walks every registered codec's `canDecode` in
|
||||
registration order and returns the first match's `name` (or `undefined`).
|
||||
- `decode(bytes, opts?)` resolves the codec, awaits its `decode`, and builds
|
||||
an `AudioDocument` from the resulting `Float32Array[]` PCM. Non-API codec
|
||||
errors are wrapped in `AudioCodecsError("AUDIO_CODECS.DECODE_FAILED")`
|
||||
with the original on `cause`; `AudioCodecsError` instances pass through.
|
||||
- `encode(doc, format, opts?)` materialises the document's PCM, dispatches
|
||||
to the codec registered for `format`, and returns the encoded bytes.
|
||||
Unknown format → `AUDIO_CODECS.NOT_SUPPORTED`. Non-API encode error →
|
||||
`AUDIO_CODECS.ENCODE_FAILED` with `cause`.
|
||||
- `decodeStream(source, format)` returns a `ReadableStream<AudioPage>` whose
|
||||
pull loop drives the codec's `createDecoder()` streaming factory.
|
||||
Codecs without that factory throw `AUDIO_CODECS.STREAM_ERROR`.
|
||||
- `encodeStream(doc, format)` returns a `ReadableStream<Uint8Array>` whose
|
||||
pull loop feeds the document's pages (sorted by `startSample`) into the
|
||||
codec's `createEncoder()`.
|
||||
- `list()` returns every registered codec for introspection.
|
||||
- Zod-validated codec registration via `AudioCodecSchema` — a malformed
|
||||
codec object is rejected at `register()` with `INVALID_SOURCE`.
|
||||
- OTel spans, counter, and duration histogram on every successful decode
|
||||
and encode; spans carry the resolved `format` as an attribute.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs/registry/registry";
|
||||
import { WavCodec } from "@suaveplan/audio-codecs/codecs/wav";
|
||||
|
||||
const registry = new CodecRegistry({ codecs: [WavCodec] });
|
||||
|
||||
// decode WAV bytes into an AudioDocument
|
||||
const doc = await registry.decode(wavBytes);
|
||||
|
||||
// re-encode back to WAV at 24-bit PCM
|
||||
const wav = await registry.encode(doc, "wav", { bitDepth: 24, format: "pcm" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Hot-swap a codec and pipeline a streaming decode into a custom transform:
|
||||
|
||||
```ts
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs/registry/registry";
|
||||
import { Mp3Codec, WavCodec, FlacCodec } from "@suaveplan/audio-codecs";
|
||||
|
||||
const registry = new CodecRegistry({ codecs: [WavCodec, Mp3Codec] });
|
||||
registry.register(FlacCodec); // additive — now decodes FLAC too
|
||||
|
||||
const sourceStream: ReadableStream<Uint8Array> = await fetchAudio();
|
||||
const pageStream = registry.decodeStream(sourceStream, "mp3");
|
||||
|
||||
// pipe through a downstream normaliser (defined elsewhere)
|
||||
const normalised = pageStream.pipeThrough(makeNormaliser());
|
||||
|
||||
for await (const page of normalised) {
|
||||
consumePage(page);
|
||||
}
|
||||
|
||||
// detect-then-dispatch for unknown bytes
|
||||
const fmt = registry.detect(unknownBytes);
|
||||
if (fmt) {
|
||||
const doc = await registry.decode(unknownBytes, { format: fmt });
|
||||
useDoc(doc);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class CodecRegistry`
|
||||
- `constructor(options?: CodecRegistryOptions)` — `options.codecs` are
|
||||
each `register`-ed eagerly.
|
||||
- `register(codec: AudioCodec): void` — throws
|
||||
`AudioCodecsError("AUDIO_CODECS.INVALID_SOURCE")` if `codec` fails
|
||||
`AudioCodecSchema` validation. Replaces any prior codec sharing the
|
||||
same `name`; extension and MIME indexes additively expand.
|
||||
- `unregister(name: string): void` — no-op for unknown names; clears
|
||||
matching extension and MIME entries.
|
||||
- `get(nameOrExtOrMime: string): AudioCodec | undefined`
|
||||
- `list(): readonly AudioCodec[]`
|
||||
- `detect(bytes: Uint8Array): string | undefined`
|
||||
- `decode(bytes, opts?): Promise<AudioDocument>` — `opts.format` is a hint
|
||||
that wins over `canDecode` detection when supplied; `opts.metadata` is
|
||||
forwarded onto the resulting `AudioDocument`.
|
||||
- `encode(doc, format, opts?): Promise<Uint8Array>`
|
||||
- `decodeStream(source, format): ReadableStream<AudioPage>`
|
||||
- `encodeStream(doc, format): ReadableStream<Uint8Array>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The three lookup indexes are kept independently — `unregister` walks both
|
||||
extension and MIME maps and deletes only entries that still point at the
|
||||
removed codec, so registering two codecs that share an extension and
|
||||
unregistering one leaves the other intact.
|
||||
- `detect` iteration order is the order codecs were registered. Callers
|
||||
that need a different priority (e.g. AIFF before WAV when both
|
||||
canDecode an ambiguous prefix) should re-`register` the higher-priority
|
||||
codec last so its `canDecode` runs first.
|
||||
- The `#instrumented` helper centralises span lifetime, counter, and
|
||||
histogram observations. It catches any thrown error, sets the span
|
||||
status to ERROR, records the exception, then re-throws — so the
|
||||
outer-most caller still observes the original (or wrapped) error.
|
||||
- `pcmFromDocument` walks the document's pages in their stored order, not
|
||||
by `startSample`. For documents produced by `buildDocument` this is the
|
||||
same; for hand-constructed documents the caller is responsible for
|
||||
ordered insertion. `encodeStream` does sort by `startSample` explicitly
|
||||
to handle reordering at the streaming boundary.
|
||||
- The constants `TRACER`, `METER`, `DECODE_COUNTER`, `ENCODE_COUNTER`,
|
||||
`DECODE_HIST`, `ENCODE_HIST` are acquired at module evaluation time but
|
||||
the underlying OTel SDK is a no-op when no exporter is registered, so
|
||||
importing the registry has no observable runtime cost in unconfigured
|
||||
environments.
|
||||
@@ -0,0 +1,42 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Schema for `DecodeOpts` — the cross-cutting decode options. */
|
||||
export const DecodeOptsSchema = z
|
||||
.object({
|
||||
format: z.string().min(1).optional(),
|
||||
signal: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Schema for `EncodeOpts`. */
|
||||
export const EncodeOptsSchema = z
|
||||
.object({
|
||||
sampleRate: z.number().int().positive().optional(),
|
||||
bitDepth: z
|
||||
.union([z.literal(8), z.literal(16), z.literal(24), z.literal(32)])
|
||||
.optional(),
|
||||
format: z.enum(["pcm", "float"]).optional(),
|
||||
signal: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Schema for the public-shape contract of an `AudioCodec`. */
|
||||
export const AudioCodecSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
extensions: z.array(z.string().min(1)).readonly(),
|
||||
mimeTypes: z.array(z.string().min(1)).readonly(),
|
||||
canDecode: z.function(),
|
||||
decode: z.function(),
|
||||
encode: z.function(),
|
||||
createDecoder: z.function().optional(),
|
||||
createEncoder: z.function().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** Schema for `CodecRegistryOptions`. */
|
||||
export const CodecRegistryOptionsSchema = z
|
||||
.object({
|
||||
codecs: z.array(AudioCodecSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
293
packages/media/audio/audio-codecs/src/registry/registry.test.ts
Normal file
293
packages/media/audio/audio-codecs/src/registry/registry.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AiffCodec } from "../codecs/aiff.js";
|
||||
import { WavCodec } from "../codecs/wav.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "./registry.js";
|
||||
import type { AudioCodec } from "./registry.types.js";
|
||||
|
||||
const SAMPLE_RATE = 44_100;
|
||||
|
||||
async function makeWavBytes(samples = 8): Promise<Uint8Array> {
|
||||
return WavCodec.encode([new Float32Array(samples)], {
|
||||
sampleRate: SAMPLE_RATE,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
}
|
||||
|
||||
describe("CodecRegistry", () => {
|
||||
it("registers a codec by name, extension, and mime", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new CodecRegistry();
|
||||
r.register(WavCodec);
|
||||
expect(r.get("wav")).toBe(WavCodec);
|
||||
expect(r.get("wave")).toBe(WavCodec);
|
||||
expect(r.get("audio/wav")).toBe(WavCodec);
|
||||
expect(r.get("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("constructor accepts initial codecs array", { timeout: 5000 }, () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec, AiffCodec] });
|
||||
expect(r.list().length).toBe(2);
|
||||
});
|
||||
|
||||
it("registering same name replaces prior codec", { timeout: 5000 }, () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const replacement: AudioCodec = {
|
||||
...WavCodec,
|
||||
name: "wav",
|
||||
mimeTypes: ["audio/replacement"],
|
||||
extensions: ["wav2"],
|
||||
};
|
||||
r.register(replacement);
|
||||
expect(r.get("wav")).toBe(replacement);
|
||||
expect(r.get("wav2")).toBe(replacement);
|
||||
});
|
||||
|
||||
it("unregister drops by name and clears matching indexes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec, AiffCodec] });
|
||||
r.unregister("wav");
|
||||
expect(r.get("wav")).toBeUndefined();
|
||||
expect(r.get("aiff")).toBe(AiffCodec);
|
||||
});
|
||||
|
||||
it("unregister is a no-op for unknown names", { timeout: 5000 }, () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
r.unregister("zzz");
|
||||
expect(r.get("wav")).toBe(WavCodec);
|
||||
});
|
||||
|
||||
it("register rejects malformed codec via Zod", { timeout: 5000 }, () => {
|
||||
const r = new CodecRegistry();
|
||||
expect(() => r.register({} as unknown as AudioCodec)).toThrow(
|
||||
AudioCodecsError
|
||||
);
|
||||
});
|
||||
|
||||
it("detect returns the first matching codec name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await makeWavBytes();
|
||||
expect(r.detect(wav)).toBe("wav");
|
||||
});
|
||||
|
||||
it("detect returns undefined for unrecognised bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
expect(r.detect(new Uint8Array(64))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("decode dispatches to detected codec and builds an AudioDocument", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await makeWavBytes(2048);
|
||||
const doc = await r.decode(wav);
|
||||
expect(doc.sampleRate).toBe(SAMPLE_RATE);
|
||||
expect(doc.channels).toBe(1);
|
||||
expect(doc.sourceLength).toBe(2048);
|
||||
});
|
||||
|
||||
it("decode uses opts.format as hint", { timeout: 30000 }, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await makeWavBytes(4);
|
||||
const doc = await r.decode(wav, { format: "wav" });
|
||||
expect(doc.sampleRate).toBe(SAMPLE_RATE);
|
||||
});
|
||||
|
||||
it("decode throws UNKNOWN_FORMAT for unrecognised bytes with no hint", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
await expect(r.decode(new Uint8Array(64))).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.UNKNOWN_FORMAT,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode wraps non-API codec errors as DECODE_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const broken: AudioCodec = {
|
||||
...WavCodec,
|
||||
name: "broken",
|
||||
extensions: ["broken"],
|
||||
mimeTypes: [],
|
||||
decode: async () => {
|
||||
throw new TypeError("boom");
|
||||
},
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
await expect(
|
||||
r.decode(new Uint8Array(64), { format: "broken" })
|
||||
).rejects.toMatchObject({ code: AUDIO_CODECS_CODES.DECODE_FAILED });
|
||||
});
|
||||
|
||||
it("decode preserves AudioCodecsError from codec unchanged", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const inner = new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"inner"
|
||||
);
|
||||
const broken: AudioCodec = {
|
||||
...WavCodec,
|
||||
name: "inner-broken",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
decode: async () => {
|
||||
throw inner;
|
||||
},
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [broken] });
|
||||
await expect(
|
||||
r.decode(new Uint8Array(64), { format: "inner-broken" })
|
||||
).rejects.toBe(inner);
|
||||
});
|
||||
|
||||
it("encode round-trips through WAV codec", { timeout: 30000 }, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await makeWavBytes(64);
|
||||
const doc = await r.decode(wav);
|
||||
const re = await r.encode(doc, "wav", { bitDepth: 16, format: "pcm" });
|
||||
expect(re.byteLength).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("encode throws NOT_SUPPORTED for unknown format", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await makeWavBytes(16);
|
||||
const doc = await r.decode(wav);
|
||||
await expect(r.encode(doc, "zzz")).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("encode wraps non-API codec errors as ENCODE_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const broken: AudioCodec = {
|
||||
...WavCodec,
|
||||
name: "broken-enc",
|
||||
extensions: [],
|
||||
mimeTypes: [],
|
||||
encode: async () => {
|
||||
throw new TypeError("boom");
|
||||
},
|
||||
};
|
||||
const r = new CodecRegistry({ codecs: [WavCodec, broken] });
|
||||
const wav = await makeWavBytes(8);
|
||||
const doc = await r.decode(wav);
|
||||
await expect(r.encode(doc, "broken-enc")).rejects.toMatchObject({
|
||||
code: AUDIO_CODECS_CODES.ENCODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodeStream emits one page per decoded chunk", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await makeWavBytes(2048);
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const chunkSize = 137;
|
||||
for (let off = 0; off < wav.byteLength; off += chunkSize) {
|
||||
controller.enqueue(wav.subarray(off, off + chunkSize));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const stream = r.decodeStream(src, "wav");
|
||||
const reader = stream.getReader();
|
||||
const pages: { channelData: Float32Array[]; startSample: number }[] =
|
||||
[];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) pages.push(value);
|
||||
}
|
||||
expect(pages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("decodeStream throws NOT_SUPPORTED for unknown format", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const src = new ReadableStream<Uint8Array>();
|
||||
expect(() => r.decodeStream(src, "zzz")).toThrow(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("decodeStream throws STREAM_ERROR for codec lacking createDecoder", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new CodecRegistry({ codecs: [AiffCodec] });
|
||||
const src = new ReadableStream<Uint8Array>();
|
||||
expect(() => r.decodeStream(src, "aiff")).toThrow(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("encodeStream emits header + body + trailer", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await makeWavBytes(64);
|
||||
const doc = await r.decode(wav);
|
||||
const stream = r.encodeStream(doc, "wav");
|
||||
const reader = stream.getReader();
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) total += value.byteLength;
|
||||
}
|
||||
expect(total).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("encodeStream throws NOT_SUPPORTED for unknown format", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await makeWavBytes(4);
|
||||
const doc = await r.decode(wav);
|
||||
expect(() => r.encodeStream(doc, "zzz")).toThrow(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("encodeStream throws STREAM_ERROR for codec lacking createEncoder", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec, AiffCodec] });
|
||||
const wav = await makeWavBytes(4);
|
||||
const doc = await r.decode(wav);
|
||||
expect(() => r.encodeStream(doc, "aiff")).toThrow(AudioCodecsError);
|
||||
});
|
||||
|
||||
it("decodeStream cancel releases the reader", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await makeWavBytes(64);
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(wav);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const stream = r.decodeStream(src, "wav");
|
||||
await stream.cancel("test cancel");
|
||||
});
|
||||
|
||||
it("encodeStream cancel closes encoder", { timeout: 5000 }, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const wav = await makeWavBytes(8);
|
||||
const doc = await r.decode(wav);
|
||||
const stream = r.encodeStream(doc, "wav");
|
||||
await stream.cancel("test cancel");
|
||||
});
|
||||
});
|
||||
371
packages/media/audio/audio-codecs/src/registry/registry.ts
Normal file
371
packages/media/audio/audio-codecs/src/registry/registry.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { metrics, type Span, SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { AudioDocument, PAGE_SIZE } from "@suaveplan/audio-core";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { AudioCodecSchema } from "./registry.schemas.js";
|
||||
import type {
|
||||
AudioCodec,
|
||||
CodecRegistryOptions,
|
||||
DecodeOpts,
|
||||
DecodeResult,
|
||||
EncodeOpts,
|
||||
} from "./registry.types.js";
|
||||
|
||||
const TRACER = trace.getTracer("@suaveplan/audio-codecs");
|
||||
const METER = metrics.getMeter("@suaveplan/audio-codecs");
|
||||
const DECODE_COUNTER = METER.createCounter("audio_codec_decode_total", {
|
||||
description: "Decoded inputs by format",
|
||||
});
|
||||
const ENCODE_COUNTER = METER.createCounter("audio_codec_encode_total", {
|
||||
description: "Encoded outputs by format",
|
||||
});
|
||||
const DECODE_HIST = METER.createHistogram("audio_codec_decode_duration_ms", {
|
||||
description: "Decode wall-clock duration in milliseconds",
|
||||
});
|
||||
const ENCODE_HIST = METER.createHistogram("audio_codec_encode_duration_ms", {
|
||||
description: "Encode wall-clock duration in milliseconds",
|
||||
});
|
||||
|
||||
function buildDocument(
|
||||
result: DecodeResult,
|
||||
metadata?: unknown
|
||||
): AudioDocument {
|
||||
const channels = result.channelData.length;
|
||||
if (channels === 0)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.registry.empty-channel-data",
|
||||
{}
|
||||
);
|
||||
const sampleCount = result.channelData[0]!.length;
|
||||
const pages: { channelData: Float32Array[]; startSample: number }[] = [];
|
||||
for (let start = 0; start < sampleCount; start += PAGE_SIZE) {
|
||||
const end = Math.min(start + PAGE_SIZE, sampleCount);
|
||||
const channelData: Float32Array[] = [];
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
const target = new Float32Array(PAGE_SIZE);
|
||||
const src = result.channelData[ch];
|
||||
if (src) target.set(src.subarray(start, end));
|
||||
channelData.push(target);
|
||||
}
|
||||
pages.push({ channelData, startSample: start });
|
||||
}
|
||||
return new AudioDocument({
|
||||
sampleRate: result.sampleRate,
|
||||
channels,
|
||||
sourceLength: sampleCount,
|
||||
pages,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
function pcmFromDocument(doc: AudioDocument): Float32Array[] {
|
||||
const ch: Float32Array[] = [];
|
||||
const len = doc.sourceLength;
|
||||
for (let c = 0; c < doc.channels; c++) {
|
||||
const buf = new Float32Array(len);
|
||||
let off = 0;
|
||||
for (const page of doc.pages) {
|
||||
if (page === null) continue;
|
||||
const src = page.channelData[c];
|
||||
if (!src) continue;
|
||||
const start = page.startSample;
|
||||
const copy = Math.min(src.length, len - start);
|
||||
if (copy > 0) buf.set(src.subarray(0, copy), start);
|
||||
off++;
|
||||
}
|
||||
if (off === 0) {
|
||||
// Fallthrough — pages array empty, length is zero.
|
||||
}
|
||||
ch.push(buf);
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory codec registry. Owns three lookups (by `name`, by `extension`,
|
||||
* by `mimeType`) plus dispatch helpers for non-streaming decode/encode and
|
||||
* the streaming `decodeStream` / `encodeStream` factories.
|
||||
*/
|
||||
export class CodecRegistry {
|
||||
readonly #byName = new Map<string, AudioCodec>();
|
||||
readonly #byExt = new Map<string, AudioCodec>();
|
||||
readonly #byMime = new Map<string, AudioCodec>();
|
||||
|
||||
public constructor(opts: CodecRegistryOptions = {}) {
|
||||
if (opts.codecs) for (const c of opts.codecs) this.register(c);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a codec. Existing entries with the same `name` are replaced;
|
||||
* extension and MIME indexes additively expand.
|
||||
*/
|
||||
public register(codec: AudioCodec): void {
|
||||
const parsed = AudioCodecSchema.safeParse(codec);
|
||||
if (!parsed.success)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.INVALID_SOURCE,
|
||||
"audio-codecs.registry.invalid-codec",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
this.#byName.set(codec.name, codec);
|
||||
for (const ext of codec.extensions) this.#byExt.set(ext, codec);
|
||||
for (const mime of codec.mimeTypes) this.#byMime.set(mime, codec);
|
||||
}
|
||||
|
||||
/** Drop the codec registered under `name`. No-op when absent. */
|
||||
public unregister(name: string): void {
|
||||
const codec = this.#byName.get(name);
|
||||
if (!codec) return;
|
||||
this.#byName.delete(name);
|
||||
for (const ext of codec.extensions) {
|
||||
if (this.#byExt.get(ext) === codec) this.#byExt.delete(ext);
|
||||
}
|
||||
for (const mime of codec.mimeTypes) {
|
||||
if (this.#byMime.get(mime) === codec) this.#byMime.delete(mime);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lookup a codec by `name` (canonical) — or by extension / MIME. */
|
||||
public get(nameOrExtOrMime: string): AudioCodec | undefined {
|
||||
return (
|
||||
this.#byName.get(nameOrExtOrMime) ??
|
||||
this.#byExt.get(nameOrExtOrMime) ??
|
||||
this.#byMime.get(nameOrExtOrMime)
|
||||
);
|
||||
}
|
||||
|
||||
/** Iterate every registered codec (no implied order). */
|
||||
public list(): readonly AudioCodec[] {
|
||||
return Array.from(this.#byName.values());
|
||||
}
|
||||
|
||||
/** Magic-byte detection — first matching codec wins; `undefined` if none. */
|
||||
public detect(bytes: Uint8Array): string | undefined {
|
||||
const span = TRACER.startSpan("codec.detect");
|
||||
try {
|
||||
for (const codec of this.#byName.values()) {
|
||||
if (codec.canDecode(bytes)) {
|
||||
span.setAttribute("format", codec.name);
|
||||
return codec.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-buffer decode dispatch. Builds an `AudioDocument` from the codec
|
||||
* output and copies through any caller-supplied metadata.
|
||||
*/
|
||||
public async decode(
|
||||
bytes: Uint8Array,
|
||||
opts: DecodeOpts & { metadata?: unknown } = {}
|
||||
): Promise<AudioDocument> {
|
||||
return this.#instrumented(
|
||||
"codec.decode",
|
||||
DECODE_COUNTER,
|
||||
DECODE_HIST,
|
||||
async (span) => {
|
||||
const codec = this.#resolveCodec(bytes, opts.format);
|
||||
span.setAttribute("format", codec.name);
|
||||
let result: DecodeResult;
|
||||
try {
|
||||
result = await codec.decode(bytes, opts);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioCodecsError) throw err;
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.DECODE_FAILED,
|
||||
"audio-codecs.decode.failed",
|
||||
{ cause: err, context: { format: codec.name } }
|
||||
);
|
||||
}
|
||||
return buildDocument(result, opts.metadata);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-buffer encode dispatch. The caller resolves `format` explicitly —
|
||||
* no detection is run on the document side.
|
||||
*/
|
||||
public async encode(
|
||||
doc: AudioDocument,
|
||||
format: string,
|
||||
opts: EncodeOpts = {}
|
||||
): Promise<Uint8Array> {
|
||||
return this.#instrumented(
|
||||
"codec.encode",
|
||||
ENCODE_COUNTER,
|
||||
ENCODE_HIST,
|
||||
async (span) => {
|
||||
const codec = this.#byName.get(format);
|
||||
if (!codec)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.encode.unknown-format",
|
||||
{ context: { format } }
|
||||
);
|
||||
span.setAttribute("format", codec.name);
|
||||
const channelData = pcmFromDocument(doc);
|
||||
try {
|
||||
return await codec.encode(channelData, {
|
||||
sampleRate: doc.sampleRate,
|
||||
...opts,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AudioCodecsError) throw err;
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.ENCODE_FAILED,
|
||||
"audio-codecs.encode.failed",
|
||||
{ cause: err, context: { format } }
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `ReadableStream<AudioPage>` driven by the codec's `StreamingDecoder`.
|
||||
* Throws `AUDIO_CODECS.STREAM_ERROR` if the codec has no streaming
|
||||
* factory.
|
||||
*/
|
||||
public decodeStream(
|
||||
source: ReadableStream<Uint8Array>,
|
||||
format: string
|
||||
): ReadableStream<{ channelData: Float32Array[]; startSample: number }> {
|
||||
const codec = this.#byName.get(format);
|
||||
if (!codec)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.stream.unknown-format",
|
||||
{ context: { format } }
|
||||
);
|
||||
if (!codec.createDecoder)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.stream.no-decoder-factory",
|
||||
{ context: { format } }
|
||||
);
|
||||
const decoder = codec.createDecoder();
|
||||
let startSample = 0;
|
||||
const reader = source.getReader();
|
||||
return new ReadableStream({
|
||||
async pull(controller) {
|
||||
const { done, value } = await reader.read();
|
||||
const results = done ? decoder.flush() : decoder.write(value!);
|
||||
for (const r of results) {
|
||||
const channelData = r.channelData.map(
|
||||
(c) => new Float32Array(c)
|
||||
);
|
||||
controller.enqueue({ channelData, startSample });
|
||||
startSample += channelData[0]?.length ?? 0;
|
||||
}
|
||||
if (done) {
|
||||
decoder.close();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel(reason) {
|
||||
decoder.close();
|
||||
return reader.cancel(reason);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `ReadableStream<Uint8Array>` driven by the codec's `StreamingEncoder`,
|
||||
* fed by the document's pages in `startSample` order.
|
||||
*/
|
||||
public encodeStream(
|
||||
doc: AudioDocument,
|
||||
format: string
|
||||
): ReadableStream<Uint8Array> {
|
||||
const codec = this.#byName.get(format);
|
||||
if (!codec)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.stream.unknown-format",
|
||||
{ context: { format } }
|
||||
);
|
||||
if (!codec.createEncoder)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.stream.no-encoder-factory",
|
||||
{ context: { format } }
|
||||
);
|
||||
const encoder = codec.createEncoder();
|
||||
const channels = doc.channels;
|
||||
const sortedPages = [...doc.pages]
|
||||
.filter((p): p is NonNullable<typeof p> => p !== null)
|
||||
.sort((a, b) => a.startSample - b.startSample);
|
||||
let pageIdx = 0;
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.start(doc.sampleRate, channels));
|
||||
},
|
||||
pull(controller) {
|
||||
if (pageIdx >= sortedPages.length) {
|
||||
controller.enqueue(encoder.flush());
|
||||
encoder.close();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const page = sortedPages[pageIdx]!;
|
||||
pageIdx++;
|
||||
controller.enqueue(encoder.write(page.channelData));
|
||||
},
|
||||
cancel() {
|
||||
encoder.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#resolveCodec(bytes: Uint8Array, hint?: string): AudioCodec {
|
||||
if (hint) {
|
||||
const c = this.#byName.get(hint);
|
||||
if (c) return c;
|
||||
}
|
||||
for (const codec of this.#byName.values()) {
|
||||
if (codec.canDecode(bytes)) return codec;
|
||||
}
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.UNKNOWN_FORMAT,
|
||||
"audio-codecs.decode.unknown-format",
|
||||
{ context: { hint } }
|
||||
);
|
||||
}
|
||||
|
||||
async #instrumented<T>(
|
||||
spanName: string,
|
||||
counter: ReturnType<typeof METER.createCounter>,
|
||||
hist: ReturnType<typeof METER.createHistogram>,
|
||||
run: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
const span = TRACER.startSpan(spanName);
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const result = await run(span);
|
||||
counter.add(1);
|
||||
hist.record(performance.now() - startedAt);
|
||||
return result;
|
||||
} catch (err) {
|
||||
// Inner decode/encode try/catch blocks wrap every non-API error
|
||||
// as AudioCodecsError, which extends SuaveplanError extends Error.
|
||||
// So `err` here is always an Error — narrowing without a branch.
|
||||
const error = err as Error;
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
span.recordException(error);
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
107
packages/media/audio/audio-codecs/src/registry/registry.types.ts
Normal file
107
packages/media/audio/audio-codecs/src/registry/registry.types.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core";
|
||||
|
||||
/**
|
||||
* Decoded PCM bundle produced by a codec's `decode` method. `channelData` is
|
||||
* normalised `Float32Array` per channel; `sampleRate` is positive Hz; `format`
|
||||
* is the codec's `name` so callers can introspect after a registry-level
|
||||
* dispatch.
|
||||
*/
|
||||
export interface DecodeResult {
|
||||
readonly channelData: readonly Float32Array[];
|
||||
readonly sampleRate: number;
|
||||
readonly format: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode options for a codec's `decode` call. Codec-specific fields live in
|
||||
* each codec's own option type — these are the cross-cutting controls only.
|
||||
*/
|
||||
export interface DecodeOpts {
|
||||
readonly format?: string;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode options for a codec's `encode` call. `sampleRate` MUST be positive
|
||||
* when supplied; `bitDepth` is consulted by codecs that support multiple
|
||||
* sample formats (WAV, FLAC, AIFF — the latter on decode only).
|
||||
*/
|
||||
export interface EncodeOpts {
|
||||
readonly sampleRate?: number;
|
||||
readonly bitDepth?: 8 | 16 | 24 | 32;
|
||||
readonly format?: "pcm" | "float";
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming decoder driving a `ReadableStream<AudioPage>` from a chunked
|
||||
* source. Codecs that expose this factory MUST also expose the non-streaming
|
||||
* `decode` method — the streaming factory is additive, not an alternative.
|
||||
*/
|
||||
export interface StreamingDecoder {
|
||||
/** Feed one chunk of compressed bytes. May produce zero or more pages. */
|
||||
write(chunk: Uint8Array): readonly DecodeResult[];
|
||||
/** End-of-stream — return any remaining buffered pages. */
|
||||
flush(): readonly DecodeResult[];
|
||||
/** Best-effort release of internal buffers; idempotent. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming encoder driving a `ReadableStream<Uint8Array>` from a chunked
|
||||
* source of PCM blocks. Same additive-factory contract as `StreamingDecoder`.
|
||||
*/
|
||||
export interface StreamingEncoder {
|
||||
/** Header bytes emitted before any PCM (RIFF, fLaC magic, etc.). */
|
||||
start(sampleRate: number, channels: number): Uint8Array;
|
||||
/** Encode one PCM block — `channelData[ch]` is a `Float32Array`. */
|
||||
write(channelData: readonly Float32Array[]): Uint8Array;
|
||||
/** End-of-stream trailer bytes (size patches, frame-count updates). */
|
||||
flush(): Uint8Array;
|
||||
/** Best-effort release of internal buffers; idempotent. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin contract for a single audio container format. Codec implementations
|
||||
* own format-specific parsing/encoding; the `CodecRegistry` owns dispatch.
|
||||
*/
|
||||
export interface AudioCodec {
|
||||
/** Canonical short identifier — `"wav"`, `"mp3"`, `"flac"`, etc. */
|
||||
readonly name: string;
|
||||
/** File extensions (without leading dot) handled by this codec. */
|
||||
readonly extensions: readonly string[];
|
||||
/** MIME types handled by this codec. */
|
||||
readonly mimeTypes: readonly string[];
|
||||
/** Magic-byte sniff — return true if `bytes` are plausibly this format. */
|
||||
canDecode(bytes: Uint8Array): boolean;
|
||||
/** Full-buffer decode. */
|
||||
decode(bytes: Uint8Array, opts?: DecodeOpts): Promise<DecodeResult>;
|
||||
/**
|
||||
* Full-buffer encode. Codecs that do not support encoding throw
|
||||
* `AudioCodecsError("AUDIO_CODECS.NOT_SUPPORTED")` — they do not return a
|
||||
* non-functional stub.
|
||||
*/
|
||||
encode(
|
||||
channelData: readonly Float32Array[],
|
||||
opts?: EncodeOpts
|
||||
): Promise<Uint8Array>;
|
||||
/** Optional streaming-decoder factory; omitted when the codec is not streamable. */
|
||||
createDecoder?(opts?: DecodeOpts): StreamingDecoder;
|
||||
/** Optional streaming-encoder factory; omitted when the codec is not streamable. */
|
||||
createEncoder?(opts?: EncodeOpts): StreamingEncoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construction options for `CodecRegistry`. `codecs` are registered eagerly;
|
||||
* `clock` lets tests stub time for histogram observations.
|
||||
*/
|
||||
export interface CodecRegistryOptions {
|
||||
readonly codecs?: readonly AudioCodec[];
|
||||
}
|
||||
|
||||
/** Source resolver passes this to registry.decode for AudioDocument hydration. */
|
||||
export type AudioDocumentBuilder = (
|
||||
result: DecodeResult,
|
||||
sampleCount: number
|
||||
) => AudioDocument;
|
||||
100
packages/media/audio/audio-codecs/src/stream/stream-decoder.md
Normal file
100
packages/media/audio/audio-codecs/src/stream/stream-decoder.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# `stream/stream-decoder`
|
||||
|
||||
`TransformStream<Uint8Array, AudioPage>` factory — composes a chunked
|
||||
byte stream into an `AudioPage` stream by driving a codec's
|
||||
`StreamingDecoder`.
|
||||
|
||||
## Purpose
|
||||
|
||||
The `CodecRegistry.decodeStream` method returns a
|
||||
`ReadableStream<AudioPage>`. That works when callers want to read
|
||||
pages directly, but does not compose into `@suaveplan/streams`
|
||||
pipelines because pipelines are built from `TransformStream` nodes,
|
||||
not `ReadableStream` sinks. This module's `createDecodeTransform`
|
||||
fills the gap — it returns a `TransformStream<Uint8Array, AudioPage>`
|
||||
suitable for `pipeThrough()` between a raw byte source and a
|
||||
downstream consumer.
|
||||
|
||||
## Features
|
||||
|
||||
- `createDecodeTransform(format, registry?)` — returns a
|
||||
`TransformStream<Uint8Array, AudioPage>` where each input chunk
|
||||
is fed to the codec's `StreamingDecoder` and each decoded
|
||||
`DecodeResult` is emitted as one `AudioPage` on the output stream.
|
||||
- Lazy codec resolution — the transform's `ensure()` helper picks
|
||||
the codec and constructs its decoder on first chunk, not at
|
||||
transform construction. This lets callers tee the stream or
|
||||
build pipeline branches without paying for codec setup on
|
||||
branches that never see data.
|
||||
- Default registry — when `registry` is omitted, the transform
|
||||
lazily loads `WavCodec`, `AiffCodec`, and `CafCodec` into a
|
||||
shared cached registry. Callers needing WASM codecs must pass
|
||||
their own registry.
|
||||
- Error propagation — codec failures (unknown format,
|
||||
no-streaming-factory, or any thrown error from `write` / `flush`)
|
||||
surface as `controller.error(...)` with an `AudioCodecsError`
|
||||
carrying the original on `cause`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createDecodeTransform } from "@suaveplan/audio-codecs/stream/stream-decoder";
|
||||
import { CodecRegistry, WavCodec } from "@suaveplan/audio-codecs";
|
||||
|
||||
const registry = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const transform = createDecodeTransform("wav", registry);
|
||||
|
||||
const upstream: ReadableStream<Uint8Array> = await fetchAudio();
|
||||
const pages = upstream.pipeThrough(transform);
|
||||
|
||||
for await (const page of pages) {
|
||||
consumePage(page);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Compose with a downstream `@suaveplan/streams` transform:
|
||||
|
||||
```ts
|
||||
import { createDecodeTransform } from "@suaveplan/audio-codecs/stream/stream-decoder";
|
||||
import { makeNormaliser, makeRingBuffer } from "./my-pipeline";
|
||||
|
||||
const decodeTx = createDecodeTransform("flac", myRegistry);
|
||||
|
||||
await fetchAudioStream()
|
||||
.pipeThrough(decodeTx)
|
||||
.pipeThrough(makeNormaliser({ targetLufs: -14 }))
|
||||
.pipeThrough(makeRingBuffer({ capacity: 16 }))
|
||||
.pipeTo(myDestinationSink);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function createDecodeTransform(format: string, registry?: CodecRegistry):
|
||||
TransformStream<Uint8Array, AudioPage>` — produces a transform that
|
||||
emits `AudioPage` per decoded block; throws / errors with
|
||||
`AUDIO_CODECS.NOT_SUPPORTED` for unknown format or
|
||||
`AUDIO_CODECS.STREAM_ERROR` for codecs without `createDecoder()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `transform()` callback awaits `ensure()` then calls
|
||||
`decoder.write(chunk)`, fanning out the resulting `DecodeResult[]`
|
||||
into the output controller. Each `DecodeResult.channelData` array
|
||||
is wrapped in fresh `Float32Array` copies so downstream consumers
|
||||
cannot accidentally mutate the codec's internal buffers.
|
||||
- `startSample` accumulates across pages so the emitted
|
||||
`AudioPage` sequence forms a contiguous timeline.
|
||||
- The `flush()` callback drains the codec's buffered tail via
|
||||
`decoder.flush()`, emits any remaining pages, then closes the
|
||||
decoder. Errors during flush are translated into
|
||||
`AUDIO_CODECS.STREAM_ERROR` via `controller.error()`.
|
||||
- Codec-thrown `AudioCodecsError` instances pass through unchanged;
|
||||
non-API errors are wrapped with `STREAM_ERROR`.
|
||||
- The lazy registry initialisation (`getDefaultRegistry`) currently
|
||||
registers only the three native codecs (WAV/AIFF/CAF). Callers
|
||||
needing MP3/FLAC/Opus/etc must build their own registry via
|
||||
`new CodecRegistry({ codecs: [...] })` and pass it explicitly.
|
||||
This choice keeps the default-registry path tree-shakeable when
|
||||
callers don't use streaming.
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AiffCodec } from "../codecs/aiff.js";
|
||||
import { WavCodec } from "../codecs/wav.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "../registry/registry.js";
|
||||
import { createDecodeTransform } from "./stream-decoder.js";
|
||||
|
||||
async function pumpThrough(
|
||||
bytes: Uint8Array,
|
||||
transform: TransformStream<Uint8Array, unknown>,
|
||||
chunkSize = 256
|
||||
): Promise<unknown[]> {
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let off = 0; off < bytes.byteLength; off += chunkSize) {
|
||||
controller.enqueue(bytes.subarray(off, off + chunkSize));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
const collected: unknown[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) collected.push(value);
|
||||
}
|
||||
return collected;
|
||||
}
|
||||
|
||||
describe("createDecodeTransform", () => {
|
||||
it("composes a chunked WAV byte stream into AudioPages", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const wav = await WavCodec.encode([new Float32Array(2048)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const pages = await pumpThrough(wav, createDecodeTransform("wav", r));
|
||||
expect(pages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("errors with NOT_SUPPORTED for unknown format", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await pumpThrough(
|
||||
new Uint8Array(64),
|
||||
createDecodeTransform("zzz", r)
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
expect((caught as AudioCodecsError).code).toBe(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED
|
||||
);
|
||||
});
|
||||
|
||||
it("errors with STREAM_ERROR for codec with no decoder factory", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [AiffCodec] });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await pumpThrough(
|
||||
new Uint8Array(64),
|
||||
createDecodeTransform("aiff", r)
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
expect((caught as AudioCodecsError).code).toBe(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR
|
||||
);
|
||||
});
|
||||
|
||||
it("flush emits remaining buffered pages", { timeout: 30000 }, async () => {
|
||||
const wav = await WavCodec.encode([new Float32Array(512)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const pages = await pumpThrough(
|
||||
wav,
|
||||
createDecodeTransform("wav", r),
|
||||
64
|
||||
);
|
||||
expect(pages.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
112
packages/media/audio/audio-codecs/src/stream/stream-decoder.ts
Normal file
112
packages/media/audio/audio-codecs/src/stream/stream-decoder.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { AudioPage } from "@suaveplan/audio-core";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "../registry/registry.js";
|
||||
|
||||
let cachedRegistry: CodecRegistry | undefined;
|
||||
async function getDefaultRegistry(): Promise<CodecRegistry> {
|
||||
if (cachedRegistry) return cachedRegistry;
|
||||
const { decodeSource } = await import("../decode/decode-source.js");
|
||||
void decodeSource;
|
||||
const [{ WavCodec }, { AiffCodec }, { CafCodec }] = await Promise.all([
|
||||
import("../codecs/wav.js"),
|
||||
import("../codecs/aiff.js"),
|
||||
import("../codecs/caf.js"),
|
||||
]);
|
||||
cachedRegistry = new CodecRegistry({
|
||||
codecs: [WavCodec, AiffCodec, CafCodec],
|
||||
});
|
||||
return cachedRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a chunked `Uint8Array` byte stream into an `AudioPage` stream by
|
||||
* driving the registered codec's `StreamingDecoder`. Faults inside the
|
||||
* codec become `AUDIO_CODECS.STREAM_ERROR` on the output stream.
|
||||
*/
|
||||
export function createDecodeTransform(
|
||||
format: string,
|
||||
registry?: CodecRegistry
|
||||
): TransformStream<Uint8Array, AudioPage> {
|
||||
let resolved: CodecRegistry | undefined = registry;
|
||||
let decoder:
|
||||
| ReturnType<
|
||||
NonNullable<
|
||||
import("../registry/registry.types.js").AudioCodec["createDecoder"]
|
||||
>
|
||||
>
|
||||
| undefined;
|
||||
let startSample = 0;
|
||||
|
||||
async function ensure(): Promise<void> {
|
||||
if (!resolved) resolved = await getDefaultRegistry();
|
||||
if (decoder) return;
|
||||
const codec = resolved.get(format);
|
||||
if (!codec)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.stream.unknown-format",
|
||||
{ context: { format } }
|
||||
);
|
||||
if (!codec.createDecoder)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.stream.no-decoder-factory",
|
||||
{ context: { format } }
|
||||
);
|
||||
decoder = codec.createDecoder();
|
||||
}
|
||||
|
||||
return new TransformStream<Uint8Array, AudioPage>({
|
||||
async transform(chunk, controller) {
|
||||
try {
|
||||
await ensure();
|
||||
const results = decoder!.write(chunk);
|
||||
for (const r of results) {
|
||||
const channelData = r.channelData.map(
|
||||
(c) => new Float32Array(c)
|
||||
);
|
||||
controller.enqueue({ channelData, startSample });
|
||||
startSample += channelData[0]?.length ?? 0;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AudioCodecsError) controller.error(err);
|
||||
else
|
||||
controller.error(
|
||||
new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.stream.decode-failed",
|
||||
{ cause: err }
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
async flush(controller) {
|
||||
try {
|
||||
await ensure();
|
||||
const tail = decoder!.flush();
|
||||
for (const r of tail) {
|
||||
const channelData = r.channelData.map(
|
||||
(c) => new Float32Array(c)
|
||||
);
|
||||
controller.enqueue({ channelData, startSample });
|
||||
startSample += channelData[0]?.length ?? 0;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AudioCodecsError) controller.error(err);
|
||||
else
|
||||
controller.error(
|
||||
new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.stream.flush-failed",
|
||||
{ cause: err }
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
decoder?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
110
packages/media/audio/audio-codecs/src/stream/stream-encoder.md
Normal file
110
packages/media/audio/audio-codecs/src/stream/stream-encoder.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# `stream/stream-encoder`
|
||||
|
||||
`TransformStream<readonly Float32Array[], Uint8Array>` factory —
|
||||
composes a PCM-block stream into encoded bytes by driving a codec's
|
||||
`StreamingEncoder`.
|
||||
|
||||
## Purpose
|
||||
|
||||
The mirror of `stream-decoder` for the encode direction. Callers
|
||||
building a `@suaveplan/streams` pipeline (decode → DSP → encode)
|
||||
need a `TransformStream` node for the encode stage; this module
|
||||
provides it.
|
||||
|
||||
## Features
|
||||
|
||||
- `createEncodeTransform(format, sampleRate, channels, registry?)` —
|
||||
returns a `TransformStream<readonly Float32Array[], Uint8Array>`
|
||||
where each input `Float32Array[]` chunk represents one block of
|
||||
PCM (one `Float32Array` per channel, same length per channel) and
|
||||
each output `Uint8Array` carries encoded bytes from the codec's
|
||||
`StreamingEncoder`.
|
||||
- Lazy codec resolution — the transform constructs the codec's
|
||||
encoder on first chunk (or on flush if no chunks arrived). The
|
||||
encoder's `start()` is called once to emit header bytes, then
|
||||
`write()` for every chunk, then `flush()` to emit any trailer.
|
||||
- Sample rate + channel count are captured at construction so the
|
||||
encoder's `start()` can pre-compute its header without waiting
|
||||
for the first chunk.
|
||||
- Empty-source handling — even when no PCM chunks arrive, `flush()`
|
||||
still calls `start()` once and emits the codec's trailer (the
|
||||
44-byte WAV header for the WAV codec). Downstream consumers
|
||||
always receive a structurally-valid file.
|
||||
- Default registry — when `registry` is omitted, the transform
|
||||
lazily loads `WavCodec` into a shared cached registry. Codecs
|
||||
beyond WAV that support encode (FLAC, MP3, Opus, QOA) must be
|
||||
passed explicitly via the `registry` parameter.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createEncodeTransform } from "@suaveplan/audio-codecs/stream/stream-encoder";
|
||||
import { CodecRegistry, WavCodec } from "@suaveplan/audio-codecs";
|
||||
|
||||
const registry = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const transform = createEncodeTransform("wav", 44_100, 2, registry);
|
||||
|
||||
const pcmStream = makePcmBlockStream();
|
||||
const wavBytes = pcmStream.pipeThrough(transform);
|
||||
|
||||
await wavBytes.pipeTo(myFileSink);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Decode-then-encode pipeline (transcode FLAC → WAV via streaming):
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDecodeTransform,
|
||||
createEncodeTransform,
|
||||
} from "@suaveplan/audio-codecs/stream/stream-encoder";
|
||||
|
||||
const decodeTx = createDecodeTransform("flac", registry);
|
||||
const encodeTx = createEncodeTransform("wav", 44_100, 2, registry);
|
||||
|
||||
// The mid pipeline converts AudioPage into Float32Array[] form.
|
||||
const pageToBlock = new TransformStream<AudioPage, readonly Float32Array[]>({
|
||||
transform(page, controller) {
|
||||
controller.enqueue(page.channelData);
|
||||
},
|
||||
});
|
||||
|
||||
await fetchAudio()
|
||||
.pipeThrough(decodeTx)
|
||||
.pipeThrough(pageToBlock)
|
||||
.pipeThrough(encodeTx)
|
||||
.pipeTo(myDestination);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function createEncodeTransform(format: string, sampleRate: number,
|
||||
channels: number, registry?: CodecRegistry):
|
||||
TransformStream<readonly Float32Array[], Uint8Array>` — produces
|
||||
a transform that emits encoded byte chunks; errors with
|
||||
`AUDIO_CODECS.NOT_SUPPORTED` for unknown formats or
|
||||
`AUDIO_CODECS.STREAM_ERROR` for codecs without `createEncoder()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `started` flag ensures the encoder's `start(sampleRate,
|
||||
channels)` is invoked exactly once even when no chunks arrive
|
||||
(the `flush()` path also checks the flag and calls `start()`
|
||||
if needed before invoking `flush()`).
|
||||
- WAV's `start()` returns an empty `Uint8Array` because the WAV
|
||||
header includes the total data-chunk size which is only known at
|
||||
flush time; the transform handles that by emitting zero-byte
|
||||
chunks (skipped via the `byteLength > 0` check) so downstream
|
||||
consumers don't see empty data.
|
||||
- Codec-thrown `AudioCodecsError` instances pass through unchanged;
|
||||
non-API errors from `write` or `flush` are wrapped with
|
||||
`STREAM_ERROR`.
|
||||
- The default registry registers only `WavCodec`. Encoding via
|
||||
FLAC, MP3, Opus, or QOA requires the caller to construct a
|
||||
registry with those codecs and pass it as the fourth argument.
|
||||
This choice keeps the default-registry path tree-shakeable when
|
||||
callers don't use streaming.
|
||||
- `cancel()` on the underlying transform stream calls
|
||||
`encoder.close()` to release any internal buffers — even
|
||||
half-streamed encodes leak no resources on cancellation.
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AiffCodec } from "../codecs/aiff.js";
|
||||
import { WavCodec } from "../codecs/wav.js";
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "../registry/registry.js";
|
||||
import { createEncodeTransform } from "./stream-encoder.js";
|
||||
|
||||
async function pumpThrough<I, O>(
|
||||
input: I[],
|
||||
transform: TransformStream<I, O>
|
||||
): Promise<O[]> {
|
||||
const upstream = new ReadableStream<I>({
|
||||
start(controller) {
|
||||
for (const chunk of input) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = upstream.pipeThrough(transform);
|
||||
const reader = out.getReader();
|
||||
const collected: O[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) collected.push(value);
|
||||
}
|
||||
return collected;
|
||||
}
|
||||
|
||||
describe("createEncodeTransform", () => {
|
||||
it("composes PCM blocks into encoded WAV bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const block: readonly Float32Array[] = [new Float32Array(1024)];
|
||||
const transform = createEncodeTransform("wav", 44_100, 1, r);
|
||||
const chunks = await pumpThrough([block, block, block], transform);
|
||||
let total = 0;
|
||||
for (const c of chunks) total += c.byteLength;
|
||||
expect(total).toBeGreaterThan(44);
|
||||
});
|
||||
|
||||
it("errors with NOT_SUPPORTED for unknown format", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const transform = createEncodeTransform("zzz", 44_100, 1, r);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await pumpThrough([[new Float32Array(8)]], transform);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
expect((caught as AudioCodecsError).code).toBe(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED
|
||||
);
|
||||
});
|
||||
|
||||
it("errors with STREAM_ERROR for codec lacking createEncoder", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [AiffCodec] });
|
||||
const transform = createEncodeTransform("aiff", 44_100, 1, r);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await pumpThrough([[new Float32Array(8)]], transform);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudioCodecsError);
|
||||
expect((caught as AudioCodecsError).code).toBe(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR
|
||||
);
|
||||
});
|
||||
|
||||
it("flush emits trailer even when no body chunks were enqueued", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r = new CodecRegistry({ codecs: [WavCodec] });
|
||||
const transform = createEncodeTransform("wav", 8000, 1, r);
|
||||
const chunks = await pumpThrough([], transform);
|
||||
let total = 0;
|
||||
for (const c of chunks) total += c.byteLength;
|
||||
expect(total).toBe(44);
|
||||
});
|
||||
});
|
||||
105
packages/media/audio/audio-codecs/src/stream/stream-encoder.ts
Normal file
105
packages/media/audio/audio-codecs/src/stream/stream-encoder.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
AUDIO_CODECS_CODES,
|
||||
AudioCodecsError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CodecRegistry } from "../registry/registry.js";
|
||||
|
||||
let cachedRegistry: CodecRegistry | undefined;
|
||||
async function getDefaultRegistry(): Promise<CodecRegistry> {
|
||||
if (cachedRegistry) return cachedRegistry;
|
||||
const [{ WavCodec }] = await Promise.all([import("../codecs/wav.js")]);
|
||||
cachedRegistry = new CodecRegistry({ codecs: [WavCodec] });
|
||||
return cachedRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose an `AudioPage`-like PCM stream (`Float32Array[]` per chunk) into
|
||||
* encoded bytes by driving the registered codec's `StreamingEncoder`.
|
||||
*
|
||||
* The first chunk MUST be the sample rate / channel-count handshake passed
|
||||
* via a synthetic page with empty `channelData`; downstream pages carry the
|
||||
* actual PCM blocks.
|
||||
*/
|
||||
export function createEncodeTransform(
|
||||
format: string,
|
||||
sampleRate: number,
|
||||
channels: number,
|
||||
registry?: CodecRegistry
|
||||
): TransformStream<readonly Float32Array[], Uint8Array> {
|
||||
let resolved: CodecRegistry | undefined = registry;
|
||||
let encoder:
|
||||
| ReturnType<
|
||||
NonNullable<
|
||||
import("../registry/registry.types.js").AudioCodec["createEncoder"]
|
||||
>
|
||||
>
|
||||
| undefined;
|
||||
let started = false;
|
||||
|
||||
async function ensure(): Promise<void> {
|
||||
if (!resolved) resolved = await getDefaultRegistry();
|
||||
if (encoder) return;
|
||||
const codec = resolved.get(format);
|
||||
if (!codec)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.NOT_SUPPORTED,
|
||||
"audio-codecs.stream.unknown-format",
|
||||
{ context: { format } }
|
||||
);
|
||||
if (!codec.createEncoder)
|
||||
throw new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.stream.no-encoder-factory",
|
||||
{ context: { format } }
|
||||
);
|
||||
encoder = codec.createEncoder();
|
||||
}
|
||||
|
||||
return new TransformStream<readonly Float32Array[], Uint8Array>({
|
||||
async transform(chunk, controller) {
|
||||
try {
|
||||
await ensure();
|
||||
if (!started) {
|
||||
const header = encoder!.start(sampleRate, channels);
|
||||
if (header.byteLength > 0) controller.enqueue(header);
|
||||
started = true;
|
||||
}
|
||||
const body = encoder!.write(chunk);
|
||||
if (body.byteLength > 0) controller.enqueue(body);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioCodecsError) controller.error(err);
|
||||
else
|
||||
controller.error(
|
||||
new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.stream.encode-failed",
|
||||
{ cause: err }
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
async flush(controller) {
|
||||
try {
|
||||
await ensure();
|
||||
if (!started) {
|
||||
encoder!.start(sampleRate, channels);
|
||||
started = true;
|
||||
}
|
||||
const tail = encoder!.flush();
|
||||
if (tail.byteLength > 0) controller.enqueue(tail);
|
||||
} catch (err) {
|
||||
if (err instanceof AudioCodecsError) controller.error(err);
|
||||
else
|
||||
controller.error(
|
||||
new AudioCodecsError(
|
||||
AUDIO_CODECS_CODES.STREAM_ERROR,
|
||||
"audio-codecs.stream.flush-failed",
|
||||
{ cause: err }
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
encoder?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
12
packages/media/audio/audio-codecs/tsconfig.lib.json
Normal file
12
packages/media/audio/audio-codecs/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
41
packages/media/audio/audio-codecs/vite.config.ts
Normal file
41
packages/media/audio/audio-codecs/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audio-codecs",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/audio-core",
|
||||
"@suaveplan/audio-metadata",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/streams",
|
||||
"@suaveplan/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
/^@audio\//,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
27
packages/media/audio/audio-codecs/vitest.config.ts
Normal file
27
packages/media/audio/audio-codecs/vitest.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/__benchmarks__/**",
|
||||
"src/**/constants/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user