# 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`, `flush(): Promise`, and a `readable: ReadableStream` accessor. Successive `write` calls SHALL forward chunks to ffmpeg's `stdin` and yield `AudioPage`s as `stdout` produces frames. The decoder SHALL expose a `@suaveplan/streams` `TransformStream` 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` source and a `WritableStream` 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`, `flush(): Promise`, and a `readable: ReadableStream` accessor. The package SHALL also export `createEncodeTransform(opts)` returning a `@suaveplan/streams` `TransformStream`. 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"`