Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,214 @@
# Design: enterprise-audio-codec-ffmpeg-native
Non-normative architecture, algorithms, and trade-offs for
`@suaveplan/audio-codec-ffmpeg-native`. Normative requirements live in
`./specs/audio-codec-ffmpeg-native/spec.md`.
---
## 1. Why subprocess, not native bindings
There are three credible ways to wrap ffmpeg in Node:
1. **Subprocess via `fluent-ffmpeg` + a bundled binary** (this package).
2. **Native libav bindings** via `beamcoder` or `node-libav` (linked C addon).
3. **Compile ffmpeg to WASM** via `ffmpeg-wasm` and run in-process.
We picked subprocess for four concrete reasons:
- **Durability across Node versions.** Native addons require N-API rebuilds for every
Node major; a static binary is a self-contained artefact that survives version churn.
- **Licensing posture.** ffmpeg is GPL/LGPL with codec-specific encumbrances. Shipping
a *separate binary* (via `@ffmpeg-installer/ffmpeg`) keeps the LGPL boundary clean —
we do not statically link, we exec. Consumers may swap the binary at runtime for a
build matching their licence needs.
- **Parallel scaling.** The OS scheduler runs N independent `ffmpeg` processes across
cores for free; native bindings serialise on libav's internal threading model and
contend with Node's libuv pool.
- **Failure isolation.** A segfault in ffmpeg crashes a child process, not the host.
The semaphore + abort path turns codec faults into recoverable errors.
The cost is process-spawn latency (~5–20 ms cold) and stdin/stdout serialisation
overhead. For the >2 GB streaming use case the overhead is negligible against the
decode itself; for tiny clips, the WASM-backed sibling codecs in `audio-codecs` are the
right call. The README documents this trade explicitly.
---
## 2. Subprocess lifecycle
Every decode/encode call follows a fixed lifecycle managed by the `runner` module:
1. **Acquire** a semaphore slot (default `concurrencyLimit = 4`).
2. **Resolve** the binary path (see §5).
3. **Spawn** via `fluent-ffmpeg(binaryPath)`. Configure input/output codecs, sample
format (`f32le` for decode output, format-specific for encode input/output), and
the `-hide_banner -loglevel error` flags.
4. **Pipe** the source bytes into ffmpeg's `stdin` as a `Readable` derived from the
input `Uint8Array` / `ReadableStream`.
5. **Drain** `stdout` into a `Writable` that accumulates `Float32Array` frames (decode)
or `Uint8Array` chunks (encode). Output is **chunk-by-chunk**; we never `Buffer.concat`
the full payload.
6. **Capture** `stderr` into a bounded ring buffer (last 64 KiB) used only to enrich
error context on failure; in the happy path it is discarded.
7. **Resolve** on `exit` code 0 + clean stream-end. Reject on non-zero exit
(`_DECODE_FAILED` / `_ENCODE_FAILED`), spawn failure (`_SPAWN_FAILED`), timeout
(`_TIMEOUT`), abort (`_ABORTED`), or unsupported codec mapping (`_UNSUPPORTED_CODEC`).
8. **Release** the semaphore slot in a `finally` block — never leak slots on error.
The runner module is ~150 lines and the only place `child_process` / `fluent-ffmpeg`
is imported. Every other module talks to it through `runDecode(...)` / `runEncode(...)`
function calls. This concentrates the subprocess surface in one well-tested file.
---
## 3. Concurrency limit — in-package semaphore
We do **not** pull in `p-limit` or `async-sema`. The behaviour we need is ~30 lines:
```ts
class Semaphore {
private active = 0;
private readonly queue: Array<() => void> = [];
constructor(private readonly max: number) {}
async acquire(): Promise<void> {
if (this.active < this.max) { this.active++; return; }
await new Promise<void>((resolve) => this.queue.push(resolve));
this.active++;
}
release(): void {
this.active--;
const next = this.queue.shift();
if (next) next();
}
}
```
The semaphore is owned by the `FfmpegNativeCodec` instance — one per codec, shared
across all its decode/encode calls. A fresh codec instance per request would defeat
the limit, so the `register(registry, options)` helper constructs one codec and
registers it. The module-level singleton lives inside the consumer's registry, not
inside this package.
---
## 4. Streaming design
Decode and encode both go chunk-by-chunk; the spec forbids buffering full files.
### Decode pipeline (`decode/decode.ts` and `stream/stream-decoder.ts`)
```text
Uint8Array | ReadableStream<Uint8Array>
→ stdin (ffmpeg child, args: -i pipe:0 -f f32le -acodec pcm_f32le pipe:1)
→ stdout chunks (raw float32 little-endian, interleaved)
→ interleave splitter (chunks → Float32Array[] per channel)
→ DecodeResult { channelData, sampleRate, format }
```
For the streaming variant `createDecoder()` returns a `StreamingDecoder` whose
`write(chunk)` forwards to stdin and whose `flush()` ends stdin and resolves with
the trailing decoded frames. Internally this is a `@suaveplan/streams`
`TransformStream<Uint8Array, AudioPage>`, so consumers compose it into
`pipeThrough` without an adapter.
### Encode pipeline (`encode/encode.ts` and `stream/stream-encoder.ts`)
```text
Float32Array[] (per-channel PCM)
→ interleaver (per-channel → interleaved Float32 buffer)
→ stdin (ffmpeg child, args: -f f32le -ar <sr> -ac <ch> -i pipe:0 -f <fmt> pipe:1)
→ stdout chunks (encoded format bytes)
→ Uint8Array (decode) | TransformStream<Float32Array[], Uint8Array> (streaming)
```
No intermediate `Buffer.concat` on the hot path. The interleaver writes directly into
a fixed-size 64 KiB working buffer and flushes to stdin when full.
---
## 5. Binary discovery order
The `binary/binary.ts` module resolves the binary path in this exact order:
1. `options.binaryProvider?.()` — synchronous test override.
2. `options.binaryPath` — explicit caller-supplied absolute path.
3. `require("@ffmpeg-installer/ffmpeg").path` — bundled static binary
(declared in `optionalDependencies` so installation never fails if the OS lacks
a prebuilt). Loaded via dynamic `import()` inside the resolver, NOT at module top.
4. `throw new AudioCodecFfmpegNativeError("AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING")`.
Resolution is cached on the codec instance after first success. The cache is cleared
on `register()` reconstruction, so tests can rebind a different provider per case.
---
## 6. Format support matrix
| Format | Decode | Encode | Streaming decode | Streaming encode | Notes |
|--------------|:------:|:------:|:----------------:|:----------------:|-------|
| `wav` | ✅ | ✅ | ✅ | ✅ | PCM 8/16/24/32-bit + float |
| `mp3` | ✅ | ✅ | ✅ | ✅ | libmp3lame for encode |
| `ogg` | ✅ | ✅ | ✅ | ✅ | Vorbis in Ogg container |
| `opus` | ✅ | ✅ | ✅ | ✅ | Opus in Ogg or WebM container — declared `opus` accepts either; encode emits Ogg/Opus |
| `flac` | ✅ | ✅ | ✅ | ✅ | FLAC native |
| `aac` / `m4a`| ✅ | ✅ | ✅ | ✅ | aac in MP4/M4A container; encode requires `libfdk_aac` or `aac` codec — runner detects and surfaces `_UNSUPPORTED_CODEC` if neither is in the binary |
| `webm` | ✅ | ✅ | ✅ | ✅ | Matroska/WebM container, Opus or Vorbis audio |
| `wma` | ✅ | 🚫 | ✅ | 🚫 | Decode only — most ffmpeg builds ship `wmav2` decoder but no encoder; encode raises `_UNSUPPORTED_CODEC` |
`canDecode(bytes)` delegates magic-byte detection to `@suaveplan/audio-metadata`'s
detector and answers `true` iff the detected format is in the supported list above.
---
## 7. Trade-offs vs alternatives
- **vs `beamcoder` (libav bindings).** beamcoder gives lower latency and finer-grained
control over individual codec frames, but it ships prebuilt addons that lag Node
releases by months, locks the licence boundary (statically links libav), and
serialises across libav's global state. We accept ~10 ms of spawn latency in
exchange for upgrade safety, licence clarity, and free per-process parallelism.
- **vs `ffmpeg-wasm`.** WASM runs in-process and is cross-platform without a binary
install, but it is 3–10× slower than native ffmpeg and has the same single-threaded
serialisation issue as beamcoder. For the universal/browser tier we already ship
WASM codecs from `@suaveplan/audio-codecs` (Mp3Codec, FlacCodec, …). This package
exists specifically because servers should not pay the WASM tax for batch jobs.
---
## 8. AbortSignal propagation
Every public method accepts a `signal?: AbortSignal`. The `abort/abort.ts` module
wires it into the subprocess:
1. On signal abort, send `SIGTERM` to the child.
2. Start a 1-second grace timer.
3. If the child has not exited when the timer fires, send `SIGKILL`.
4. Reject the call with `_ABORTED` carrying the abort `reason` in `context`.
5. Release the semaphore slot in `finally`.
A signal already aborted when the call begins short-circuits before spawn — no child
process is created. This matches the `audio-codecs` `_STREAM_ERROR` semantics from the
upstream contract.
---
## 9. OpenTelemetry
- Spans: `audio-codec-ffmpeg-native.decode`, `audio-codec-ffmpeg-native.encode`,
carrying `format`, `sampleRate`, `channels`, and `byteLength` as attributes.
- Counter: `audio_codec_ffmpeg_native_invocations_total` with `op=decode|encode` and
`format=<fmt>` labels.
- Histogram: `audio_codec_ffmpeg_native_duration_ms` with the same labels.
- Gauge: `audio_codec_ffmpeg_native_inflight` tracking active subprocess count.
Instrumentation is inert without a registered exporter (no import-time side effects).
---
## 10. File layout
See `proposal.md` § "Module layout". The five-file pattern (`feature.ts`,
`feature.test.ts`, `feature.types.ts`, `feature.md`, `index.ts`) applies to every
feature directory; barrels are explicit named re-exports per §6a.

