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

20 KiB

Capability Spec Delta: audio-codec-ffmpeg-native (audio-codec-ffmpeg-native)

Package: @suaveplan/audio-codec-ffmpeg-native Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions.

ADDED Requirements

Requirement: Server-tier purity

The @suaveplan/audio-codec-ffmpeg-native package SHALL be a server-tier package that imports zero browser globals (window, document, navigator, AudioContext, AudioBuffer) and SHALL run only on Node-compatible runtimes (Node ≥ 20, Bun). The package's package.json SHALL NOT declare a "browser" field, SHALL declare "engines": { "node": ">=20" }, and SHALL import node:child_process only inside the runner module — not at any other module's top level. The dependencies SHALL be limited to @suaveplan/audio-codecs, @suaveplan/audio-core, @suaveplan/streams, @suaveplan/error, @suaveplan/types, fluent-ffmpeg, zod, @opentelemetry/api, and tslib; @ffmpeg-installer/ffmpeg SHALL be declared in optionalDependencies.

Scenario: built dist references no browser globals

  • WHEN the built dist/ is grep'd for window, document, navigator, AudioContext, or AudioBuffer
  • THEN it MUST NOT statically reference any of them at module scope

Scenario: child_process is confined to the runner module

  • WHEN the source tree is grep'd for from "node:child_process" or require("child_process")
  • THEN the only match is inside src/runner/runner.ts

Verified by: tests/purity.test.ts::"dist references no browser globals" AND tests/purity.test.ts::"child_process is imported only by the runner module"


Requirement: AudioCodec contract conformance

The package SHALL export FfmpegNativeCodec implementing the AudioCodec interface from @suaveplan/audio-codecs. The codec SHALL expose readonly name === "ffmpeg-native", extensions covering wav, mp3, ogg, opus, flac, aac, m4a, webm, wma, and mimeTypes covering audio/wav, audio/mpeg, audio/ogg, audio/opus, audio/flac, audio/aac, audio/mp4, audio/webm, audio/x-ms-wma. decode(bytes) SHALL return a DecodeResult ({ channelData: Float32Array[]; sampleRate: number; format: string }). encode(pcm, opts) SHALL return a Uint8Array whose bytes are a valid file of the requested format. canDecode(bytes) SHALL delegate magic-byte detection to @suaveplan/audio-metadata and return true iff the detected format is in the supported list.

Scenario: decode returns a well-formed DecodeResult

  • WHEN a caller invokes decode on a valid 16-bit WAV buffer
  • THEN the result has channelData.length === channels, every channel is a Float32Array of length frames, sampleRate is positive, and format === "wav"

Scenario: canDecode rejects non-audio bytes

  • WHEN canDecode is called with 64 bytes of random data whose magic matches no supported format
  • THEN it returns false without spawning any subprocess

Verified by: src/codec/codec.test.ts::"decode returns a well-formed DecodeResult for 16-bit WAV" AND src/codec/codec.test.ts::"canDecode returns false for unrecognised bytes without spawning"


Requirement: Binary discovery and resolution order

The package SHALL resolve the ffmpeg binary path on first use in this exact order: (1) options.binaryProvider?.() if provided, (2) options.binaryPath if provided, (3) the path exposed by dynamically importing @ffmpeg-installer/ffmpeg, (4) throw AudioCodecFfmpegNativeError with code AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING. Resolution SHALL be cached on the codec instance after first success and SHALL never happen at module import time.

Scenario: binaryProvider overrides all other sources

  • WHEN a codec is constructed with both binaryProvider: () => "/usr/local/bin/ffmpeg" and binaryPath: "/opt/ffmpeg" and @ffmpeg-installer/ffmpeg installed
  • THEN the spawned process uses /usr/local/bin/ffmpeg

Scenario: missing binary throws BINARY_MISSING

  • WHEN decode is called on a codec whose binaryProvider returns "", no binaryPath is set, and @ffmpeg-installer/ffmpeg is uninstallable in the test fixture
  • THEN it throws AudioCodecFfmpegNativeError whose code === "AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING"

Verified by: src/binary/binary.test.ts::"binaryProvider takes precedence over binaryPath and installer" AND src/binary/binary.test.ts::"unresolved binary throws AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING"


Requirement: Decode pipeline streams chunk-by-chunk

decode(bytes) SHALL feed the input through ffmpeg's stdin, drain stdout chunk-by-chunk into per-channel Float32Array accumulators, and SHALL NOT call Buffer.concat on the full output payload. Decode of a payload larger than the host's free heap SHALL complete successfully provided the resulting PCM fits — buffering of the encoded input is forbidden. A non-zero ffmpeg exit code SHALL throw AudioCodecFfmpegNativeError with code AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED carrying the captured stderr tail in context.stderr.

