Files
2026-05-27 23:29:53 +01:00

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 for node:, process., Buffer., or top-level require(
  • 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 static import … from statement

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 FfmpegWasmCodec is constructed and inspected
  • THEN it has the declared name, non-empty extensions and mimeTypes arrays, and the methods canDecode, decode, encode, createDecoder, createEncoder, dispose are all functions

Scenario: decode return shape matches DecodeResult

  • WHEN decode succeeds against a valid WAV input
  • THEN the result is { channelData: Float32Array[]; sampleRate: number; format: string } with channelData.length === channels and format === "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/core resource has been issued and no wasm module has been instantiated

Scenario: core load failure throws AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED

  • WHEN decode is called with a corePath that resolves to a non-existent URL
  • THEN it throws AudioCodecFfmpegWasmError whose code === "AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED" with the URL in context.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 decode is called on an FfmpegWasmCodec constructed with workerFactory: () => fakeWorker
  • THEN the fake worker receives the decode messages and no real Worker constructor is invoked

Scenario: worker spawn failure throws AUDIO_CODEC_FFMPEG_WASM.WORKER_SPAWN_FAILED

  • WHEN opts.workerFactory returns a worker that immediately emits an error event before load() resolves
  • THEN the first method call throws AudioCodecFfmpegWasmError whose code === "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 decode is called with a 16-bit WAV buffer
  • THEN the result channelData is Float32Array[] with all samples in [-1, 1] and sampleRate equals the source rate

Scenario: ffmpeg failure throws AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED

  • WHEN decode is called with bytes that ffmpeg rejects (e.g. a truncated MP3 header)
  • THEN it throws AudioCodecFfmpegWasmError whose code === "AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED" with the stderr tail in context.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 encode is called with a single-channel sine wave and format: "mp3"
  • THEN the returned bytes start with an MP3 frame sync (0xFFFB or 0xFFFA) 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 encode is called with format: "alac"
  • THEN it throws AudioCodecFfmpegWasmError whose code === "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 calls write(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 calls flush()
  • THEN the resulting DecodeResult.channelData is byte-equal to decode(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 decode is called with an already-aborted signal
  • THEN it throws AudioCodecFfmpegWasmError whose code === "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 with AUDIO_CODEC_FFMPEG_WASM.ABORTED, and the next decode call 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 decode is called with timeoutMs: 10 against a worker that never responds, using an injectable Clock
  • THEN the call throws AudioCodecFfmpegWasmError whose code === "AUDIO_CODEC_FFMPEG_WASM.TIMEOUT" with elapsedMs >= 10 and timeoutMs === 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 AudioCodecFfmpegWasmError whose code === "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 decode is first called on an FfmpegWasmCodec constructed with enableMultithread: true in an environment where globalThis.crossOriginIsolated === false
  • THEN it throws AudioCodecFfmpegWasmError whose code === "AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED" and @ffmpeg/core-mt has not been dynamic-imported

Scenario: missing @ffmpeg/core-mt falls back silently

  • WHEN COI is true, enableMultithread is true, and import("@ffmpeg/core-mt") rejects with a module-not-found error
  • THEN the single-thread @ffmpeg/core loads successfully, a warning is recorded via opts.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 decode runs against a multi-second WAV with progressCallback set
  • THEN the callback is invoked at least once with a number in [0, 1], and the final invocation is 1 (or decode completes without one — both are acceptable)

Scenario: progress callback exception does not abort decode

  • WHEN progressCallback throws on its first invocation
  • THEN the error is logged via opts.logger and decode still resolves with a valid DecodeResult

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 AudioCodecFfmpegWasmError whose code === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC" with Zod issues in context.issues

Scenario: register validates its options argument

  • WHEN register(registry, { enableMultithread: "yes" as unknown as boolean }) is called
  • THEN it throws AudioCodecFfmpegWasmError whose code === "AUDIO_CODEC_FFMPEG_WASM.UNSUPPORTED_CODEC" with Zod issues in context.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 decode runs against a real in-memory MeterProvider and TracerProvider
  • THEN audio-codec-ffmpeg-wasm.decode records one span carrying the format and multithread attributes, audio_codec_ffmpeg_wasm_decode_total increments by 1, and audio_codec_ffmpeg_wasm_decode_duration_ms records 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 fresh CodecRegistry
  • THEN the returned FfmpegWasmCodec is identity-equal to registry.get("ffmpeg-wasm") and registry.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.ts is 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"