8.6 KiB
OpenSpec Change: enterprise-audio-codec-ffmpeg-wasm
- Change ID: enterprise-audio-codec-ffmpeg-wasm
- Package:
@suaveplan/audio-codec-ffmpeg-wasm - Category: browser
- Target path:
packages/media/audio/audio-codec-ffmpeg-wasm/ - Status: PROPOSED
- Date: 2026-05-15
- Design:
./design.md - Spec delta:
./specs/audio-codec-ffmpeg-wasm/spec.md
Summary
Deliver @suaveplan/audio-codec-ffmpeg-wasm on the genesis branch from scratch at enterprise quality. The package is the browser-tier concrete codec plugin that implements the AudioCodec plugin contract from @suaveplan/audio-codecs by wrapping @ffmpeg/ffmpeg (ffmpeg.wasm) and @ffmpeg/core running inside a Web Worker. It exposes a FfmpegWasmCodec class covering decode and encode for wav, mp3, ogg, opus, flac, aac, and webm — the same format surface as the native server-tier counterpart so the two are interchangeable at the CodecRegistry level. A single-line register(registry) helper installs the codec into a shared registry. The wasm core (~30 MB) is lazy-loaded on the first decode/encode call, never at module import. When the host page is cross-origin-isolated (crossOriginIsolated === true) and enableMultithread is set, the package loads the multi-threaded @ffmpeg/core-mt variant via optionalDependencies; otherwise it falls back to the single-thread core. The codec is genesis-original — there is no upstream counterpart to migrate.
Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data. It is genesis-original: no upstream ../suaveplan/packages/audio-codec-ffmpeg-wasm exists and no entry in STATUS.md's Pending list — the change is authored under §0b's third clause ("does not exist in Pending or Completed").
Available workspace deps on genesis
| Package | Role |
|---|---|
@suaveplan/audio-codecs (workspace:^) |
AudioCodec interface, CodecRegistry, DecodeResult, streaming factory types |
@suaveplan/audio-core (workspace:^) |
AudioDocument, AudioPage (consumed indirectly via the codec contract) |
@suaveplan/streams (workspace:^) |
WHATWG ReadableStream / TransformStream primitives for chunked source feeding |
@suaveplan/error (workspace:^) |
Base class for AudioCodecFfmpegWasmError; AUDIO_CODEC_FFMPEG_WASM.* codes |
@suaveplan/types (workspace:^) |
LoggerLike, Clock shared abstractions |
@ffmpeg/ffmpeg (catalog entry — add) |
Main-thread façade over ffmpeg.wasm; spawns the worker and drives MEMFS |
@ffmpeg/core (catalog entry — add) |
Single-threaded wasm core (~30 MB) |
@ffmpeg/core-mt (optionalDependencies) |
Multi-threaded wasm core — only loaded when crossOriginIsolated && enableMultithread |
zod (catalog:forms) |
Schemas for FfmpegWasmCodecOptions and the register argument |
@opentelemetry/api (catalog:observability) |
Spans + counter + histogram on decode and encode |
tslib (catalog:build) |
TypeScript helpers |
Dev deps: vitest, @vitest/coverage-istanbul, happy-dom, fast-check, mitata (all catalog:testing). The @suaveplan/testing/runner wrapper is the only path to test-runner primitives.
Catalog action item:
@ffmpeg/ffmpegand@ffmpeg/coreare not currently in the rootpackage.jsoncatalogblock. Adding both at exact pinned versions is the first task — seetasks.md §1.
Public API
Core types
class AudioCodecFfmpegWasmError extends SuaveplanError
interface FfmpegWasmCodecOptions {
/** CDN URL for `@ffmpeg/core` wasm; mutually exclusive with `corePath`. */
coreUrl?: string
/** Local URL for `@ffmpeg/core` wasm; mutually exclusive with `coreUrl`. */
corePath?: string
/** Test seam — overrides the default `new Worker(...)` construction. */
workerFactory?: () => Worker
/**
* Opt into the multi-threaded `@ffmpeg/core-mt` variant.
* Requires `crossOriginIsolated === true`; otherwise throws on first use.
* Default `false` (auto-detect: if `crossOriginIsolated` is true, mt is preferred only when this flag is also true).
*/
enableMultithread?: boolean
/** Receives progress in `[0, 1]` from ffmpeg's `progress` event. */
progressCallback?: (progress: number) => void
/** Structured logger; defaults to a no-op. */
logger?: LoggerLike
/** Injectable clock for OTel histograms and timeouts; defaults to `Date.now`-based. */
clock?: Clock
}
class FfmpegWasmCodec implements AudioCodec {
readonly name: 'ffmpeg-wasm'
readonly extensions: ['wav', 'mp3', 'ogg', 'opus', 'flac', 'aac', 'webm']
readonly mimeTypes: string[]
constructor(options?: FfmpegWasmCodecOptions)
canDecode(bytes: Uint8Array): boolean
decode(bytes: Uint8Array | ArrayBuffer): Promise<DecodeResult>
encode(pcm: Float32Array[], opts: EncodeOpts): Promise<Uint8Array>
createDecoder(): StreamingDecoder
createEncoder(): StreamingEncoder
/** Terminates the worker and releases the loaded core. */
dispose(): Promise<void>
}
/** One-line install helper. Constructs the codec with `options` and registers it. */
function register(registry: CodecRegistry, options?: FfmpegWasmCodecOptions): FfmpegWasmCodec
Error codes
type AudioCodecFfmpegWasmErrorCode =
| 'AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED'
| 'AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED'
| 'AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED'
| 'AUDIO_CODEC_FFMPEG_WASM.ENCODE_FAILED'
| 'AUDIO_CODEC_FFMPEG_WASM.TIMEOUT'
| 'AUDIO_CODEC_FFMPEG_WASM.ABORTED'
| 'AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC'
| 'AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED'
| 'AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED'
const AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA:
Readonly<Record<AudioCodecFfmpegWasmErrorCode, CodeMetadata>>
function registerAudioCodecFfmpegWasmErrorCodes(): void
Zod schemas
FfmpegWasmCodecOptionsSchema
Module layout
Five files per feature module, co-located with .md siblings per CLAUDE.md "Package Structure":
packages/media/audio/audio-codec-ffmpeg-wasm/
package.json
tsconfig.json
vitest.config.ts
README.md
src/
index.ts
error-codes.ts
error-codes.md
error-codes.test.ts
codec/
ffmpeg-wasm-codec.ts
ffmpeg-wasm-codec.md
ffmpeg-wasm-codec.types.ts
ffmpeg-wasm-codec.schemas.ts
ffmpeg-wasm-codec.test.ts
index.ts
worker/
worker-host.ts
worker-host.md
worker-host.types.ts
worker-host.test.ts
index.ts
core-loader/
core-loader.ts
core-loader.md
core-loader.types.ts
core-loader.test.ts
index.ts
decode/
decode-pipeline.ts
decode-pipeline.md
decode-pipeline.types.ts
decode-pipeline.test.ts
index.ts
encode/
encode-pipeline.ts
encode-pipeline.md
encode-pipeline.types.ts
encode-pipeline.test.ts
index.ts
streaming/
streaming-decoder.ts
streaming-decoder.md
streaming-decoder.test.ts
streaming-encoder.ts
streaming-encoder.md
streaming-encoder.test.ts
index.ts
register/
register.ts
register.md
register.test.ts
index.ts
__benchmarks__/
decode.bench.ts
encode.bench.ts
tests/
purity.test.ts
exports.test.ts
integration.test.ts
Every feature module's index.ts is an explicit named-export barrel per CLAUDE.md rule 10; no export *.
Impact
Breaking changes: none — fresh build, package does not exist on genesis.
Prerequisites: @suaveplan/audio-codecs, @suaveplan/audio-core, @suaveplan/streams, @suaveplan/error, @suaveplan/types published to Verdaccio. All five are scheduled in the same enterprise-audio-* batch and must land before this package builds.
Affected dependents
@suaveplan/audio-codec-ffmpeg-wasm is a leaf codec plugin — nothing in the workspace currently depends on it. It is installed at runtime by browser-tier applications via the register(registry) helper; those applications are out-of-tree consumers and do not appear in bunx turbo ls --filter=...@suaveplan/audio-codec-ffmpeg-wasm. There is therefore no in-repo dependent surface to sweep.
- (no in-repo dependents) — [x] no action needed (reason: leaf plugin; consumers are out-of-tree browser applications)
Once landed, any subsequent change to this package's public surface MUST run bunx turbo test --filter=...@suaveplan/audio-codec-ffmpeg-wasm per openspec/AGENTS.md §13 step 10 so that any future in-repo dependent (e.g. a browser audio engine that bundles a default codec set) is covered automatically.