Scenario: decode does not buffer full output

  • WHEN the runner module is instrumented to observe Buffer.concat calls and a 50 MiB WAV is decoded
  • THEN zero Buffer.concat calls observe the full output payload — output is appended chunk-by-chunk

Scenario: ffmpeg failure throws DECODE_FAILED with stderr context

  • WHEN decode is called with bytes that ffmpeg rejects (e.g. a truncated MP3 header)
  • THEN it throws AudioCodecFfmpegNativeError whose code === "AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED" with the captured stderr tail in context.stderr

Verified by: src/decode/decode.test.ts::"decode appends chunks without concatenating the full output" AND src/decode/decode.test.ts::"decode throws AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED on non-zero exit"


Requirement: Encode pipeline streams chunk-by-chunk

encode(pcm, opts) SHALL interleave the per-channel Float32Array[] PCM through a fixed-size working buffer (≤ 64 KiB) into ffmpeg's stdin, drain stdout chunk-by-chunk, and emit a Uint8Array whose bytes form a complete file of opts.format. Encode SHALL validate pcm shape (every channel same length, length > 0) and throw AudioCodecFfmpegNativeError with code AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM for malformed input. A non-zero ffmpeg exit code SHALL throw AUDIO_CODEC_FFMPEG_NATIVE.ENCODE_FAILED.

Scenario: encode round-trips through decode

  • WHEN encode is called on the DecodeResult of a 16-bit WAV and the resulting bytes are decoded again
  • THEN the re-decoded channelData matches the original within ±1e-4 per sample

Scenario: ragged PCM throws INVALID_PCM

  • WHEN encode is called with pcm = [Float32Array(100), Float32Array(99)]
  • THEN it throws AudioCodecFfmpegNativeError whose code === "AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM" before any subprocess is spawned

Verified by: src/encode/encode.test.ts::"encode then decode round-trips PCM within tolerance" AND src/encode/encode.test.ts::"encode rejects ragged channel arrays with INVALID_PCM"


Requirement: Streaming decoder factory

createDecoder(opts) SHALL return a StreamingDecoder exposing write(chunk: Uint8Array): Promise<DecodeResult | undefined>, flush(): Promise<DecodeResult | undefined>, and a readable: ReadableStream<AudioPage> accessor. Successive write calls SHALL forward chunks to ffmpeg's stdin and yield AudioPages as stdout produces frames. The decoder SHALL expose a @suaveplan/streams TransformStream<Uint8Array, AudioPage> via a createDecodeTransform() helper so callers can compose it with pipeThrough.

Scenario: streamed decode total equals one-shot decode

  • WHEN a 1 MiB WAV is split into 64 KiB chunks and fed through createDecoder while the same payload is decoded via one-shot decode
  • THEN the concatenated streamed channelData is byte-equal to the one-shot channelData

Scenario: TransformStream composes into a streams pipeline

  • WHEN createDecodeTransform("wav") is piped between a ReadableStream<Uint8Array> source and a WritableStream<AudioPage> sink
  • THEN the pipeline drains without buffering the full source and the sink receives one or more non-empty AudioPage chunks

Verified by: src/stream/stream-decoder.test.ts::"chunked write yields the same channelData as one-shot decode" AND src/stream/stream-decoder.test.ts::"createDecodeTransform composes into a @suaveplan/streams pipeline"


Requirement: Streaming encoder factory

createEncoder(opts) SHALL return a StreamingEncoder exposing write(pcm: Float32Array[]): Promise<Uint8Array | undefined>, flush(): Promise<Uint8Array | undefined>, and a readable: ReadableStream<Uint8Array> accessor. The package SHALL also export createEncodeTransform(opts) returning a @suaveplan/streams TransformStream<Float32Array[], Uint8Array>. Encoding SHALL stream output chunk-by-chunk; the encoder SHALL NOT buffer the full encoded output before yielding the first chunk.

Scenario: streamed encode yields output before flush

  • WHEN a 60-second MP3 encode runs via createEncoder and the first write is followed by 5 seconds of additional write calls before flush
  • THEN at least one non-empty Uint8Array chunk is yielded from the readable before flush resolves

Verified by: src/stream/stream-encoder.test.ts::"streamed encode yields output before flush is called"


Requirement: AbortSignal propagation kills the subprocess

Every public method (decode, encode, createDecoder, createEncoder) SHALL accept an optional signal: AbortSignal. A signal already aborted at call time SHALL short-circuit before any subprocess is spawned and throw AudioCodecFfmpegNativeError with code AUDIO_CODEC_FFMPEG_NATIVE.ABORTED. A signal aborted mid-operation SHALL send SIGTERM to the child, start a 1-second grace timer, send SIGKILL if the child has not exited when the timer fires, release the semaphore slot, and reject with AUDIO_CODEC_FFMPEG_NATIVE.ABORTED carrying the abort reason in context.reason.

