11 KiB
Design: @suaveplan/audio-codec-ffmpeg-wasm
Non-normative design notes. The normative contract lives in
./specs/audio-codec-ffmpeg-wasm/spec.md.
Why wasm + a Web Worker
@suaveplan/audio-codecs defines a tier-agnostic plugin contract. The server-tier
audio-codec-ffmpeg-native package wraps a system ffmpeg binary via subprocess. The
browser cannot spawn subprocesses, and there is no ffmpeg analogue baked into Web Audio.
The only credible way to reach the same format coverage in a sandboxed page is ffmpeg.wasm
— ffmpeg compiled to WebAssembly and exposed via the @ffmpeg/ffmpeg JS façade.
Two non-negotiable design choices follow from that:
- The wasm core runs inside a
Worker. Decoding a 5-minute MP3 takes hundreds of milliseconds of solid CPU; doing that on the main thread blocks input handling, layout, and the compositor.@ffmpeg/ffmpegalready supports a worker boundary; we always use it. The worker is the unit of cancellation:AbortSignal.aborttranslates toworker.terminate(), which is the only reliable way to interrupt wasm. - The wasm payload is lazy-loaded.
@ffmpeg/coreis ~30 MB; eagerly fetching it atimporttime would crater page load. The codec stays inert at module load and pulls the core only on the firstdecode/encode/createDecoder/createEncodercall. A second call reuses the loaded core;dispose()tears it down.
Lazy load strategy
The core-loader module exposes loadCore(opts): Promise<LoadedCore> and memoises the
result per FfmpegWasmCodec instance. Resolution order for the core URL:
opts.corePath(explicit local URL) — preferred for self-hosted, COEP-correct serving.opts.coreUrl(explicit CDN URL).- The package's own bundled default (
unpkg.com/@ffmpeg/core@x.y.z/dist/ffmpeg-core.js) — documented but not the recommended path; only used when neither option is set.
When opts.enableMultithread === true:
- The loader inspects
globalThis.crossOriginIsolated. If it is nottrue, the loader throwsAUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIREDwith a context payload explaining the COOP/COEP requirement (see "Cross-origin isolation" below). This is the only path that surfaces that code — whenenableMultithreadis unset orfalsethe loader falls back to the single-thread core silently. - When COI is true, the loader dynamic-imports
@ffmpeg/core-mt(declared inoptionalDependenciesso single-thread consumers do not pay the install cost). If the dynamic import rejects (not installed), the loader emits a warning viaopts.loggerand falls back to@ffmpeg/corerather than failing — multi-thread is an optimisation, not a correctness boundary.
Cross-origin isolation
The multi-threaded core uses SharedArrayBuffer, which browsers gate behind cross-origin
isolation:
- The page must be served with
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp(orcredentialless). - All loaded resources must opt-in via
Cross-Origin-Resource-Policyor becrossorigin="anonymous"with proper CORS. globalThis.crossOriginIsolatedistrueonly when both headers are present.
The codec never silently downgrades correctness because of COI. The flag controls performance only:
enableMultithread |
crossOriginIsolated |
Result |
|---|---|---|
false (default) |
any | Single-thread core loads |
true |
true |
@ffmpeg/core-mt loads (with fallback to single-thread if optional dep missing) |
true |
false |
Throws AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED on first use |
The "throw" case is the only way that error code is surfaced, satisfying §2c (every registered code reachable from a real call site).
Worker isolation
@ffmpeg/ffmpeg's FFmpeg class spawns a dedicated worker. We do not subclass FFmpeg;
the worker-host module wraps it to add:
AbortSignalplumbing →worker.terminate()on abort plus aPromise.rejectwithAUDIO_CODEC_FFMPEG_WASM.ABORTED.- Timeout watchdog (default 60s for decode, 120s for encode; both configurable via
EncodeOpts/DecodeOptssignalplus a wrappingAbortSignal.timeout) →AUDIO_CODEC_FFMPEG_WASM.TIMEOUT. - A
progressevent listener that bridges intoopts.progressCallback. - Crash detection: an unexpected worker
errorevent maps toAUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILEDif it fires beforeload()resolved, else toAUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED/_ENCODE_FAILEDdepending on the active call. - Memory-pressure detection: wasm linear memory is capped at 4 GB; ffmpeg.wasm surfaces
OutOfMemoryvia stderr or anAborted()JS exception. The host parses this and re-emitsAUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDEDwith the requested allocation size incontext.
Test seam: opts.workerFactory overrides new Worker(coreUrl, …) so tests can supply a
controllable shim. The shim is exercised in tests/ under happy-dom plus a hand-rolled
Worker polyfill — see worker-host.test.ts.
Decode pipeline
Per the AudioCodec contract, decode(bytes) returns
DecodeResult = { channelData: Float32Array[]; sampleRate: number; format: string }. The
ffmpeg command we run is:
ffmpeg -hide_banner -nostdin -i input.<ext> -f f32le -ac <preserve> -ar <preserve> output.raw
Steps:
- Inspect
bytesmagic to pick the input container/extension hint (re-using@suaveplan/audio-metadata's sniffer throughaudio-codecs). Failure to detect →AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC. ffmpeg.writeFile('input.' + ext, bytes)— writes into MEMFS (in-memory virtual FS).- Run a probe pass with
ffprobe-equivalent flags to learn the sourcesampleRateandchannels. (@ffmpeg/ffmpegexposes this as a JSON log line.) - Run the f32le extraction; ffmpeg writes interleaved
Float32samples. ffmpeg.readFile('output.raw')→Uint8Array.- Deinterleave into
Float32Array[]per channel. ffmpeg.deleteFile('input.' + ext)andffmpeg.deleteFile('output.raw')to free MEMFS.
Any ffmpeg non-zero exit → AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED with the stderr tail in
context.
Encode pipeline
encode(pcm, opts) accepts Float32Array[] per channel plus an EncodeOpts with at
minimum format, sampleRate, and a format-specific bitrate / quality. Steps:
-
Interleave channels into a single
Float32Array, thennew Uint8Array(buffer)of the underlying bytes. -
ffmpeg.writeFile('input.raw', interleavedBytes). -
Run a format-specific command, for example for MP3:
ffmpeg -f f32le -ar <rate> -ac <ch> -i input.raw -c:a libmp3lame -b:a <bitrate> output.mp3 -
ffmpeg.readFile('output.<ext>')→Uint8Arrayreturned to caller. -
Cleanup both MEMFS entries.
Each supported format owns a small command-builder function in encode-pipeline.ts. A
format outside the supported set → AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC before
any wasm call.
Streaming "emulation" — be honest
@ffmpeg/ffmpeg is not naturally streaming. Its MEMFS surface is whole-file in, whole-file
out. We expose StreamingDecoder / StreamingEncoder because the AudioCodec contract
asks for it, but the implementation is buffered emulation, and the spec scenario calls
this out plainly. The behaviour:
StreamingDecoder.write(chunk): appendchunkto an internalUint8Arraybuffer; do not call ffmpeg. Always returnsnull(no decoded pages yet).StreamingDecoder.flush(): write the buffered bytes to MEMFS, run the same decode pipeline asdecode(), and return the resultingDecodeResult. Subsequentflush()calls on the same decoder are no-ops.- Progress events fire during the
flush()call only, not duringwrite(). StreamingEncoderis symmetric.
This is documented in the spec scenario "Streaming is buffered emulation, not chunked
decode" so callers do not assume true streaming behaviour. Real streaming would require a
fork of @ffmpeg/ffmpeg that pipes stdout/stdin through MessagePort — out of scope.
Format support matrix
| Format | Decode | Encode | Encoder cmd flag |
|---|---|---|---|
wav |
✅ | ✅ | -c:a pcm_s16le (default) |
mp3 |
✅ | ✅ | -c:a libmp3lame |
ogg |
✅ | ✅ | -c:a libvorbis |
opus |
✅ | ✅ | -c:a libopus |
flac |
✅ | ✅ | -c:a flac |
aac |
✅ | ✅ | -c:a aac |
webm |
✅ | ✅ | -c:a libopus -f webm |
The exact codec availability depends on which @ffmpeg/core build is loaded. The default
@ffmpeg/core build ships libmp3lame, libvorbis, libopus, flac, aac, pcm_*. A
runtime probe (ffmpeg -encoders parsed once at load) checks availability; missing entries
get pruned from extensions/mimeTypes rather than silently failing later.
Trade-offs vs the native server-tier counterpart
| Axis | audio-codec-ffmpeg-native (server) |
audio-codec-ffmpeg-wasm (browser) |
|---|---|---|
| Throughput | Hardware SIMD, full CPU | 10–50× slower; wasm SIMD only when COI |
| Startup cost | Negligible (system binary) | ~30 MB wasm fetch + ~200ms instantiate |
| Memory ceiling | OS-bound | 4 GB hard wasm cap |
| Sandbox | None | Page sandbox + Worker isolation |
| Streaming | True streaming via pipes | Buffered emulation |
| Cancellation | child.kill() |
worker.terminate() |
| Format reach | Whatever the system ffmpeg supports | Whatever the loaded core ships |
The two are interchangeable at the registry level: applications register the appropriate
codec per tier; consumers of CodecRegistry.decode(source) see the same AudioDocument
shape regardless.
OTel instrumentation
The codec wraps every public method in a span:
audio-codec-ffmpeg-wasm.decode— attributes:format,bytes,channels,sampleRate,multithread.audio-codec-ffmpeg-wasm.encode— attributes:format,samples,channels,sampleRate,bitrate.- Counter
audio_codec_ffmpeg_wasm_decode_total(labels:format,outcome). - Counter
audio_codec_ffmpeg_wasm_encode_total(labels:format,outcome). - Histogram
audio_codec_ffmpeg_wasm_decode_duration_ms(labels:format). - Histogram
audio_codec_ffmpeg_wasm_encode_duration_ms(labels:format).
All instrumentation is a no-op when no exporter is registered; nothing executes at module
import. The injectable Clock drives the histogram measurements so tests are
deterministic.
Browser-tier purity
CLAUDE.md rule "Universal: ZERO browser/React deps | Browser: React/DOM ok | Server:
Node.js ok" makes the constraint explicit. This package is browser-tier — DOM and Worker
APIs are allowed; Node-only APIs are forbidden even via dynamic import. The build is
configured "browser": true in package.json and the test in tests/purity.test.ts
greps the built dist/ for node:, process., Buffer., and require( to confirm.
Source bytes arrive as ArrayBuffer / Uint8Array / ReadableStream<Uint8Array> —
never as a file path. File-path sources are the registry's problem and resolved by the
universal audio-codecs source-resolution code paths before bytes ever reach this codec.