Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 -->
|
||||
@@ -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/`.
|
||||
Reference in New Issue
Block a user