Scenario: pre-aborted signal short-circuits before spawn

  • WHEN decode is called with an AbortSignal whose aborted === true
  • THEN it throws AudioCodecFfmpegNativeError whose code === "AUDIO_CODEC_FFMPEG_NATIVE.ABORTED" and no subprocess is spawned

Scenario: mid-decode abort escalates SIGTERM to SIGKILL

  • WHEN a long-running decode is aborted, the child ignores SIGTERM, and 1 second elapses
  • THEN the child receives SIGKILL, the semaphore slot is released, and the call rejects with AUDIO_CODEC_FFMPEG_NATIVE.ABORTED

Verified by: src/abort/abort.test.ts::"pre-aborted signal short-circuits before spawn" AND src/abort/abort.test.ts::"mid-decode abort escalates SIGTERM to SIGKILL after 1s grace"


Requirement: Concurrency limit via in-package semaphore

The codec SHALL enforce a per-instance concurrency limit (default 4, override via options.concurrencyLimit) using an in-package semaphore — no external dependency. Calls that exceed the limit SHALL queue and acquire a slot in FIFO order; slot release SHALL be guaranteed in a finally block so errors and aborts never leak slots. The codec SHALL NOT import p-limit, async-sema, or any other concurrency library.

Scenario: concurrency limit bounds active subprocesses

  • WHEN 10 decode calls are issued in parallel against a codec with concurrencyLimit: 2
  • THEN at any instant the number of live child processes is ≤ 2, and all 10 calls eventually resolve

Scenario: errored call releases its slot

  • WHEN a decode call fails with _DECODE_FAILED while 3 other calls are queued behind the semaphore
  • THEN the next queued call begins within one tick of the failure, proving the slot was released

Verified by: src/semaphore/semaphore.test.ts::"concurrency limit caps live subprocess count" AND src/semaphore/semaphore.test.ts::"failed call releases its semaphore slot"


Requirement: Timeout enforcement

Every subprocess call SHALL be bounded by options.timeoutMs (default 30000). When the timer elapses before the child exits, the runner SHALL send SIGTERM, start a 1-second grace timer, send SIGKILL on grace expiry, and reject with AudioCodecFfmpegNativeError whose code === "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT" carrying timeoutMs in context. Timeouts SHALL release the semaphore slot in the same finally path as aborts.

Scenario: timeout kills the child and rejects with TIMEOUT

  • WHEN a codec is constructed with timeoutMs: 50 and given a synthetic input that causes ffmpeg to hang
  • THEN the call rejects with AudioCodecFfmpegNativeError whose code === "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT" and the child process exits

Verified by: src/runner/runner.test.ts::"timeout kills hung child and rejects with AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT"


Requirement: Every error code is reachable from a call site

The package SHALL export AudioCodecFfmpegNativeErrorCode, AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA, and registerAudioCodecFfmpegNativeErrorCodes(), registering the codes AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING, _SPAWN_FAILED, _DECODE_FAILED, _ENCODE_FAILED, _TIMEOUT, _ABORTED, _UNSUPPORTED_CODEC, and _INVALID_PCM with @suaveplan/error. Every error thrown by the package SHALL be an AudioCodecFfmpegNativeError extending SuaveplanError and carry one of these codes, a cause, and a context. Every registered code SHALL be reachable from a real call site exercised by tests — no code may be declared and unused.

Scenario: every registered code is asserted thrown by tests

  • WHEN the test suite runs
  • THEN each code in AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA is asserted thrown by at least one test, including _SPAWN_FAILED (via a binaryProvider returning a non-executable path) and _UNSUPPORTED_CODEC (via wma encode)

Verified by: src/error-codes.test.ts::"every AUDIO_CODEC_FFMPEG_NATIVE code is reachable from a call site"


Requirement: OpenTelemetry instrumentation

The package SHALL instrument its public entry points via @opentelemetry/api: spans audio-codec-ffmpeg-native.decode and audio-codec-ffmpeg-native.encode carrying format, sampleRate, channels, and byteLength attributes; a counter audio_codec_ffmpeg_native_invocations_total with op and format labels; a histogram audio_codec_ffmpeg_native_duration_ms; and an up/down counter audio_codec_ffmpeg_native_inflight tracking live subprocess count. Instrumentation SHALL be inert when no exporter is registered and SHALL NOT run as an import-time side effect.