View File

@@ -0,0 +1,246 @@
# OpenSpec Change: enterprise-audio-codec-ffmpeg-native
- **Change ID**: enterprise-audio-codec-ffmpeg-native
- **Package**: `@suaveplan/audio-codec-ffmpeg-native`
- **Category**: server
- **Target path**: `packages/media/audio/audio-codec-ffmpeg-native/`
- **Status**: PROPOSED
- **Date**: 2026-05-15
- **Design**: ./design.md
- **Spec delta**: ./specs/audio-codec-ffmpeg-native/spec.md
---
## Summary
Deliver `@suaveplan/audio-codec-ffmpeg-native` as a server-tier concrete codec plugin that
implements the `AudioCodec` contract from `@suaveplan/audio-codecs` by shelling out to the
system `ffmpeg` binary via the `fluent-ffmpeg` CLI wrapper. The package bundles a static
binary via `@ffmpeg-installer/ffmpeg` (declared as `optionalDependencies` so consumers may
override) and streams audio through ffmpeg's stdin/stdout to support decode, encode, and
true chunked streaming for wav, mp3, ogg, opus, flac, aac/m4a, webm/matroska, and wma —
without ever buffering the full file in memory. Concurrency is bounded by an in-package
semaphore, every spawn carries an `AbortSignal` that escalates `SIGTERM` → `SIGKILL`, and
the package exposes a one-line `register(registry)` helper plus `@suaveplan/streams`-based
`TransformStream` adapters for pipeline composition.
---
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build. No prior source, no
test results, no coverage data. The package is genesis-original (no upstream parity
checklist required); the `AudioCodec` contract it plugs into is defined by the
in-flight `enterprise-audio-codecs` change.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/audio-codecs` (`workspace:^`) | `AudioCodec`, `CodecRegistry`, `DecodeResult`, `StreamingDecoder`, `StreamingEncoder` contracts |
| `@suaveplan/audio-core` (`workspace:^`) | `AudioPage`, `AudioFormat` aliases |
| `@suaveplan/streams` (`workspace:^`) | `TransformStream`, `ReadableStream`, `WritableStream` adapters with backpressure |
| `@suaveplan/error` (`workspace:^`) | `SuaveplanError` base class, `defineCodes`, structured error registry |
| `@suaveplan/types` (`workspace:^`) | `Clock`, `LoggerLike` injectables |
| `fluent-ffmpeg` (`catalog:audio`) | CLI wrapper used to compose ffmpeg invocations |
| `@ffmpeg-installer/ffmpeg` (`catalog:audio`, `optionalDependencies`) | Bundled static ffmpeg binary, default `binaryPath` |
| `zod` (`catalog:forms`) | Public options validation |
| `@opentelemetry/api` (`catalog:observability`) | Spans, counters, histograms, gauges |
| `tslib` (`catalog:build`) | TS runtime helpers |
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`),
`@suaveplan/testing` (`workspace:^`). Note: catalog entries for `fluent-ffmpeg` and
`@ffmpeg-installer/ffmpeg` must be added to root `package.json` `catalog.audio` if not
already present — this is tracked in `tasks.md` step 1.
---
## Public API
### Core types
```ts
import type {
AudioCodec,
CodecRegistry,
DecodeResult,
EncodeOpts,
StreamingDecoder,
StreamingEncoder,
} from "@suaveplan/audio-codecs";
import type { Clock, LoggerLike } from "@suaveplan/types";
export interface FfmpegNativeCodecOptions {
binaryPath?: string;
binaryProvider?: () => string;
concurrencyLimit?: number;
timeoutMs?: number;
logger?: LoggerLike;
clock?: Clock;
}
export class FfmpegNativeCodec implements AudioCodec {
readonly name: string;
readonly extensions: readonly string[];
readonly mimeTypes: readonly string[];
constructor(options?: FfmpegNativeCodecOptions);
canDecode(bytes: Uint8Array): boolean;
decode(bytes: Uint8Array, opts?: { signal?: AbortSignal }): Promise<DecodeResult>;
encode(
pcm: Float32Array[],
opts: EncodeOpts & { signal?: AbortSignal },
): Promise<Uint8Array>;
createDecoder(opts?: { format: string; signal?: AbortSignal }): StreamingDecoder;
createEncoder(opts: EncodeOpts & { signal?: AbortSignal }): StreamingEncoder;
}
export function register(registry: CodecRegistry, options?: FfmpegNativeCodecOptions): void;
```
### Error codes
```ts
export type AudioCodecFfmpegNativeErrorCode =
| "AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING"
| "AUDIO_CODEC_FFMPEG_NATIVE.SPAWN_FAILED"
| "AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED"
| "AUDIO_CODEC_FFMPEG_NATIVE.ENCODE_FAILED"
| "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT"
| "AUDIO_CODEC_FFMPEG_NATIVE.ABORTED"
| "AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC"
| "AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM";
export class AudioCodecFfmpegNativeError extends SuaveplanError<AudioCodecFfmpegNativeErrorCode> {}
export const AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA: Readonly<
Record<AudioCodecFfmpegNativeErrorCode, CodeMetadata>
>;
export function registerAudioCodecFfmpegNativeErrorCodes(): void;
```
### Zod schemas (sibling `*.schemas.ts`)
```ts
export const FfmpegNativeCodecOptionsSchema: z.ZodType<FfmpegNativeCodecOptions>;
export const EncodeOptsSchema: z.ZodType<EncodeOpts>;
```
---
## Module layout
Five-file pattern per feature module (`feature.ts`, `feature.test.ts`, `feature.types.ts`,
`feature.md`, `index.ts`); barrels use explicit named re-exports (rule 10 / §6a). Every
test file imports runner primitives from `@suaveplan/testing/runner` (rule 11 / §7).
```text
packages/media/audio/audio-codec-ffmpeg-native/
package.json
tsconfig.json
vitest.config.ts
README.md
src/
index.ts
error-codes.ts
error-codes.md
error-codes.test.ts
constants/
index.ts
codec/
codec.ts
codec.md
codec.test.ts
codec.types.ts
codec.schemas.ts
index.ts
binary/
binary.ts
binary.md
binary.test.ts
binary.types.ts
index.ts
runner/
runner.ts
runner.md
runner.test.ts
runner.types.ts
index.ts
semaphore/
semaphore.ts
semaphore.md
semaphore.test.ts
semaphore.types.ts
index.ts
decode/
decode.ts
decode.md
decode.test.ts
decode.types.ts
index.ts
encode/
encode.ts
encode.md
encode.test.ts
encode.types.ts
index.ts
stream/
stream-decoder.ts
stream-decoder.md
stream-decoder.test.ts
stream-decoder.types.ts
stream-encoder.ts
stream-encoder.md
stream-encoder.test.ts
stream-encoder.types.ts
index.ts
abort/
abort.ts
abort.md
abort.test.ts
abort.types.ts
index.ts
otel/
otel.ts
otel.md
otel.test.ts
otel.types.ts
index.ts
register/
register.ts
register.md
register.test.ts
register.types.ts
index.ts
__benchmarks__/
codec.bench.ts
tests/
purity.test.ts
integration.test.ts
exports.test.ts
```
---
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/audio-codec-ffmpeg-native` to enumerate.
Package does not exist yet:
- **No dependents — fresh package.** No downstream consumers exist on genesis at proposal
time; future consumers (`@suaveplan/audio-codecs` default-registry server bundle,
application-side `register(registry, ...)` call sites) will pick this up by direct
workspace dep when published.
Breaking changes: none — fresh build.
Prerequisites: `@suaveplan/audio-codecs`, `@suaveplan/audio-core`, `@suaveplan/streams`,
`@suaveplan/error`, `@suaveplan/types` published (or workspace-resolvable) on genesis;
catalog entries for `fluent-ffmpeg` and `@ffmpeg-installer/ffmpeg` declared in the root
`package.json` `catalog.audio` block.

View File

@@ -0,0 +1,273 @@
<!-- markdownlint-disable MD024 -->
# 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 `AudioPage`s 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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,215 @@
# Tasks: enterprise-audio-codec-ffmpeg-native
Implementation checklist for `@suaveplan/audio-codec-ffmpeg-native`. Tasks are ordered;
each section must be complete before the next begins. Every `### Requirement` and
`#### Scenario` from `specs/audio-codec-ffmpeg-native/spec.md` is enumerated in §0c
working notes and checked off as implementation lands.
---
## 1. Scaffold
- [ ] Create `packages/media/audio/audio-codec-ffmpeg-native/` with `package.json`,
`tsconfig.json`, `vitest.config.ts` (use `createTestConfig()` from
`@suaveplan/testing/config`; `pool: 'forks'`).
- [ ] `package.json` declares `"engines": { "node": ">=20" }`, no `"browser"` field,
`"sideEffects": false`, `"type": "module"`, and `version: "0.1.0"`.
- [ ] `dependencies`: `@suaveplan/audio-codecs workspace:^`,
`@suaveplan/audio-core workspace:^`, `@suaveplan/streams workspace:^`,
`@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`,
`fluent-ffmpeg catalog:audio`, `zod catalog:forms`,
`@opentelemetry/api catalog:observability`, `tslib catalog:build`.
- [ ] `optionalDependencies`: `@ffmpeg-installer/ffmpeg catalog:audio`.
- [ ] `devDependencies`: `@suaveplan/testing workspace:^`, `vitest catalog:testing`,
`@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`,
`mitata catalog:testing`, `@types/fluent-ffmpeg catalog:types`.
- [ ] If `fluent-ffmpeg`, `@ffmpeg-installer/ffmpeg`, or `@types/fluent-ffmpeg` are
not yet in the root `package.json` catalog block, add them under
`catalog.audio` / `catalog.types` and re-run `bun install`.
- [ ] Declare subpath `exports` for every feature module listed in `proposal.md`
"Module layout" plus the root barrel — every entry resolves to a typed
`import` condition under `dist/`.
- [ ] Add per-package `tsconfig.json` extending the shared preset.
## 2. Types + error codes
- [ ] `src/error-codes.ts` — `defineCodes` with the eight
`AUDIO_CODEC_FFMPEG_NATIVE.*` codes; export
`AudioCodecFfmpegNativeError extends SuaveplanError`,
`AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA`,
`registerAudioCodecFfmpegNativeErrorCodes()`.
- [ ] `src/error-codes.test.ts` — assert each code is thrown by at least one call
site (this test cross-references the per-feature tests once they exist).
- [ ] `src/error-codes.md` — Purpose / Features / Basic / Advanced / API / Notes.
- [ ] `src/constants/index.ts` — supported format lists, MIME types, default
timeouts, default concurrency.
## 3. Binary resolver
- [ ] `src/binary/binary.ts` — `resolveBinary(opts): Promise<string>` implementing
the 4-step discovery order; cache result on success.
- [ ] `src/binary/binary.types.ts` — `BinaryResolverOptions`,
`ResolvedBinary` shapes.
- [ ] `src/binary/binary.test.ts` — covers `binaryProvider` precedence, explicit
`binaryPath`, dynamic-import fallback, and `BINARY_MISSING` throw path.
- [ ] `src/binary/binary.md`.
- [ ] `src/binary/index.ts` — explicit named exports.
## 4. Semaphore
- [ ] `src/semaphore/semaphore.ts` — ~30-line FIFO semaphore with `acquire` /
`release`; no external dep.
- [ ] `src/semaphore/semaphore.types.ts`.
- [ ] `src/semaphore/semaphore.test.ts` — concurrency cap, FIFO ordering,
slot-release on error.
- [ ] `src/semaphore/semaphore.md`, `index.ts`.
## 5. Subprocess runner (the only place `node:child_process` / `fluent-ffmpeg` is imported)
- [ ] `src/runner/runner.ts` — `runDecode(opts)` and `runEncode(opts)`. Owns spawn,
stdin pipe, stdout drain (chunk-by-chunk, no full-output `Buffer.concat`),
stderr ring buffer, exit handling, timeout enforcement, semaphore release.
- [ ] `src/runner/runner.types.ts` — `RunDecodeOptions`, `RunEncodeOptions`,
`RunnerCallbacks`, `RunnerHandles`.
- [ ] `src/runner/runner.test.ts` — happy path, non-zero exit → `_DECODE_FAILED`
with stderr context, spawn failure → `_SPAWN_FAILED`, timeout →
`_TIMEOUT` with SIGTERM/SIGKILL escalation observed.
- [ ] `src/runner/runner.md`, `index.ts`.
## 6. Decode pipeline
- [ ] `src/decode/decode.ts` — `decodeOneShot(bytes, opts)` orchestrating
`runDecode` + per-channel `Float32Array` accumulators + interleave splitter.
- [ ] `src/decode/decode.types.ts`.
- [ ] `src/decode/decode.test.ts` — well-formed `DecodeResult`,
chunk-by-chunk assertion (instrument runner to count `Buffer.concat`),
`_DECODE_FAILED` with stderr in context, `canDecode` delegation.
- [ ] `src/decode/decode.md`, `index.ts`.
## 7. Encode pipeline
- [ ] `src/encode/encode.ts` — `encodeOneShot(pcm, opts)` with PCM-shape validation,
interleaver writing into a 64 KiB working buffer, runner invocation.
- [ ] `src/encode/encode.types.ts`.
- [ ] `src/encode/encode.test.ts` — round-trip through decode, `_INVALID_PCM` for
ragged channels, `_UNSUPPORTED_CODEC` for unknown format and for WMA encode.
- [ ] `src/encode/encode.md`, `index.ts`.
## 8. Streaming adapters
- [ ] `src/stream/stream-decoder.ts` — `createDecoder(opts)` returning
`StreamingDecoder`; `createDecodeTransform(opts)` returning a
`@suaveplan/streams` `TransformStream<Uint8Array, AudioPage>`.
- [ ] `src/stream/stream-encoder.ts` — `createEncoder(opts)` returning
`StreamingEncoder`; `createEncodeTransform(opts)` returning a
`TransformStream<Float32Array[], Uint8Array>`.
- [ ] Sibling `.types.ts`, `.test.ts`, `.md` for each; tests cover chunked-write
equivalence, TransformStream composition, encoder yielding before flush.
- [ ] `src/stream/index.ts` — explicit named exports.
## 9. AbortSignal wiring
- [ ] `src/abort/abort.ts` — `attachAbort(child, signal, semaphoreRelease)`:
sends `SIGTERM`, starts 1 s timer, escalates to `SIGKILL`, releases slot,
rejects with `_ABORTED`. Short-circuit path for pre-aborted signals.
- [ ] `src/abort/abort.types.ts`.
- [ ] `src/abort/abort.test.ts` — pre-aborted short-circuit (no spawn observed),
mid-decode SIGTERM → SIGKILL escalation under a child that ignores SIGTERM.
- [ ] `src/abort/abort.md`, `index.ts`.
## 10. OpenTelemetry instrumentation
- [ ] `src/otel/otel.ts` — span / counter / histogram / inflight-gauge helpers;
inert without exporter; no import-time side effects.
- [ ] `src/otel/otel.types.ts`, `otel.test.ts` (in-memory `MeterProvider` +
`TracerProvider`), `otel.md`, `index.ts`.
## 11. Codec class + register helper
- [ ] `src/codec/codec.ts` — `FfmpegNativeCodec` implementing `AudioCodec`. Owns
the per-instance semaphore, the cached binary path, the OTel handles, and
delegates to `decode/`, `encode/`, `stream/` modules.
- [ ] `src/codec/codec.schemas.ts` — `FfmpegNativeCodecOptionsSchema`,
`EncodeOptsSchema` (Zod).
- [ ] `src/codec/codec.types.ts`, `codec.test.ts` (constructor Zod validation,
`canDecode` rejection without spawn, WMA encode short-circuit, full
`DecodeResult` shape), `codec.md`, `index.ts`.
- [ ] `src/register/register.ts` — `register(registry, options?)` idempotent
installer.
- [ ] `src/register/register.test.ts`, `register.types.ts`, `register.md`,
`index.ts`.
## 12. Root barrel
- [ ] `src/index.ts` — explicit named re-exports of every public symbol from each
feature module. No `export *` (rule 10 / §6a).
## 13. Tests — purity, integration, exports
- [ ] `tests/purity.test.ts` — built dist has no browser globals; `child_process`
imported only from `src/runner/runner.ts`; package import mutates no global
OTel state.
- [ ] `tests/integration.test.ts` — full round-trip across every declared
encodable format using the bundled `@ffmpeg-installer/ffmpeg` binary.
- [ ] `tests/exports.test.ts` — every feature module has a working subpath export
entry and is independently importable.
- [ ] Every test file imports runner primitives from
`@suaveplan/testing/runner` (rule 11 / §7).
## 14. Benchmarks
- [ ] `src/__benchmarks__/codec.bench.ts` — mitata suite: decode 10 s WAV,
decode 10 s MP3, encode 10 s WAV→MP3, concurrent-decode at `concurrencyLimit`
= 4. Use `--cpu-prof-md` / `--heap-prof-md` for LLM-assisted analysis.
- [ ] Publish baseline numbers in `README.md`.
## 15. Documentation
- [ ] Every `.ts` (excluding `.test.ts`, `.types.ts`, `.schemas.ts`, barrel
`index.ts`) has a sibling `.md` meeting §4: ≥200 words, Purpose / Features
/ Basic / Advanced / API Reference / Implementation Notes.
- [ ] `README.md` at package root: name, one-paragraph description, install,
quick-start, Modules section linking every `src/<feature>/<feature>.md`,
format support matrix from `design.md`, baseline benchmark numbers.
## 16. Pre-publish gates (§13)
- [ ] Step 0 — Spec reconciliation: walk every requirement and `Verified by` from
the spec delta; confirm every symbol, span, counter, error code, and test
name exists verbatim in `src/`. STOP if any item is unmatched.
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-codec-ffmpeg-native`.
- [ ] `bun run check` (biome).
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-codec-ffmpeg-native`
reports 100 / 100 / 100 / 100 (istanbul).
- [ ] `bunx turbo build --filter=@suaveplan/audio-codec-ffmpeg-native`.
- [ ] Banned-pattern scan: zero hits for `@deprecated`, `TODO`, `FIXME`,
`ts-ignore`, `ts-expect-error`, `.skip`, `.only`, bare `any`, `console.log`.
- [ ] Barrel scan (§13 step 4a): zero `export *` in feature barrels.
- [ ] Test-runner import scan (§13 step 4b): zero direct
`from "vitest"` / `from "bun:test"` imports.
- [ ] Missing-`.md` scan returns zero output.
- [ ] `README.md` present.
- [ ] Bare-version scan returns zero output.
- [ ] Subpath-export count matches feature-module count.
- [ ] Security scan for hardcoded secrets returns zero output.
- [ ] (Not a foundation package — dependent sweep optional; run it anyway since
this is a fresh public surface.)
## 17. Root-doc refresh + markdownlint (§13b)
- [ ] `bun scripts/folder-reorg/generate-manifest.ts` → refresh `MANIFEST.md`.
- [ ] `bun scripts/generate-missing.ts` → refresh `MISSING.yml`.
- [ ] Move the package row from Pending → Completed in `STATUS.md`.
- [ ] Add the per-package paragraph in `SUMMARY.md`.
- [ ] `bunx markdownlint-cli2 --fix`; resolve any remaining violations by hand.
## 18. Fold spec into canonical location
- [ ] Fold
`openspec/changes/enterprise-audio-codec-ffmpeg-native/specs/audio-codec-ffmpeg-native/spec.md`
into `openspec/specs/audio-codec-ffmpeg-native/spec.md` per §14.
## 19. Archive
- [ ] Move `openspec/changes/enterprise-audio-codec-ffmpeg-native/` to
`openspec/archive/2026-05-15-enterprise-audio-codec-ffmpeg-native/`.