22 KiB
Capability Spec Delta: audio-codec-ffmpeg-wasm (audio-codec-ffmpeg-wasm)
Package: @suaveplan/audio-codec-ffmpeg-wasm
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Browser-tier purity
The @suaveplan/audio-codec-ffmpeg-wasm package SHALL be browser-tier and MUST NOT import Node-only APIs (node:fs, node:path, node:crypto, process, Buffer, require) at any point — neither statically nor through a dynamic import(). Top-level imports SHALL be limited to @suaveplan/audio-codecs, @suaveplan/audio-core, @suaveplan/streams, @suaveplan/error, @suaveplan/types, @ffmpeg/ffmpeg, zod, @opentelemetry/api, and tslib. @ffmpeg/core SHALL be loaded only via the @ffmpeg/ffmpeg runtime, not via a static import. @ffmpeg/core-mt SHALL be declared in optionalDependencies and reached only via dynamic import() from inside the COI-gated branch of the core loader. package.json SHALL declare "browser": true and "sideEffects": false.
Scenario: built dist references no Node APIs
- WHEN the built
dist/is grep'd fornode:,process.,Buffer., or top-levelrequire( - THEN the search returns zero matches in any file under
dist/
Scenario: @ffmpeg/core-mt is loaded only inside a dynamic import
- WHEN the built
dist/is searched for@ffmpeg/core-mt - THEN every occurrence is inside an
import("@ffmpeg/core-mt")expression, never inside a staticimport … fromstatement
Verified by: tests/purity.test.ts::"dist contains no Node APIs" AND tests/purity.test.ts::"@ffmpeg/core-mt is reached only via dynamic import"
Requirement: AudioCodec contract conformance
The package SHALL export a FfmpegWasmCodec class implementing the AudioCodec interface from @suaveplan/audio-codecs. The instance SHALL expose name === "ffmpeg-wasm", an extensions array covering "wav", "mp3", "ogg", "opus", "flac", "aac", and "webm" (filtered down to only those the loaded core's encoder/decoder probe actually supports), and a matching mimeTypes array. The class SHALL implement canDecode(bytes), decode(bytes), encode(pcm, opts), createDecoder(), and createEncoder() per the AudioCodec contract, plus a dispose(): Promise<void> that terminates the worker and releases the loaded core.
Scenario: instance satisfies the AudioCodec interface
- WHEN an
FfmpegWasmCodecis constructed and inspected - THEN it has the declared
name, non-emptyextensionsandmimeTypesarrays, and the methodscanDecode,decode,encode,createDecoder,createEncoder,disposeare all functions
Scenario: decode return shape matches DecodeResult
- WHEN
decodesucceeds against a valid WAV input - THEN the result is
{ channelData: Float32Array[]; sampleRate: number; format: string }withchannelData.length === channelsandformat === "wav"
Verified by: src/codec/ffmpeg-wasm-codec.test.ts::"instance satisfies the AudioCodec interface" AND src/codec/ffmpeg-wasm-codec.test.ts::"decode returns a well-formed DecodeResult"
Requirement: Lazy core load on first use
The package SHALL NOT load the @ffmpeg/core wasm payload at module import time, at FfmpegWasmCodec construction time, or via any module-level side effect. The core SHALL be loaded only on the first call to decode, encode, createDecoder, or createEncoder. Subsequent calls SHALL reuse the loaded core. A core-load failure SHALL throw AudioCodecFfmpegWasmError with code AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED carrying the resolved core URL and the underlying error as cause.
Scenario: importing the package loads no wasm
- WHEN the package is imported and
new FfmpegWasmCodec()is constructed - THEN no network request to a
@ffmpeg/coreresource has been issued and no wasm module has been instantiated
Scenario: core load failure throws AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED
- WHEN
decodeis called with acorePaththat resolves to a non-existent URL - THEN it throws
AudioCodecFfmpegWasmErrorwhosecode === "AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED"with the URL incontext.coreUrl
Verified by: src/core-loader/core-loader.test.ts::"importing the package does not load wasm" AND src/core-loader/core-loader.test.ts::"core load failure throws AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED"
Requirement: Worker isolation for the wasm core
The package SHALL run the ffmpeg.wasm core inside a dedicated Worker so the main thread is never blocked by decode or encode work. Construction of the worker SHALL go through opts.workerFactory() when supplied (test seam); otherwise the package uses @ffmpeg/ffmpeg's default worker construction. A worker that fails to spawn SHALL throw AudioCodecFfmpegWasmError with code AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED carrying the underlying error as cause.
Scenario: workerFactory test seam is honoured
- WHEN
decodeis called on anFfmpegWasmCodecconstructed withworkerFactory: () => fakeWorker - THEN the fake worker receives the decode messages and no real
Workerconstructor is invoked
Scenario: worker spawn failure throws AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED
- WHEN
opts.workerFactoryreturns a worker that immediately emits anerrorevent beforeload()resolves - THEN the first method call throws
AudioCodecFfmpegWasmErrorwhosecode === "AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED"
Verified by: src/worker/worker-host.test.ts::"workerFactory test seam is honoured" AND src/worker/worker-host.test.ts::"worker spawn failure throws AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED"
Requirement: Decode pipeline
decode(bytes) SHALL write the input bytes into ffmpeg's MEMFS, run an ffmpeg command that emits f32le interleaved samples to a MEMFS output, read that output back, deinterleave into Float32Array[] per channel, and return a DecodeResult carrying the sniffed sampleRate and the input format name. Both MEMFS entries SHALL be deleted before returning. A non-zero ffmpeg exit SHALL throw AudioCodecFfmpegWasmError with code AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED carrying the stderr tail in context.stderr and the resolved format in context.format.
Scenario: decode produces normalised float PCM
- WHEN
decodeis called with a 16-bit WAV buffer - THEN the result
channelDataisFloat32Array[]with all samples in[-1, 1]andsampleRateequals the source rate
Scenario: ffmpeg failure throws AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED
- WHEN
decodeis called with bytes that ffmpeg rejects (e.g. a truncated MP3 header) - THEN it throws
AudioCodecFfmpegWasmErrorwhosecode === "AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED"with the stderr tail incontext.stderr
Verified by: src/decode/decode-pipeline.test.ts::"decode produces normalised float PCM" AND src/decode/decode-pipeline.test.ts::"ffmpeg failure throws AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED"
Requirement: Encode pipeline
encode(pcm, opts) SHALL interleave Float32Array[] channels into a single buffer, write it to MEMFS as f32le, run the format-specific ffmpeg command (one per wav/mp3/ogg/opus/flac/aac/webm) honouring opts.bitrate / opts.quality when provided, read the encoded output from MEMFS, delete both MEMFS entries, and return the encoded Uint8Array. A format outside the supported set SHALL throw AudioCodecFfmpegWasmError with code AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC before the worker is invoked. A non-zero ffmpeg exit SHALL throw AudioCodecFfmpegWasmError with code AUDIO_CODEC_FFMPEG_WASM.ENCODE_FAILED carrying the stderr tail in context.stderr.
Scenario: encode produces a valid file for the requested format
- WHEN
encodeis called with a single-channel sine wave andformat: "mp3" - THEN the returned bytes start with an MP3 frame sync (
0xFFFBor0xFFFA) and decoding them back yields PCM whose length matches the input within ±1 frame
Scenario: unsupported format throws AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC before the worker is touched
- WHEN
encodeis called withformat: "alac" - THEN it throws
AudioCodecFfmpegWasmErrorwhosecode === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC"and no message is posted to the worker
Verified by: src/encode/encode-pipeline.test.ts::"encode produces a valid file for the requested format" AND src/encode/encode-pipeline.test.ts::"unsupported format throws AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC before the worker is touched"
Requirement: Buffered streaming emulation
createDecoder() and createEncoder() SHALL return objects implementing the StreamingDecoder and StreamingEncoder interfaces from @suaveplan/audio-codecs. The implementation SHALL be buffered emulation: write(chunk) SHALL append the chunk to an internal Uint8Array and return null without invoking the worker; flush() SHALL submit the accumulated bytes through the same decode or encode pipeline as the non-streaming method and return the single resulting DecodeResult or encoded Uint8Array. Subsequent flush() calls SHALL be no-ops. Progress events SHALL fire only during the flush() call.
Scenario: write does not invoke the worker
- WHEN a caller invokes
createDecoder()and callswrite(chunk)three times - THEN no message has been posted to the worker and every call returned
null
Scenario: flush yields the same bytes as a one-shot decode
- WHEN a caller streams a WAV file through
createDecoder()in three chunks and callsflush() - THEN the resulting
DecodeResult.channelDatais byte-equal todecode(fullBuffer).channelData
Verified by: src/streaming/streaming-decoder.test.ts::"write does not invoke the worker" AND src/streaming/streaming-decoder.test.ts::"flush yields the same bytes as a one-shot decode"
Requirement: AbortSignal propagation terminates the worker
decode, encode, StreamingDecoder.flush, and StreamingEncoder.flush SHALL accept an AbortSignal via their options object. A signal that is already aborted SHALL short-circuit the call with AudioCodecFfmpegWasmError code AUDIO_CODEC_FFMPEG_WASM.ABORTED before any worker message is posted. A signal aborted mid-call SHALL trigger worker.terminate(), reject the pending call with the same code, release any worker reference, and require the next call to spawn a fresh worker.
Scenario: pre-aborted signal short-circuits before worker touch
- WHEN
decodeis called with an already-aborted signal - THEN it throws
AudioCodecFfmpegWasmErrorwhosecode === "AUDIO_CODEC_FFMPEG_WASM.ABORTED"and no message has been posted to the worker
Scenario: mid-call abort terminates the worker
- WHEN a signal is aborted after the worker received the decode message but before it responded
- THEN
worker.terminate()is invoked, the decode promise rejects withAUDIO_CODEC_FFMPEG_WASM.ABORTED, and the nextdecodecall constructs a new worker
Verified by: src/worker/worker-host.test.ts::"pre-aborted signal short-circuits before worker touch" AND src/worker/worker-host.test.ts::"mid-call abort terminates the worker"
Requirement: Timeout watchdog
decode and encode SHALL apply a timeout to the underlying worker call. The default timeout SHALL be 60 000 ms for decode and 120 000 ms for encode; both SHALL be overridable via the call options. A timeout SHALL terminate the worker and throw AudioCodecFfmpegWasmError with code AUDIO_CODEC_FFMPEG_WASM.TIMEOUT carrying the elapsed milliseconds in context.elapsedMs and the configured limit in context.timeoutMs. The watchdog SHALL be driven by opts.clock when supplied so tests are deterministic.
Scenario: timeout throws AUDIO_CODEC_FFMPEG_WASM.TIMEOUT
- WHEN
decodeis called withtimeoutMs: 10against a worker that never responds, using an injectableClock - THEN the call throws
AudioCodecFfmpegWasmErrorwhosecode === "AUDIO_CODEC_FFMPEG_WASM.TIMEOUT"withelapsedMs >= 10andtimeoutMs === 10
Verified by: src/worker/worker-host.test.ts::"timeout throws AUDIO_CODEC_FFMPEG_WASM.TIMEOUT"
Requirement: Memory-limit detection
When ffmpeg.wasm exhausts its 4 GB linear-memory cap — surfaced as an Aborted() JS exception or an OutOfMemory/Cannot enlarge memory stderr line — the worker host SHALL re-emit the failure as AudioCodecFfmpegWasmError with code AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED carrying the requested allocation size (when parseable from stderr) in context.requestedBytes. The codec MUST NOT silently swallow OOM into the generic DECODE_FAILED / ENCODE_FAILED codes.
Scenario: wasm OOM maps to AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED
- WHEN the test worker emits a stderr line
"Cannot enlarge memory arrays." - THEN the pending call throws
AudioCodecFfmpegWasmErrorwhosecode === "AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED"
Verified by: src/worker/worker-host.test.ts::"wasm OOM maps to AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED"
Requirement: Cross-origin-isolation gate for the multi-thread variant
When FfmpegWasmCodecOptions.enableMultithread is true, the core loader SHALL inspect globalThis.crossOriginIsolated. If it is not exactly true, the loader SHALL throw AudioCodecFfmpegWasmError with code AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED before attempting to load @ffmpeg/core-mt. When COI is true and the dynamic import("@ffmpeg/core-mt") rejects (optional dep not installed), the loader SHALL log a warning via opts.logger and fall back to single-thread @ffmpeg/core rather than throwing. When enableMultithread is unset or false, the single-thread core SHALL be loaded unconditionally and the COI flag SHALL NOT be consulted.
Scenario: enableMultithread without COI throws AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED
- WHEN
decodeis first called on anFfmpegWasmCodecconstructed withenableMultithread: truein an environment whereglobalThis.crossOriginIsolated === false - THEN it throws
AudioCodecFfmpegWasmErrorwhosecode === "AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED"and@ffmpeg/core-mthas not been dynamic-imported
Scenario: missing @ffmpeg/core-mt falls back silently
- WHEN COI is
true,enableMultithreadistrue, andimport("@ffmpeg/core-mt")rejects with a module-not-found error - THEN the single-thread
@ffmpeg/coreloads successfully, a warning is recorded viaopts.logger, and the call completes
Verified by: src/core-loader/core-loader.test.ts::"enableMultithread without COI throws AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED" AND src/core-loader/core-loader.test.ts::"missing @ffmpeg/core-mt falls back silently"
Requirement: Progress callback wiring
When FfmpegWasmCodecOptions.progressCallback is supplied, the codec SHALL invoke it with a number in [0, 1] for every progress event emitted by @ffmpeg/ffmpeg during a decode or encode call. The callback SHALL be invoked at least once per call that runs through the worker (zero and one are both acceptable bookends). Callback exceptions SHALL be caught and logged via opts.logger without aborting the in-flight call.
Scenario: progress callback receives values in [0, 1]
- WHEN
decoderuns against a multi-second WAV withprogressCallbackset - THEN the callback is invoked at least once with a number in
[0, 1], and the final invocation is1(ordecodecompletes without one — both are acceptable)
Scenario: progress callback exception does not abort decode
- WHEN
progressCallbackthrows on its first invocation - THEN the error is logged via
opts.loggeranddecodestill resolves with a validDecodeResult
Verified by: src/codec/ffmpeg-wasm-codec.test.ts::"progress callback receives values in [0, 1]" AND src/codec/ffmpeg-wasm-codec.test.ts::"progress callback exception does not abort decode"
Requirement: Structured error codes — every code reachable
The package SHALL export AudioCodecFfmpegWasmError, AudioCodecFfmpegWasmErrorCode, AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA, and registerAudioCodecFfmpegWasmErrorCodes(), registering the codes 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, and AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED with @suaveplan/error. Every error thrown by the package SHALL be an AudioCodecFfmpegWasmError extending SuaveplanError and carry one of these codes, cause, and context. Every code SHALL be reachable from a real call site exercised by tests.
Scenario: every registered code is thrown by a call site
- WHEN the test suite runs
- THEN for each code in
AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA, at least one test asserts that code is thrown
Verified by: src/error-codes.test.ts::"every AUDIO_CODEC_FFMPEG_WASM code is reachable from a call site"
Requirement: Zod boundary validation
FfmpegWasmCodecOptionsSchema SHALL be exported from src/codec/ffmpeg-wasm-codec.schemas.ts and validate FfmpegWasmCodecOptions at the FfmpegWasmCodec constructor and at the register(registry, options?) entry point. A validation failure SHALL throw AudioCodecFfmpegWasmError with code AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC carrying the Zod issue list in context.issues. Options SHALL reject coreUrl and corePath being set simultaneously.
Scenario: invalid options are rejected at the constructor
- WHEN
new FfmpegWasmCodec({ coreUrl: "https://a/", corePath: "/b/" }) - THEN it throws
AudioCodecFfmpegWasmErrorwhosecode === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC"with Zod issues incontext.issues
Scenario: register validates its options argument
- WHEN
register(registry, { enableMultithread: "yes" as unknown as boolean })is called - THEN it throws
AudioCodecFfmpegWasmErrorwhosecode === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC"with Zod issues incontext.issues
Verified by: src/codec/ffmpeg-wasm-codec.test.ts::"invalid options are rejected at the constructor" AND src/register/register.test.ts::"register validates its options argument"
Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via @opentelemetry/api: spans audio-codec-ffmpeg-wasm.decode and audio-codec-ffmpeg-wasm.encode; counters audio_codec_ffmpeg_wasm_decode_total and audio_codec_ffmpeg_wasm_encode_total; histograms audio_codec_ffmpeg_wasm_decode_duration_ms and audio_codec_ffmpeg_wasm_encode_duration_ms. Spans SHALL carry the resolved format and multithread flag as attributes. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
Scenario: instrumentation is inert without an exporter
- WHEN the package is imported and used with no OTel exporter registered
- THEN no error is thrown and no global state is mutated at import time
Scenario: a decode records a span, counter, and histogram
- WHEN
decoderuns against a real in-memoryMeterProviderandTracerProvider - THEN
audio-codec-ffmpeg-wasm.decoderecords one span carrying theformatandmultithreadattributes,audio_codec_ffmpeg_wasm_decode_totalincrements by 1, andaudio_codec_ffmpeg_wasm_decode_duration_msrecords one observation
Verified by: src/codec/ffmpeg-wasm-codec.test.ts::"decode records a span, counter, and histogram when an exporter is registered" AND tests/purity.test.ts::"importing the package mutates no global state"
Requirement: register(registry) install helper
The package SHALL export a register(registry: CodecRegistry, options?: FfmpegWasmCodecOptions): FfmpegWasmCodec function. It SHALL validate options via FfmpegWasmCodecOptionsSchema, construct a FfmpegWasmCodec with those options, call registry.register(codec), and return the constructed instance so callers can dispose() it later. A second call with the same registry SHALL replace the prior registration (matching CodecRegistry.register's replace-on-duplicate-name behaviour from audio-codecs).
Scenario: register installs the codec and returns the instance
- WHEN
register(registry)is called against a freshCodecRegistry - THEN the returned
FfmpegWasmCodecis identity-equal toregistry.get("ffmpeg-wasm")andregistry.detect(wavBytes)returns"ffmpeg-wasm"
Verified by: src/register/register.test.ts::"register installs the codec and returns the instance"
Requirement: Subpath exports per feature module
package.json SHALL declare one exports entry per feature module (./codec/ffmpeg-wasm-codec, ./worker/worker-host, ./core-loader/core-loader, ./decode/decode-pipeline, ./encode/encode-pipeline, ./streaming/streaming-decoder, ./streaming/streaming-encoder, ./register/register, ./error-codes) in addition to the root . barrel, each resolving to a typed import condition. sideEffects SHALL be false. Test files SHALL import runner primitives only from @suaveplan/testing/runner (rule 11) — direct imports from vitest or bun:test are forbidden.
Scenario: each feature module is independently importable
- WHEN a consumer imports
@suaveplan/audio-codec-ffmpeg-wasm/register/register - THEN the import resolves without pulling the decode or encode pipelines, and no wasm has loaded
Scenario: no test file imports vitest or bun:test directly
- WHEN
bun scripts/lint/check-test-runner-imports.tsis run against this package - THEN the gate passes with zero violations
Verified by: tests/exports.test.ts::"every feature module has a subpath export entry" AND tests/exports.test.ts::"no test file imports vitest or bun:test directly"