Scenario: a decode records span, counter, histogram, and inflight gauge

  • WHEN decode runs against a real in-memory MeterProvider and TracerProvider
  • THEN one audio-codec-ffmpeg-native.decode span is recorded with format attribute, the invocations counter increments by 1, the duration histogram records one observation, and the inflight gauge reaches 1 during the call and returns to 0 on completion

Scenario: import is side-effect-free

  • WHEN the package is imported and never used
  • THEN no metric or span is emitted and no global OTel state is mutated

Verified by: src/otel/otel.test.ts::"decode records span, counter, histogram, and inflight gauge" AND tests/purity.test.ts::"importing the package mutates no global OTel state"


Requirement: Zod boundary validation

The public options objects — FfmpegNativeCodecOptions and EncodeOpts — SHALL have Zod schemas exported from sibling *.schemas.ts files. The codec constructor SHALL validate FfmpegNativeCodecOptions against FfmpegNativeCodecOptionsSchema, and encode / createEncoder SHALL validate EncodeOpts against EncodeOptsSchema. A validation failure SHALL throw AudioCodecFfmpegNativeError with code AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM (for PCM-shape failures) or AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC (for an unknown format) carrying the Zod issue list in context.issues.

Scenario: negative concurrencyLimit is rejected at construction

  • WHEN new FfmpegNativeCodec({ concurrencyLimit: -1 }) is called
  • THEN it throws an error whose cause is a Zod issue listing the failing path

Scenario: unknown format throws UNSUPPORTED_CODEC

  • WHEN encode(pcm, { format: "xyz", sampleRate: 44100 }) is called
  • THEN it throws AudioCodecFfmpegNativeError whose code === "AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC" with the issues in context.issues

Verified by: src/codec/codec.test.ts::"constructor rejects invalid options via Zod" AND src/encode/encode.test.ts::"unknown format throws AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC"


Requirement: Subpath exports per feature module

package.json SHALL declare one exports entry per feature module — ./codec/codec, ./binary/binary, ./runner/runner, ./semaphore/semaphore, ./decode/decode, ./encode/encode, ./stream/stream-decoder, ./stream/stream-encoder, ./abort/abort, ./otel/otel, ./register/register, ./error-codes — in addition to the root . barrel, each resolving to a typed import condition. sideEffects SHALL be false. Feature-module barrels SHALL use explicit named re-exports — no export * (rule 10 / §6a).

Scenario: each feature module is independently importable

  • WHEN a consumer imports @suaveplan/audio-codec-ffmpeg-native/register/register
  • THEN the import resolves to the register module without pulling the runner, semaphore, or OTel modules

Verified by: tests/exports.test.ts::"every feature module has a subpath export entry and is independently importable"


Requirement: register helper installs the codec into a CodecRegistry

The package SHALL export register(registry: CodecRegistry, options?: FfmpegNativeCodecOptions): void, which constructs a single FfmpegNativeCodec instance with the given options and calls registry.register(codec). The helper SHALL be idempotent — calling it twice on the same registry with the same options SHALL leave the registry holding the most-recently-registered instance (per CodecRegistry.register replace semantics). The helper SHALL be the canonical one-line install path documented in the README.

Scenario: register installs the codec under the ffmpeg-native name

  • WHEN register(registry) is called against a fresh CodecRegistry
  • THEN registry.get("ffmpeg-native") returns an FfmpegNativeCodec instance, and registry.detect(wavBytes) returns "ffmpeg-native" when no other codec is registered

Verified by: src/register/register.test.ts::"register installs FfmpegNativeCodec under the ffmpeg-native name"


Requirement: Declared format support matrix

The codec SHALL support the following operations exactly: decode for wav, mp3, ogg, opus, flac, aac, m4a, webm, wma; encode for wav, mp3, ogg, opus, flac, aac, m4a, webm; streaming decode for every decodable format; streaming encode for every encodable format. encode(pcm, { format: "wma" }) SHALL throw AudioCodecFfmpegNativeError with code AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC regardless of binary capability. When the underlying ffmpeg binary lacks a codec listed as supported (e.g. an aac encoder build without libfdk_aac or aac), the runner SHALL surface a AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC error rather than a generic _ENCODE_FAILED.

Scenario: WMA encode is rejected by contract

  • WHEN encode(pcm, { format: "wma", sampleRate: 44100 }) is called
  • THEN it throws AudioCodecFfmpegNativeError whose code === "AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC" and no subprocess is spawned

Scenario: round-trip across declared encodable formats

  • WHEN a 1-second sine-wave Float32Array[] is encoded then decoded for each of wav, mp3, ogg, opus, flac, aac, m4a, webm
  • THEN every round trip succeeds and the decoded PCM length matches the input length within one frame of tolerance

Verified by: src/codec/codec.test.ts::"WMA encode throws AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC without spawning" AND tests/integration.test.ts::"round-trip across every declared encodable format"