Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# @suaveplan/audio-codec-ffmpeg-wasm - Browser-tier ffmpeg.wasm AudioCodec plugin for @suaveplan/audio-codecs. Lazy-loaded @ffmpeg/core wasm inside a Web Worker; decode/encode for WAV/MP3/OGG/Opus/FLAC/AAC/WebM; AbortSignal, timeout watchdog, COI-gated multi-thread, OTel spans+counters+histograms, structured AUDIO_CODEC_FFMPEG_WASM.* error codes.
|
||||
|
||||
Browser-tier `AudioCodec` plugin for
|
||||
[`@suaveplan/audio-codecs`](../audio-codecs) backed by
|
||||
[`@ffmpeg/ffmpeg`](https://github.com/ffmpegwasm/ffmpeg.wasm) running inside
|
||||
a dedicated Web Worker. Decodes and encodes `wav`, `mp3`, `ogg`, `opus`,
|
||||
`flac`, `aac`, and `webm`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [codec](./src/codec/ffmpeg-wasm-codec.md) — This module assembles the public `FfmpegWasmCodec` class — the browser-tier `AudioCodec` plugin for `@suaveplan/audio-c…
|
||||
- [ffmpeg-wasm-codec](./src/codec/ffmpeg-wasm-codec.md) — This module assembles the public `FfmpegWasmCodec` class — the browser-tier `AudioCodec` plugin for `@suaveplan/audio-c…
|
||||
- [core-loader](./src/core-loader/core-loader.md) — This module owns the lazy resolution of the ffmpeg.wasm core module. It picks the single-thread `@ffmpeg/core` or the m…
|
||||
- [core-loader](./src/core-loader/core-loader.md) — This module owns the lazy resolution of the ffmpeg.wasm core module. It picks the single-thread `@ffmpeg/core` or the m…
|
||||
- [decode](./src/decode/decode-pipeline.md) — This module owns the pure logic of the decode pipeline: format sniffing from magic bytes, the ffmpeg argument list that…
|
||||
- [decode-pipeline](./src/decode/decode-pipeline.md) — This module owns the pure logic of the decode pipeline: format sniffing from magic bytes, the ffmpeg argument list that…
|
||||
- [encode](./src/encode/encode-pipeline.md) — This module owns the pure logic of the encode pipeline: validating the requested format against the supported set *befo…
|
||||
- [encode-pipeline](./src/encode/encode-pipeline.md) — This module owns the pure logic of the encode pipeline: validating the requested format against the supported set *befo…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module declares the canonical error-code registry for `@suaveplan/audio-codec-ffmpeg-wasm`. It exposes the nine `A…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module declares the canonical error-code registry for `@suaveplan/audio-codec-ffmpeg-wasm`. It exposes the nine `A…
|
||||
- [register](./src/register/register.md) — This module exposes the `register(registry, options?)` install helper — a single function that callers use to plug `Ffm…
|
||||
- [register](./src/register/register.md) — This module exposes the `register(registry, options?)` install helper — a single function that callers use to plug `Ffm…
|
||||
- [streaming-decoder](./src/streaming/streaming-decoder.md) — This module implements the `StreamingDecoder` contract from `@suaveplan/audio-codecs` over ffmpeg.wasm. The implementat…
|
||||
- [streaming-encoder](./src/streaming/streaming-encoder.md) — This module implements the `StreamingEncoder` contract from `@suaveplan/audio-codecs` over ffmpeg.wasm. The implementat…
|
||||
- [worker](./src/worker/worker-host.md) — This module owns the lifecycle of the dedicated Web Worker that runs the ffmpeg.wasm core. It serialises every codec ca…
|
||||
- [worker-host](./src/worker/worker-host.md) — This module owns the lifecycle of the dedicated Web Worker that runs the ffmpeg.wasm core. It serialises every codec ca…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/audio-codec-ffmpeg-wasm @suaveplan/audio-codecs
|
||||
# The wasm core packages are optional dependencies — install whichever you
|
||||
# plan to serve:
|
||||
bun add @ffmpeg/ffmpeg @ffmpeg/core
|
||||
# Optional: multi-thread core (~30 MB) for cross-origin-isolated pages
|
||||
bun add @ffmpeg/core-mt
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs";
|
||||
import { register } from "@suaveplan/audio-codec-ffmpeg-wasm/register/register";
|
||||
|
||||
const registry = new CodecRegistry();
|
||||
const codec = register(registry, {
|
||||
corePath: "/static/ffmpeg-core.js",
|
||||
});
|
||||
|
||||
const doc = await registry.decode(mp3Bytes);
|
||||
const opus = await registry.encode(doc, "opus");
|
||||
await codec.dispose();
|
||||
```
|
||||
|
||||
## The 30 MB lazy-load disclaimer
|
||||
|
||||
The `@ffmpeg/core` wasm payload is roughly 30 MB. It is **not** loaded
|
||||
when this package is imported, nor when `new FfmpegWasmCodec()` is
|
||||
constructed — it is loaded on the first call to `decode`, `encode`,
|
||||
`createDecoder`, or `createEncoder`. Subsequent calls reuse the loaded
|
||||
core. `codec.dispose()` tears the worker down and releases the core.
|
||||
|
||||
## Multi-thread (cross-origin isolation)
|
||||
|
||||
Pass `enableMultithread: true` to opt into the `@ffmpeg/core-mt` build.
|
||||
This requires the page to be cross-origin-isolated:
|
||||
|
||||
- Serve the page with
|
||||
`Cross-Origin-Opener-Policy: same-origin` and
|
||||
`Cross-Origin-Embedder-Policy: require-corp`.
|
||||
- `globalThis.crossOriginIsolated` must be `true` at runtime.
|
||||
|
||||
If `enableMultithread` is `true` without COI, the codec throws
|
||||
`AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` on first use
|
||||
**before** attempting the dynamic `import()`. If COI is true but the
|
||||
optional `@ffmpeg/core-mt` package is missing, the codec logs a warning
|
||||
and silently falls back to the single-thread core.
|
||||
|
||||
## Streaming honesty note
|
||||
|
||||
The `StreamingDecoder` / `StreamingEncoder` factories from this codec
|
||||
are **buffered emulation** — ffmpeg.wasm is whole-file in / whole-file
|
||||
out, so `write(chunk)` only appends to an internal buffer and never
|
||||
invokes the worker; the actual decode/encode happens once when
|
||||
`flushAsync()` is awaited. Use this for chunked-source UX (progress
|
||||
bars, abortable downloads), not for true low-latency streaming.
|
||||
|
||||
## Public API
|
||||
|
||||
- `class FfmpegWasmCodec implements AudioCodec`
|
||||
- `class FfmpegWasmStreamingDecoder implements StreamingDecoder`
|
||||
- `class FfmpegWasmStreamingEncoder implements StreamingEncoder`
|
||||
- `class WorkerHost` — worker lifecycle abstraction
|
||||
- `loadCore(opts)` — lazy core loader
|
||||
- `register(registry, options?)` — one-line install helper
|
||||
- `AUDIO_CODEC_FFMPEG_WASM_CODES` + `AudioCodecFfmpegWasmError`
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | Thrown when |
|
||||
|---|---|
|
||||
| `AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED` | wasm core fetch / import failed |
|
||||
| `AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED` | Worker construction or pre-load error |
|
||||
| `AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED` | ffmpeg non-zero exit on decode |
|
||||
| `AUDIO_CODEC_FFMPEG_WASM.ENCODE_FAILED` | ffmpeg non-zero exit on encode |
|
||||
| `AUDIO_CODEC_FFMPEG_WASM.TIMEOUT` | Per-call watchdog fired |
|
||||
| `AUDIO_CODEC_FFMPEG_WASM.ABORTED` | `AbortSignal` propagated |
|
||||
| `AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC` | Format outside the supported set, or Zod boundary failure |
|
||||
| `AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED` | wasm 4 GB linear memory cap hit |
|
||||
| `AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` | `enableMultithread` without COI |
|
||||
|
||||
## Test seams
|
||||
|
||||
For testing without a real worker, supply:
|
||||
|
||||
- `workerFactory: () => Worker` — returns a controllable shim.
|
||||
- `coreLoader: () => Promise<unknown>` — overrides the dynamic
|
||||
`import("@ffmpeg/core")`.
|
||||
- `coreMtLoader: () => Promise<unknown>` — overrides the dynamic
|
||||
`import("@ffmpeg/core-mt")`.
|
||||
|
||||
These are dependency-injection seams, not mocks; production callers
|
||||
omit them and the real `new Worker(coreUrl)` / dynamic `import()` path
|
||||
runs unchanged.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# `ffmpeg-wasm-codec.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module assembles the public `FfmpegWasmCodec` class — the browser-tier
|
||||
`AudioCodec` plugin for `@suaveplan/audio-codecs`. The class wires Zod
|
||||
boundary validation, the lazy core loader, the worker host, the decode /
|
||||
encode pipelines, the streaming decoder / encoder factories, and the
|
||||
OpenTelemetry instrumentation into a single object that satisfies the
|
||||
`AudioCodec` contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Constructor validates options against `FfmpegWasmCodecOptionsSchema`;
|
||||
malformed options throw `UNSUPPORTED_CODEC` with the Zod issue list.
|
||||
- `canDecode` sniffs magic bytes against the supported format set.
|
||||
- `decode` / `encode` lazy-load the wasm core and the worker, run the
|
||||
pipeline, emit spans, increment counters, record histograms.
|
||||
- `createDecoder` / `createEncoder` return the streaming wrappers
|
||||
defined in `streaming/`.
|
||||
- `dispose()` terminates the worker and releases the loaded core.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FfmpegWasmCodec } from "@suaveplan/audio-codec-ffmpeg-wasm/codec/ffmpeg-wasm-codec";
|
||||
|
||||
const codec = new FfmpegWasmCodec({ corePath: "/static/ffmpeg-core.js" });
|
||||
const result = await codec.decode(wavBytes);
|
||||
console.log(result.channelData.length);
|
||||
await codec.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FfmpegWasmCodec } from "@suaveplan/audio-codec-ffmpeg-wasm/codec/ffmpeg-wasm-codec";
|
||||
|
||||
const codec = new FfmpegWasmCodec({
|
||||
corePath: "/static/ffmpeg-core-mt.js",
|
||||
enableMultithread: true,
|
||||
progressCallback: (p) => console.log("progress", p),
|
||||
logger: myLogger,
|
||||
clock: () => performance.now(),
|
||||
workerFactory: () => new Worker(coreUrl, { type: "module" }),
|
||||
});
|
||||
try {
|
||||
const result = await codec.decode(buffer, {
|
||||
signal: controller.signal,
|
||||
format: "mp3",
|
||||
});
|
||||
const opus = await codec.encode(result.channelData, {
|
||||
format: "opus",
|
||||
sampleRate: result.sampleRate,
|
||||
bitrate: 96_000,
|
||||
});
|
||||
} finally {
|
||||
await codec.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FfmpegWasmCodec implements AudioCodec`.
|
||||
- `new FfmpegWasmCodec(options?: FfmpegWasmCodecOptions)`.
|
||||
- `codec.name`, `codec.extensions`, `codec.mimeTypes`.
|
||||
- `codec.canDecode(bytes)`.
|
||||
- `codec.decode(bytes, opts?): Promise<DecodeResult>`.
|
||||
- `codec.encode(channelData, opts?): Promise<Uint8Array>`.
|
||||
- `codec.createDecoder(opts?): FfmpegWasmStreamingDecoder`.
|
||||
- `codec.createEncoder(opts?): FfmpegWasmStreamingEncoder`.
|
||||
- `codec.dispose(): Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class holds at most one `WorkerHost` and one `LoadedCore` at a time.
|
||||
`dispose()` is idempotent — calling it twice is a no-op the second time.
|
||||
The OTel spans, counters, and histograms use namespaced names matching
|
||||
the spec (`audio-codec-ffmpeg-wasm.decode`, etc.). Every `decode` and
|
||||
`encode` increments the counter exactly once with an `outcome` label
|
||||
(`ok`, `core-load-failed`, or `error`) so callers can compute per-format
|
||||
failure rates without scraping span events. The progress callback is
|
||||
wired through `WorkerHost.onProgress`, which catches and logs user
|
||||
callback exceptions so a buggy callback cannot abort an in-flight call.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `core-loader.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the lazy resolution of the ffmpeg.wasm core module. It picks
|
||||
the single-thread `@ffmpeg/core` or the multi-thread `@ffmpeg/core-mt` based
|
||||
on the caller's `enableMultithread` flag and the page's
|
||||
`globalThis.crossOriginIsolated` value, then memoises the result so a second
|
||||
codec call reuses the already-loaded core. All static analysis of the source
|
||||
must show that `@ffmpeg/core-mt` is reached only inside a dynamic `import()`
|
||||
expression — never as a static import — so consumers without the optional
|
||||
dependency installed pay zero install cost.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy: nothing happens until `loadCore` is called.
|
||||
- URL precedence: `corePath` > `coreUrl` > bundled `DEFAULT_CORE_URL`.
|
||||
- COI gate: `enableMultithread === true` with COI false throws
|
||||
`CROSS_ORIGIN_ISOLATION_REQUIRED` before the mt dynamic import is reached.
|
||||
- Optional-dep fallback: when COI is true but `import("@ffmpeg/core-mt")`
|
||||
rejects, the loader logs a warning via `opts.logger` and loads the
|
||||
single-thread core instead.
|
||||
- Failure surface: any single-thread load failure throws
|
||||
`CORE_LOAD_FAILED` with the resolved URL in `context.coreUrl`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { loadCore } from "@suaveplan/audio-codec-ffmpeg-wasm/core-loader/core-loader";
|
||||
|
||||
const core = await loadCore({ corePath: "/static/ffmpeg-core.js" });
|
||||
console.log(core.multithread); // false
|
||||
console.log(core.resolvedUrl); // "/static/ffmpeg-core.js"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { loadCore } from "@suaveplan/audio-codec-ffmpeg-wasm/core-loader/core-loader";
|
||||
|
||||
// Multi-thread with explicit COI override (testing or controlled embed)
|
||||
const core = await loadCore({
|
||||
corePath: "/static/ffmpeg-core-mt.js",
|
||||
enableMultithread: true,
|
||||
crossOriginIsolated: true,
|
||||
logger: myLogger,
|
||||
coreMtLoader: () => import("@ffmpeg/core-mt"),
|
||||
});
|
||||
```
|
||||
|
||||
The `coreLoader` / `coreMtLoader` options are dependency-injection seams.
|
||||
Tests use them to assert lazy load semantics, exercise the COI gate, and
|
||||
hit the optional-dep-missing fallback without altering the import graph
|
||||
at static-analysis time. Production callers should leave both unset so
|
||||
the dynamic `import()` is the only path that ever names the ffmpeg
|
||||
modules.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DEFAULT_CORE_URL: string` — the bundled CDN fallback.
|
||||
- `loadCore(opts: CoreLoaderOptions): Promise<LoadedCore>`.
|
||||
- `interface CoreLoaderOptions` — `coreUrl`, `corePath`, `enableMultithread`,
|
||||
`logger`, `coreLoader`, `coreMtLoader`, `crossOriginIsolated`.
|
||||
- `interface LoadedCore` — `multithread`, `resolvedUrl`, `module`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The COI check intentionally happens *before* the dynamic `import()` so
|
||||
that callers without the `@ffmpeg/core-mt` optional dep installed never
|
||||
incur a module-not-found warning when the COI gate is the real failure.
|
||||
The fallback path runs only when COI is true *and* the mt import rejects
|
||||
— if COI is true and mt loads successfully, no fallback occurs. The
|
||||
`crossOriginIsolated` override exists so tests can drive both branches
|
||||
deterministically without manipulating the global.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `decode-pipeline.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the pure logic of the decode pipeline: format sniffing
|
||||
from magic bytes, the ffmpeg argument list that emits `f32le` interleaved
|
||||
samples to a MEMFS scratch file, and the deinterleave step that produces
|
||||
per-channel `Float32Array`s for the `DecodeResult` shape. The pipeline
|
||||
runs entirely through the supplied `WorkerHost` so cancellation,
|
||||
timeouts, and OOM detection are inherited from a single source of
|
||||
truth.
|
||||
|
||||
## Features
|
||||
|
||||
- `sniffFormat(bytes)` recognises WAV, MP3 (ID3v2 + frame-sync), Ogg
|
||||
(Vorbis vs Opus by inspecting the bos packet), FLAC, AAC ADTS, and
|
||||
WebM/EBML headers.
|
||||
- `deinterleave(samples, channels)` returns `channels` equal-length
|
||||
`Float32Array`s.
|
||||
- `runDecode(opts)` orchestrates writeFile → run → readFile via
|
||||
`WorkerHost.submitFfmpeg` and returns a normalised `DecodeResult`.
|
||||
- Default sample rate (48 000) and channel count (2) for callers that
|
||||
do not pre-probe their input.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runDecode } from "@suaveplan/audio-codec-ffmpeg-wasm/decode/decode-pipeline";
|
||||
|
||||
const result = await runDecode({ host, bytes, format: "wav" });
|
||||
console.log(result.channelData.length); // channel count
|
||||
console.log(result.channelData[0].length); // frame count
|
||||
console.log(result.sampleRate);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
runDecode,
|
||||
sniffFormat,
|
||||
deinterleave,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-wasm/decode/decode-pipeline";
|
||||
|
||||
const fmt = sniffFormat(bytes);
|
||||
if (!fmt) throw new Error("unsupported");
|
||||
const result = await runDecode({
|
||||
host,
|
||||
bytes,
|
||||
format: fmt as "wav" | "mp3" | "ogg" | "opus" | "flac" | "aac" | "webm",
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
timeoutMs: 30_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Drop a deinterleaved view for downstream processing
|
||||
const ch = deinterleave(new Float32Array([0, 1, 2, 3]), 2);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DEFAULT_SAMPLE_RATE: 48_000`.
|
||||
- `DEFAULT_CHANNELS: 2`.
|
||||
- `sniffFormat(bytes: Uint8Array): string | undefined`.
|
||||
- `deinterleave(interleaved: Float32Array, channels: number): Float32Array[]`.
|
||||
- `runDecode(opts: DecodePipelineOptions): Promise<DecodeResult>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The deinterleave step copies the raw `Uint8Array` through a `Float32Array`
|
||||
view to honour the f32le wire layout and then takes a `.slice()` so the
|
||||
returned arrays own their own backing buffer — callers can transfer or
|
||||
mutate them without aliasing the worker output. The pipeline never
|
||||
silently catches MEMFS cleanup failures here; the host's `submitFfmpeg`
|
||||
handles try/finally cleanup and logs any delete failures via the
|
||||
injected logger. A non-zero ffmpeg exit is mapped to
|
||||
`AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED` inside the worker host so this
|
||||
module never sees it directly.
|
||||
@@ -0,0 +1,84 @@
|
||||
# `encode-pipeline.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the pure logic of the encode pipeline: validating the
|
||||
requested format against the supported set *before* the worker is touched,
|
||||
interleaving per-channel PCM into a single `Float32Array`, projecting
|
||||
that into ffmpeg's `f32le` wire format, building the format-specific
|
||||
ffmpeg command for each of `wav` / `mp3` / `ogg` / `opus` / `flac` /
|
||||
`aac` / `webm`, and dispatching the call through the supplied
|
||||
`WorkerHost`. All MEMFS lifecycle is owned by the host.
|
||||
|
||||
## Features
|
||||
|
||||
- `interleave` and `float32ToBytes` produce the exact wire layout ffmpeg
|
||||
expects for `-f f32le`.
|
||||
- `buildCommand` returns the ffmpeg argument list per supported format,
|
||||
honouring caller-supplied `bitrate` (`mp3`, `opus`, `aac`, `webm`) and
|
||||
`quality` (`ogg`).
|
||||
- `assertSupported` throws `UNSUPPORTED_CODEC` synchronously, before any
|
||||
worker message is posted — required by the spec.
|
||||
- `runEncode` drives the full pipeline through `WorkerHost.submitFfmpeg`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runEncode } from "@suaveplan/audio-codec-ffmpeg-wasm/encode/encode-pipeline";
|
||||
|
||||
const bytes = await runEncode({
|
||||
host,
|
||||
channelData: [leftFloats, rightFloats],
|
||||
format: "mp3",
|
||||
sampleRate: 44_100,
|
||||
bitrate: 256_000,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
runEncode,
|
||||
interleave,
|
||||
buildCommand,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-wasm/encode/encode-pipeline";
|
||||
|
||||
// Inspect the command ffmpeg will be asked to run
|
||||
const cmd = buildCommand("opus", 48_000, 1, 96_000);
|
||||
console.log(cmd.args);
|
||||
|
||||
const interleaved = interleave([mono]);
|
||||
const result = await runEncode({
|
||||
host,
|
||||
channelData: [mono],
|
||||
format: "opus",
|
||||
sampleRate: 48_000,
|
||||
bitrate: 96_000,
|
||||
signal: controller.signal,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interleave(channelData): Float32Array`.
|
||||
- `float32ToBytes(samples): Uint8Array`.
|
||||
- `buildCommand(format, sampleRate, channels, bitrate?, quality?): FormatCommand`.
|
||||
- `runEncode(opts: EncodePipelineOptions): Promise<Uint8Array>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`assertSupported` is intentionally written as a TypeScript `asserts`
|
||||
function so its throw branch narrows the format type for the rest of the
|
||||
function — no extra runtime branch is needed. Format-specific defaults
|
||||
(192 kbps for MP3/AAC, 96 kbps for Opus/WebM, q=5 for Vorbis) are
|
||||
chosen to match the ffmpeg.wasm reference build's documented sweet
|
||||
spots; callers always override via `bitrate`/`quality` for production
|
||||
use. The interleaved `Float32Array` is wrapped in a `Uint8Array` view
|
||||
without an extra copy so the worker `postMessage` boundary takes one
|
||||
transferable buffer instead of two.
|
||||
@@ -0,0 +1,77 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the canonical error-code registry for
|
||||
`@suaveplan/audio-codec-ffmpeg-wasm`. It exposes the nine `AUDIO_CODEC_FFMPEG_WASM.*`
|
||||
codes the package can throw, an immutable metadata record describing each code,
|
||||
the `AudioCodecFfmpegWasmError` class that every codec fault inherits from, and
|
||||
an idempotent registration hook so external systems (OTel attribute binders,
|
||||
report generators) can record that the registry has been touched.
|
||||
|
||||
## Features
|
||||
|
||||
- Nine stable error codes covering core-load failures, worker spawn, decode,
|
||||
encode, timeout, abort, unsupported codec, OOM and COI gating.
|
||||
- `AudioCodecFfmpegWasmError` class — extends `SuaveplanError`, carries
|
||||
`category`, `code`, `context`, and the originating `cause`.
|
||||
- Frozen `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA` record with description and
|
||||
severity per code.
|
||||
- `registerAudioCodecFfmpegWasmErrorCodes()` no-op hook for downstream wiring.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AudioCodecFfmpegWasmError,
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-wasm/error-codes";
|
||||
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
"audio-codec-ffmpeg-wasm.encode.unknown-format",
|
||||
{ context: { format: "alac" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA,
|
||||
type AudioCodecFfmpegWasmErrorCode,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-wasm/error-codes";
|
||||
|
||||
function severityFor(code: AudioCodecFfmpegWasmErrorCode): string {
|
||||
return AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA[code].severity;
|
||||
}
|
||||
```
|
||||
|
||||
The metadata record is frozen so downstream consumers can safely lift it into
|
||||
read-only contexts (OTel attribute maps, code-to-i18n-key tables). Throw sites
|
||||
elsewhere in the package always carry a `context` payload — `coreUrl`,
|
||||
`format`, `elapsedMs`, `timeoutMs`, `requestedBytes`, or the Zod issues list.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUDIO_CODEC_FFMPEG_WASM_CODES` — `defineCodes("AUDIO_CODEC_FFMPEG_WASM", […])`
|
||||
result; key → namespaced code string.
|
||||
- `type AudioCodecFfmpegWasmErrorCode` — union of every code string value.
|
||||
- `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA` — frozen `Record` of metadata.
|
||||
- `class AudioCodecFfmpegWasmError extends SuaveplanError<…>` — instances expose
|
||||
`category = "audio-codec-ffmpeg-wasm"`, `code`, `messageKey`, `context`,
|
||||
`cause`.
|
||||
- `registerAudioCodecFfmpegWasmErrorCodes()` — idempotent no-op.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The metadata record is `Object.freeze`d so it cannot be mutated at runtime —
|
||||
this keeps the OTel binder behaviour deterministic. The class has a `category`
|
||||
field so the constructor body is non-trivial, sidestepping the `useless-constructor`
|
||||
biome rule without any `biome-ignore` comment (CLAUDE.md rule 9). Every code
|
||||
emitted by this package is reachable from a real call site exercised in
|
||||
`error-codes.test.ts`, satisfying the §2c reachability requirement.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `register.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes the `register(registry, options?)` install helper —
|
||||
a single function that callers use to plug `FfmpegWasmCodec` into a
|
||||
shared `CodecRegistry` from `@suaveplan/audio-codecs`. The helper exists
|
||||
so consumers can install the codec without importing the class
|
||||
explicitly; it forwards options through the codec constructor (which
|
||||
runs Zod validation) and returns the constructed instance so callers
|
||||
can `dispose()` it later.
|
||||
|
||||
## Features
|
||||
|
||||
- One-line installation against a `CodecRegistry`.
|
||||
- Forwards options through `FfmpegWasmCodec`'s Zod boundary.
|
||||
- Returns the constructed codec so callers retain a `dispose()` handle.
|
||||
- Re-register semantics: a second call against the same registry with
|
||||
the same codec name replaces the prior entry.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs";
|
||||
import { register } from "@suaveplan/audio-codec-ffmpeg-wasm/register/register";
|
||||
|
||||
const registry = new CodecRegistry();
|
||||
const codec = register(registry);
|
||||
// later:
|
||||
await codec.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs";
|
||||
import { register } from "@suaveplan/audio-codec-ffmpeg-wasm/register/register";
|
||||
|
||||
const registry = new CodecRegistry();
|
||||
const codec = register(registry, {
|
||||
corePath: "/static/ffmpeg-core.js",
|
||||
enableMultithread: true,
|
||||
crossOriginIsolated: globalThis.crossOriginIsolated,
|
||||
progressCallback: (p) => store.update({ encodeProgress: p }),
|
||||
logger: myLogger,
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
void codec.dispose();
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `register(registry: CodecRegistry, options?: FfmpegWasmCodecOptions): FfmpegWasmCodec`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helper deliberately delegates validation to the codec constructor
|
||||
rather than duplicating the Zod schema call here — there is exactly one
|
||||
boundary. A second call against the same registry with the same `name`
|
||||
("ffmpeg-wasm") replaces the prior entry per
|
||||
`CodecRegistry.register`'s replace-on-duplicate behaviour. Callers
|
||||
holding the previous instance from a prior `register()` call must
|
||||
explicitly `dispose()` it; the registry does not own dispose
|
||||
responsibility.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `streaming-decoder.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module implements the `StreamingDecoder` contract from
|
||||
`@suaveplan/audio-codecs` over ffmpeg.wasm. The implementation is
|
||||
buffered emulation — ffmpeg.wasm is whole-file in / whole-file out and
|
||||
cannot produce decoded pages incrementally, so this class accumulates
|
||||
chunks in memory and runs the decode pipeline once during `flush`.
|
||||
|
||||
## Features
|
||||
|
||||
- `write(chunk)` buffers without touching the worker; returns `[]`.
|
||||
- `flushAsync()` runs the decode pipeline and caches the result.
|
||||
- `flush()` (synchronous, per the contract) returns the cached result
|
||||
populated by a prior `flushAsync()` call.
|
||||
- Idempotent `flush` / `flushAsync` — subsequent calls return cached.
|
||||
- `close()` clears buffered chunks; idempotent.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FfmpegWasmStreamingDecoder } from "@suaveplan/audio-codec-ffmpeg-wasm/streaming/streaming-decoder";
|
||||
|
||||
const decoder = new FfmpegWasmStreamingDecoder({ host, format: "wav" });
|
||||
for (const chunk of chunks) decoder.write(chunk);
|
||||
const result = await decoder.flushAsync();
|
||||
decoder.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const decoder = new FfmpegWasmStreamingDecoder({
|
||||
host,
|
||||
format: "mp3",
|
||||
sampleRate: 44_100,
|
||||
channels: 2,
|
||||
timeoutMs: 30_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
let consumed = 0;
|
||||
for await (const chunk of source) {
|
||||
decoder.write(chunk);
|
||||
consumed += chunk.byteLength;
|
||||
}
|
||||
const decoded = await decoder.flushAsync();
|
||||
// Subsequent synchronous flush() calls return the cached result so
|
||||
// CodecRegistry.decodeStream can pull frames in lock-step.
|
||||
const sync = decoder.flush();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FfmpegWasmStreamingDecoder implements StreamingDecoder`.
|
||||
- `new FfmpegWasmStreamingDecoder(opts: StreamingDecoderOptions)`.
|
||||
- `decoder.write(chunk): readonly DecodeResult[]`.
|
||||
- `decoder.flush(): readonly DecodeResult[]`.
|
||||
- `decoder.flushAsync(): Promise<DecodeResult | null>`.
|
||||
- `decoder.close(): void`.
|
||||
- `interface StreamingDecoderOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `StreamingDecoder` interface in `@suaveplan/audio-codecs` is fully
|
||||
synchronous. ffmpeg.wasm cannot satisfy that synchronously, so the
|
||||
implementation exposes an additional `flushAsync()` method that callers
|
||||
must await before they invoke `flush()` to retrieve the result. The
|
||||
`CodecRegistry.decodeStream` consumer in `audio-codecs` calls `flush()`
|
||||
synchronously, so a wrapping `ReadableStream` `pull` body must await
|
||||
`flushAsync()` first. Inside an async pull body the wait is natural.
|
||||
Progress events fire only during the `flushAsync()` call, never during
|
||||
`write()`.
|
||||
@@ -0,0 +1,71 @@
|
||||
# `streaming-encoder.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module implements the `StreamingEncoder` contract from
|
||||
`@suaveplan/audio-codecs` over ffmpeg.wasm. The implementation is
|
||||
buffered emulation — ffmpeg.wasm cannot stream-encode, so this class
|
||||
accumulates per-channel PCM blocks in memory and runs the encode
|
||||
pipeline once during `flushAsync`. Synchronous `start` / `write` /
|
||||
`flush` return empty bytes until `flushAsync` has been awaited.
|
||||
|
||||
## Features
|
||||
|
||||
- `start(sampleRate, channels)` captures the rate and channel count; emits
|
||||
no header bytes.
|
||||
- `write(channelData)` appends a block per channel without touching the
|
||||
worker.
|
||||
- `flushAsync()` runs the encode pipeline once and caches the encoded
|
||||
bytes.
|
||||
- `flush()` (synchronous, per the contract) returns the cached bytes.
|
||||
- Idempotent `flush` / `flushAsync` / `close`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FfmpegWasmStreamingEncoder } from "@suaveplan/audio-codec-ffmpeg-wasm/streaming/streaming-encoder";
|
||||
|
||||
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "mp3" });
|
||||
encoder.start(44_100, 2);
|
||||
encoder.write([left, right]);
|
||||
const bytes = await encoder.flushAsync();
|
||||
encoder.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const encoder = new FfmpegWasmStreamingEncoder({
|
||||
host,
|
||||
format: "opus",
|
||||
bitrate: 128_000,
|
||||
signal: controller.signal,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
encoder.start(48_000, 1);
|
||||
for (const page of pages) encoder.write(page.channelData);
|
||||
const encoded = await encoder.flushAsync();
|
||||
// Subsequent synchronous flush() calls return the cached bytes.
|
||||
const sync = encoder.flush();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FfmpegWasmStreamingEncoder implements StreamingEncoder`.
|
||||
- `new FfmpegWasmStreamingEncoder(opts: StreamingEncoderOptions)`.
|
||||
- `encoder.start(sampleRate, channels): Uint8Array`.
|
||||
- `encoder.write(channelData): Uint8Array`.
|
||||
- `encoder.flush(): Uint8Array`.
|
||||
- `encoder.flushAsync(): Promise<Uint8Array>`.
|
||||
- `encoder.close(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The encoder mirrors `streaming-decoder` exactly: `start` and `write` are
|
||||
synchronous shims that buffer; the heavy lifting happens in
|
||||
`flushAsync()`. Per-channel arrays are concatenated by allocating a
|
||||
fresh `Float32Array` per `write` call — fine for short streams; large
|
||||
streams should call the non-streaming `encode` instead. Output bytes
|
||||
are returned only once `flushAsync` resolves; the synchronous `flush()`
|
||||
returns the cached value so `CodecRegistry.encodeStream` can pull
|
||||
bytes in lock-step inside an async pull body.
|
||||
@@ -0,0 +1,99 @@
|
||||
# `worker-host.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the lifecycle of the dedicated Web Worker that runs the
|
||||
ffmpeg.wasm core. It serialises every codec call onto the worker, plumbs
|
||||
`AbortSignal` and a timeout watchdog through to `worker.terminate()`,
|
||||
parses worker stderr for memory-limit conditions, and bridges progress
|
||||
events to the user-supplied callback. The host is the unit of cancellation
|
||||
for the entire codec.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy worker construction through the injected `workerFactory` seam.
|
||||
- One-time `load` handshake; subsequent calls reuse the loaded core.
|
||||
- `submitFfmpeg` writes input bytes to MEMFS, runs an ffmpeg command,
|
||||
reads the output, and cleans both MEMFS entries via try/finally.
|
||||
- Pre-aborted and mid-call `AbortSignal` handling — terminate the worker
|
||||
and reject every pending call with `AUDIO_CODEC_FFMPEG_WASM.ABORTED`.
|
||||
- Per-call timeout watchdog driven by the injected `Clock`. Defaults
|
||||
60 s decode / 120 s encode; configurable per call.
|
||||
- Memory-limit detection scans worker stderr (`Cannot enlarge memory`,
|
||||
`OutOfMemory`, `Aborted()`) and surfaces
|
||||
`AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED`.
|
||||
- Worker spawn failures (factory throws, `error` before `load` resolves,
|
||||
`postMessage` throws) surface as `WORKER_SPAWN_FAILED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WorkerHost } from "@suaveplan/audio-codec-ffmpeg-wasm/worker/worker-host";
|
||||
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => new Worker(coreUrl),
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
await host.load();
|
||||
const { outputBytes, stderr } = await host.submitFfmpeg(
|
||||
{ path: "input.mp3", bytes: inputBytes },
|
||||
"output.raw",
|
||||
["-i", "input.mp3", "-f", "f32le", "output.raw"],
|
||||
{ category: "decode" },
|
||||
);
|
||||
host.terminate();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { WorkerHost } from "@suaveplan/audio-codec-ffmpeg-wasm/worker/worker-host";
|
||||
|
||||
const ctl = new AbortController();
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => new Worker(coreUrl, { type: "module" }),
|
||||
clock: () => performance.now(),
|
||||
progressCallback: (p) => console.log("progress", p),
|
||||
logger: myLogger,
|
||||
});
|
||||
try {
|
||||
const result = await host.submitFfmpeg(
|
||||
{ path: "in.wav", bytes: wavBytes },
|
||||
"out.opus",
|
||||
["-i", "in.wav", "-c:a", "libopus", "-b:a", "96k", "out.opus"],
|
||||
{ category: "encode", signal: ctl.signal, timeoutMs: 30_000 },
|
||||
);
|
||||
return result.outputBytes;
|
||||
} finally {
|
||||
host.terminate();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class WorkerHost` — owns one worker.
|
||||
- `new WorkerHost(opts: WorkerHostOptions)`.
|
||||
- `host.isLoaded: boolean`.
|
||||
- `host.ensureWorker(): WorkerLike`.
|
||||
- `host.load(): Promise<void>`.
|
||||
- `host.submitFfmpeg(input, outputPath, args, opts): Promise<FfmpegRunResult>`.
|
||||
- `host.terminate(reason?: AudioCodecFfmpegWasmError): void`.
|
||||
- `host.onProgress(progress: number): void`.
|
||||
- `DEFAULT_TIMEOUTS` — readonly `{ decode: 60_000, encode: 120_000 }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The host treats the worker as a stateful resource: every call goes through
|
||||
`ensureWorker`, every failure path resets the worker reference so the next
|
||||
call constructs a fresh one. The `WorkerLike` interface only requires
|
||||
`postMessage` / `addEventListener` / `removeEventListener` / `terminate`,
|
||||
so the real DOM `Worker` and the test shim are both first-class. Memory
|
||||
detection runs against both the structured `WorkerResponse.stderr` channel
|
||||
and any free-floating `stderr` events posted between requests. The
|
||||
watchdog timer is cleared on every settle path — success, failure,
|
||||
abort, and the OOM short-circuit — so the host never leaks pending
|
||||
`setTimeout` handles.
|
||||
Reference in New Issue
Block a user