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 forwindow,document,navigator,AudioContext, orAudioBuffer - 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"orrequire("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
decodeon a valid 16-bit WAV buffer - THEN the result has
channelData.length === channels, every channel is aFloat32Arrayof lengthframes,sampleRateis positive, andformat === "wav"
Scenario: canDecode rejects non-audio bytes
- WHEN
canDecodeis called with 64 bytes of random data whose magic matches no supported format - THEN it returns
falsewithout 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"andbinaryPath: "/opt/ffmpeg"and@ffmpeg-installer/ffmpeginstalled - THEN the spawned process uses
/usr/local/bin/ffmpeg
Scenario: missing binary throws BINARY_MISSING
- WHEN
decodeis called on a codec whosebinaryProviderreturns"", nobinaryPathis set, and@ffmpeg-installer/ffmpegis uninstallable in the test fixture - THEN it throws
AudioCodecFfmpegNativeErrorwhosecode === "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.concatcalls and a 50 MiB WAV is decoded - THEN zero
Buffer.concatcalls observe the full output payload — output is appended chunk-by-chunk
Scenario: ffmpeg failure throws DECODE_FAILED with stderr context
- WHEN
decodeis called with bytes that ffmpeg rejects (e.g. a truncated MP3 header) - THEN it throws
AudioCodecFfmpegNativeErrorwhosecode === "AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED"with the captured stderr tail incontext.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
encodeis called on theDecodeResultof a 16-bit WAV and the resulting bytes are decoded again - THEN the re-decoded
channelDatamatches the original within ±1e-4 per sample
Scenario: ragged PCM throws INVALID_PCM
- WHEN
encodeis called withpcm = [Float32Array(100), Float32Array(99)] - THEN it throws
AudioCodecFfmpegNativeErrorwhosecode === "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
createDecoderwhile the same payload is decoded via one-shotdecode - THEN the concatenated streamed
channelDatais byte-equal to the one-shotchannelData
Scenario: TransformStream composes into a streams pipeline
- WHEN
createDecodeTransform("wav")is piped between aReadableStream<Uint8Array>source and aWritableStream<AudioPage>sink - THEN the pipeline drains without buffering the full source and the sink receives one or more non-empty
AudioPagechunks
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
createEncoderand the firstwriteis followed by 5 seconds of additionalwritecalls beforeflush - THEN at least one non-empty
Uint8Arraychunk is yielded from the readable beforeflushresolves
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
decodeis called with anAbortSignalwhoseaborted === true - THEN it throws
AudioCodecFfmpegNativeErrorwhosecode === "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 withAUDIO_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
decodecalls are issued in parallel against a codec withconcurrencyLimit: 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
decodecall fails with_DECODE_FAILEDwhile 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: 50and given a synthetic input that causes ffmpeg to hang - THEN the call rejects with
AudioCodecFfmpegNativeErrorwhosecode === "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_METADATAis asserted thrown by at least one test, including_SPAWN_FAILED(via abinaryProviderreturning a non-executable path) and_UNSUPPORTED_CODEC(viawmaencode)
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
decoderuns against a real in-memoryMeterProviderandTracerProvider - THEN one
audio-codec-ffmpeg-native.decodespan is recorded withformatattribute, 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
causeis 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
AudioCodecFfmpegNativeErrorwhosecode === "AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC"with the issues incontext.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 freshCodecRegistry - THEN
registry.get("ffmpeg-native")returns anFfmpegNativeCodecinstance, andregistry.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
AudioCodecFfmpegNativeErrorwhosecode === "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 ofwav,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"