first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
# @suaveplan/audio-codec-ffmpeg-native - Server-tier ffmpeg-native AudioCodec plugin for @suaveplan/audio-codecs: spawns the system ffmpeg binary via fluent-ffmpeg, streams stdin/stdout for true chunked decode/encode of wav/mp3/ogg/opus/flac/aac/webm, bounded concurrency, AbortSignal cleanup with SIGTERM->SIGKILL escalation, and a one-line register(registry) helper.
Server-tier `AudioCodec` plugin for `@suaveplan/audio-codecs` that shells out
to the system `ffmpeg` binary via `fluent-ffmpeg` and streams audio through
the subprocess' stdin/stdout. Spec ID: `enterprise-audio-codec-ffmpeg-native`.
<!-- begin:auto-docs -->
## Documentation tree
- [binary](./src/binary/binary.md) — This module owns ffmpeg-binary discovery for `@suaveplan/audio-codec-ffmpeg-native`. It implements a fixed four-step pr…
- [binary](./src/binary/binary.md) — This module owns ffmpeg-binary discovery for `@suaveplan/audio-codec-ffmpeg-native`. It implements a fixed four-step pr…
- [codec](./src/codec/codec.md) — `FfmpegNativeCodec` is the server-tier concrete `AudioCodec` implementation that backs `@suaveplan/audio-codec-ffmpeg-n…
- [codec](./src/codec/codec.md) — `FfmpegNativeCodec` is the server-tier concrete `AudioCodec` implementation that backs `@suaveplan/audio-codec-ffmpeg-n…
- [pcm](./src/codec/pcm.md) — PCM helper module for `@suaveplan/audio-codec-ffmpeg-native`. The ffmpeg subprocess speaks `f32le` (planar-interleaved…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, namespaced error-code registry for `@suaveplan/audio-codec-ffmpeg-native`. Every operation…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, namespaced error-code registry for `@suaveplan/audio-codec-ffmpeg-native`. Every operation…
- [register](./src/register/register.md) — `register(registry, options?)` is the one-line entry point applications use to wire `@suaveplan/audio-codec-ffmpeg-nati…
- [register](./src/register/register.md) — `register(registry, options?)` is the one-line entry point applications use to wire `@suaveplan/audio-codec-ffmpeg-nati…
- [runner](./src/runner/runner.md) — This module is the single place in `@suaveplan/audio-codec-ffmpeg-native` that imports `node:child_process`. It owns sp…
- [runner](./src/runner/runner.md) — This module is the single place in `@suaveplan/audio-codec-ffmpeg-native` that imports `node:child_process`. It owns sp…
- [stream-decoder](./src/stream/stream-decoder.md) — `createStreamingDecoder` returns a `FfmpegStreamingDecoder` — the `@suaveplan/audio-codecs` `StreamingDecoder` plus an…
- [stream-encoder](./src/stream/stream-encoder.md) — `createStreamingEncoder` returns a `FfmpegStreamingEncoder` — the `@suaveplan/audio-codecs` `StreamingEncoder` plus an…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audio-codec-ffmpeg-native @suaveplan/audio-codecs
# Optional bundled static binary:
bun add @ffmpeg-installer/ffmpeg
```
## Quickstart
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs";
import { register } from "@suaveplan/audio-codec-ffmpeg-native";
const registry = new CodecRegistry();
register(registry, { concurrencyLimit: 8, timeoutMs: 30_000 });
const codec = registry.byName("ffmpeg-native");
const decoded = await codec.decode(mp3Bytes);
const encoded = await codec.encode(decoded.channelData, {
format: "flac",
sampleRate: decoded.sampleRate,
});
```
## Streaming
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
const decoder = new FfmpegNativeCodec().createDecoder({ format: "ogg" });
for (const chunk of oggChunks) decoder.write(chunk);
const trailing = await decoder.flushAsync(); // additive Promise companion.
decoder.close();
```
`flushAsync()` is an additive method on the streaming decoder/encoder that
the spec asks for, layered over the synchronous base contract from
`@suaveplan/audio-codecs`. See `src/stream/*.md` for details.
## Engines
Node.js 22+. Server-tier only — no browser support, no `worker_threads`
plumbing.
## Error codes
Eight codes namespaced under `AUDIO_CODEC_FFMPEG_NATIVE.*`:
`BINARY_MISSING`, `SPAWN_FAILED`, `DECODE_FAILED`, `ENCODE_FAILED`,
`TIMEOUT`, `ABORTED`, `UNSUPPORTED_CODEC`, `INVALID_PCM`.
## Status
`0.1.0` — first-tier landing. Subpath exports cover every implementation
module so consumers can tree-shake. See the package's OpenSpec change for
the full specification.

View File

@@ -0,0 +1,79 @@
# `binary`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns ffmpeg-binary discovery for `@suaveplan/audio-codec-ffmpeg-native`.
It implements a fixed four-step precedence chain — caller-supplied provider,
explicit absolute path, dynamically imported `@ffmpeg-installer/ffmpeg`
optional dependency, then PATH-based discovery — and memoises successful
resolutions by options-object identity so that high-traffic decode/encode
pipelines pay the resolution cost exactly once per shared options bundle.
The resolver throws `AudioCodecFfmpegNativeError("AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING")`
when every step fails, which lets the codec surface an actionable error to the
caller instead of waiting for a downstream `ENOENT` from `child_process.spawn`.
## Features
- Strict precedence: provider → explicit path → installer → PATH lookup.
- Promise-returning provider supported (sync providers also accepted).
- Dynamic `import("@ffmpeg-installer/ffmpeg")` so the optional dependency is
never a hard requirement at module load.
- Test-injectable `installerLoader` and `pathLookup` hooks decouple resolution
from real filesystem state.
- `BINARY_MISSING` error code on full-chain failure with no leaked stack from
the underlying optional-dep failure.
- WeakMap-keyed memoisation that automatically GCs entries when the caller
drops their options reference.
## Usage: Basic
```ts
import { resolveBinary } from "@suaveplan/audio-codec-ffmpeg-native/binary/binary";
const { path, source } = await resolveBinary();
console.log(`Using ffmpeg at ${path} via ${source}`);
```
## Usage: Advanced
```ts
import { resolveBinary } from "@suaveplan/audio-codec-ffmpeg-native/binary/binary";
const opts = {
binaryProvider: async () => process.env.FFMPEG_PATH ?? "",
binaryPath: "/usr/local/bin/ffmpeg",
installerLoader: async () => ({ path: "/opt/ffmpeg" }),
pathLookup: () => "/usr/bin/ffmpeg",
} as const;
const first = await resolveBinary(opts);
const second = await resolveBinary(opts); // memoised — no re-resolution.
```
## API Reference
- `resolveBinary(opts?): Promise<ResolvedBinary>` — returns `{ path, source }`.
- `BinaryResolverOptions` — `binaryProvider`, `binaryPath`, `installerLoader`,
`pathLookup` (all optional; first match wins per the precedence chain).
- `ResolvedBinary.source` — `"provider" | "explicit" | "installer" | "path"`.
## Implementation Notes
The four-step chain is hard-coded — there is no plugin slot — because deviation
in precedence would silently change which binary actually runs on production
servers. Empty strings are treated as "miss" so callers can write `() => ""`
in a provider to opt into the next strategy without throwing.
The installer is imported via dynamic `import()` because static imports would
make `@ffmpeg-installer/ffmpeg` a hard dependency rather than the optional one
the spec requires. Any failure inside the installer load (missing module,
platform mismatch, exec-bit stripped) is swallowed and the chain advances —
the only surfaced error is `BINARY_MISSING` after the entire chain misses.
Memoisation uses a `WeakMap` keyed by the options object reference; callers
who want forced re-resolution pass a fresh options bundle.

View File

@@ -0,0 +1,96 @@
# `codec`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FfmpegNativeCodec` is the server-tier concrete `AudioCodec` implementation
that backs `@suaveplan/audio-codec-ffmpeg-native`. It satisfies the
`@suaveplan/audio-codecs` plugin contract — `canDecode`, `decode`, `encode`,
`createDecoder`, `createEncoder` — by spawning a child `ffmpeg` process per
operation, piping bytes through stdin and reading streamed PCM out of stdout
without ever buffering the full input.
The class also owns the per-instance concurrency limit (a tiny FIFO
semaphore) so that multi-tenant servers cannot launch unbounded ffmpeg
children, and threads the caller's `AbortSignal` through to the
`runner` module so that cancellation reliably reaps the subprocess.
## Features
- Full `AudioCodec` surface: `canDecode`, `decode`, `encode`, plus the
optional `createDecoder` / `createEncoder` streaming factories.
- Configurable concurrency (`concurrencyLimit`, default 4) enforced through
an in-instance FIFO waiter queue.
- Per-operation `AbortSignal` and per-instance `timeoutMs` plumbed through
the runner; both escalate SIGTERM → SIGKILL.
- Strict PCM validation on encode (`validatePcm` raises `INVALID_PCM`).
- Strict encode-format allowlist (`UNSUPPORTED_CODEC` for anything outside
`wav | mp3 | ogg | opus | flac | aac | m4a | webm | matroska | wma`).
- f32le interleaving on encode + de-interleaving on decode (see `pcm.ts`).
## Usage: Basic
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native/codec/codec";
const codec = new FfmpegNativeCodec();
const decoded = await codec.decode(mp3Bytes);
const wav = await codec.encode(decoded.channelData, {
sampleRate: decoded.sampleRate,
format: "wav",
});
```
## Usage: Advanced
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native/codec/codec";
const codec = new FfmpegNativeCodec({
binaryProvider: () => process.env.FFMPEG_PATH ?? "",
concurrencyLimit: 8,
timeoutMs: 15_000,
channels: 1,
sampleRate: 16_000,
defaultEncodeFormat: "opus",
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
const encoded = await codec.encode([Float32Array.from([0.1, -0.1])], {
signal: controller.signal,
format: "flac",
});
```
## API Reference
- `new FfmpegNativeCodec(options?, spawn?)` — `options` matches
`FfmpegNativeCodecOptions`; the second positional `spawn` argument is the
test-only `Spawn` injector that bypasses `node:child_process.spawn`.
- `name`, `extensions`, `mimeTypes` — read-only metadata for registry
dispatch.
- `decode(bytes, opts?)` / `encode(channelData, opts?)` — full-buffer paths.
- `createDecoder(opts?)` / `createEncoder(opts?)` — streaming-factory
returning the additive companion shapes from `stream/*.ts`.
## Implementation Notes
The semaphore is intentionally inline (one private counter + one waiter
array) rather than a separate module because it never leaves this class —
extracting it would inflate the public surface for zero callers. The same
reasoning applies to the singleton `singleChunk` async generator used by
`encode`: it exists to feed `runEncode`'s `AsyncIterable<Uint8Array>` contract
from a single buffered byte string.
Decode and encode share argv templates (`decodeArgs` / `encodeArgs`) so the
streaming factories can reuse the exact same ffmpeg argv as the full-buffer
paths. This keeps the on-the-wire ffmpeg behaviour stable across the four
public methods.
`canDecode` deliberately leans on the magic-byte sniffer in `pcm.ts` rather
than running ffmpeg in a probe mode; we want `canDecode` to be cheap and
synchronous because the `CodecRegistry` calls it on every dispatch.

View File

@@ -0,0 +1,79 @@
# `pcm`
## Purpose
PCM helper module for `@suaveplan/audio-codec-ffmpeg-native`. The ffmpeg
subprocess speaks `f32le` (planar-interleaved 32-bit little-endian float) on
its stdin and stdout pipes; this module owns the conversion between that wire
shape and the channel-major `Float32Array[]` shape that the
`@suaveplan/audio-codecs` `DecodeResult` and encode input expect.
It also owns the conservative magic-byte sniffer used by `canDecode`, kept
here because both the codec and the streaming decoder lean on it for input
type-checks.
## Features
- `validatePcm(channels)` — enforces non-empty channel list, equal channel
lengths, raises `INVALID_PCM` on every divergence.
- `interleaveF32(channels)` — packs `Float32Array[]` into a single
`Uint8Array` view in f32le interleaved order.
- `deinterleaveF32(bytes, channels)` — inverse: splits an interleaved
`Uint8Array` into `channels` per-channel `Float32Array`s.
- `concat(chunks)` — single-pass `Uint8Array` concatenation used to merge
stdout chunks.
- `sniff(bytes)` — magic-byte format detection for the major container
formats (`wav`, `flac`, `ogg`, `mp3`, `matroska`/webm, `m4a`).
## Usage: Basic
```ts
import {
interleaveF32,
deinterleaveF32,
} from "@suaveplan/audio-codec-ffmpeg-native/codec/pcm";
const bytes = interleaveF32([leftChannel, rightChannel]);
const [left, right] = deinterleaveF32(bytes, 2);
```
## Usage: Advanced
```ts
import {
concat,
sniff,
validatePcm,
} from "@suaveplan/audio-codec-ffmpeg-native/codec/pcm";
const detected = sniff(headerSlice);
if (detected === "flac") console.log("FLAC stream confirmed");
validatePcm(channelData); // throws INVALID_PCM if uneven channels
const merged = concat(stdoutChunks);
```
## API Reference
- `validatePcm(channels: readonly Float32Array[]): void`
- `interleaveF32(channels: readonly Float32Array[]): Uint8Array`
- `deinterleaveF32(bytes: Uint8Array, channels: number): readonly Float32Array[]`
- `concat(chunks: readonly Uint8Array[]): Uint8Array`
- `sniff(bytes: Uint8Array): string | null`
## Implementation Notes
Interleave allocates one `Float32Array(channels * length)` then writes a
column-major copy; the returned `Uint8Array` is a zero-copy view over the
same backing buffer, so handing it directly to `child.stdin.write` avoids a
second allocation.
`deinterleaveF32` uses `bytes.buffer.slice(...)` rather than aliasing the
incoming buffer because callers frequently hand in `Uint8Array` views over a
shared Node `Buffer` pool — aliasing would otherwise yield channels that
silently mutate when the pool recycles its slab.
`sniff` returns `null` (rather than `"unknown"`) so the codec's `canDecode`
can short-circuit on a single nullability check. The mp3 sync pattern
`0xFF 0xEx` also matches AAC ADTS, which is acceptable because ffmpeg accepts
both with the same `pipe:0` decoder argv.

View File

@@ -0,0 +1,90 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable, namespaced error-code registry for
`@suaveplan/audio-codec-ffmpeg-native`. Every operational failure raised by the
package — binary-resolution mishaps, spawn/IO errors, codec-level decode/encode
failures, lifecycle events (timeout, abort) and input-validation rejections —
flows through a single `AudioCodecFfmpegNativeError` typed subclass whose code
field is narrowed to the union of `AUDIO_CODEC_FFMPEG_NATIVE.*` strings.
This centralisation lets consumers do exhaustive `switch (err.code)` dispatch
with TypeScript checking, and it gives observability the canonical label set to
attribute span events and metric counters against without per-call-site
stringly-typed labels.
## Features
- Eight stable codes covering binary, spawn, decode, encode, timeout, abort,
unsupported-codec and invalid-PCM failure modes.
- `defineCodes("AUDIO_CODEC_FFMPEG_NATIVE", [...])` produces frozen
`<namespace>.<reason>` constants and a precise literal-union type.
- `AudioCodecFfmpegNativeError` subclass of `SuaveplanError` preserves
`cause`, `context`, `traceId`, and the inherited timestamp.
- `AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA` records per-code description +
severity for human-readable surfacing (logs, dashboards).
## Usage: Basic
```ts
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AudioCodecFfmpegNativeError,
} from "@suaveplan/audio-codec-ffmpeg-native/error-codes";
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.BINARY_MISSING,
"ffmpeg-native.binary.missing",
);
```
## Usage: Advanced
```ts
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA,
AudioCodecFfmpegNativeError,
} from "@suaveplan/audio-codec-ffmpeg-native/error-codes";
try {
await someFfmpegOperation();
} catch (cause) {
const code = AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED;
throw new AudioCodecFfmpegNativeError(code, "ffmpeg-native.decode.failed", {
cause,
context: {
severity: AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA[code].severity,
stderr: "...last 4 KiB of ffmpeg stderr...",
},
});
}
```
## API Reference
- `AUDIO_CODEC_FFMPEG_NATIVE_CODES` — frozen record of namespaced codes.
- `AudioCodecFfmpegNativeErrorCode` — string-literal union of all codes.
- `AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA` — `Record<Code, { description, severity }>`.
- `AudioCodecFfmpegNativeError` — concrete `SuaveplanError` subclass; constructor
takes `(code, messageKey, options)`; `options.context.stderr` carries the
ffmpeg stderr tail when available.
## Implementation Notes
The eight codes are intentionally minimal: spec-level failure scenarios collapse
into one of these eight. `BINARY_MISSING` is reserved for the resolution
preflight, `SPAWN_FAILED` for `child_process.spawn` errors before any output is
read, and `DECODE_FAILED` / `ENCODE_FAILED` are the post-spawn ffmpeg-exit
failures. `TIMEOUT` and `ABORTED` share the SIGTERM-then-SIGKILL escalation
path; only their code distinguishes the trigger.
`AudioCodecFfmpegNativeError` deliberately mirrors the `AudioCodecsError`
shape (public constructor + `category` literal + `name` override) so that
multi-codec error funnels can discriminate by `category` without
runtime-importing every package.

View File

@@ -0,0 +1,77 @@
# `register`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`register(registry, options?)` is the one-line entry point applications use
to wire `@suaveplan/audio-codec-ffmpeg-native` into a
`@suaveplan/audio-codecs` `CodecRegistry`. It encapsulates construction of a
`FfmpegNativeCodec` with the given options and the structural `register`
call against the registry, returning the constructed codec for callers that
want to retain a direct reference (for later inspection, instrumentation, or
unregistration).
This helper exists so that downstream services do not need to import and
construct `FfmpegNativeCodec` directly; the import shape mirrors every other
codec plugin (`@suaveplan/audio-codecs` siblings, future
`audio-codec-ffmpeg-wasm`) for ergonomic consistency.
## Features
- Single function call, single import.
- Accepts the full `FfmpegNativeCodecOptions` bundle — binary providers,
concurrency, timeout, sample rate, etc.
- Optional `spawn` injection point for tests that want to register a codec
driven by a fake subprocess.
- Returns the constructed `FfmpegNativeCodec` for chained registry hooks.
- Operates against a structural `RegistryLike` view so registries from
different bundle versions remain interoperable.
## Usage: Basic
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs";
import { register } from "@suaveplan/audio-codec-ffmpeg-native/register/register";
const registry = new CodecRegistry();
register(registry);
```
## Usage: Advanced
```ts
import { CodecRegistry } from "@suaveplan/audio-codecs";
import { register } from "@suaveplan/audio-codec-ffmpeg-native/register/register";
const registry = new CodecRegistry();
const codec = register(registry, {
binaryProvider: () => process.env.FFMPEG_PATH ?? "",
concurrencyLimit: 16,
timeoutMs: 30_000,
defaultEncodeFormat: "opus",
});
// Optionally swap the codec out later (multi-tenant test seam):
registry.unregister(codec.name);
```
## API Reference
- `register(registry, options?, spawn?): FfmpegNativeCodec`
- `RegisterOptions` — alias for `FfmpegNativeCodecOptions`.
- `RegistryLike` — `{ register(codec: AudioCodec): void }`.
## Implementation Notes
The function is intentionally a thin wrapper — it does not memoise, throttle,
or de-duplicate calls, because doing so would silently override the
registry's own replace-on-name-collision semantics documented in
`@suaveplan/audio-codecs`. Each `register` call constructs a fresh codec; if
the caller wants a singleton, they keep the returned reference around.
The `spawn` parameter is positional rather than nested inside `options` so
that the public surface in production code remains a clean two-argument
shape, and the test-only seam is opt-in.

View File

@@ -0,0 +1,99 @@
# `runner`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the single place in `@suaveplan/audio-codec-ffmpeg-native` that
imports `node:child_process`. It owns spawning ffmpeg, piping bytes through
stdin, streaming stdout chunks to a callback (never buffering the full
output), enforcing optional timeouts with a SIGTERM-then-SIGKILL escalation,
honouring caller-supplied AbortSignals with the same escalation path, and
mapping all post-spawn failure modes onto the typed
`AudioCodecFfmpegNativeError` codes.
Centralising spawn lifecycle here means decode, encode and the streaming
adapters never re-implement timeout/abort/exit handling — they only describe
the ffmpeg argv they want to run.
## Features
- `runDecode` — feeds a single `Uint8Array` into stdin then ends it.
- `runEncode` — drains an `AsyncIterable<Uint8Array>` into stdin with
backpressure-aware `drain` waits.
- `onStdout(chunk)` callback called per stdout chunk; nothing is buffered
inside the runner.
- Optional `timeoutMs` triggers SIGTERM with a 250 ms grace window before
SIGKILL.
- AbortSignal listeners use `{ once: true }` and are removed on settle.
- Rolling stderr ring buffer (default 4 KiB) preserved on
`error.context.stderr`.
- Injectable `spawn` for tests; defaults to `node:child_process.spawn`.
## Usage: Basic
```ts
import { runDecode } from "@suaveplan/audio-codec-ffmpeg-native/runner/runner";
const chunks: Uint8Array[] = [];
await runDecode({
binaryPath: "/usr/bin/ffmpeg",
args: ["-i", "pipe:0", "-f", "f32le", "-ac", "1", "pipe:1"],
input: compressedBytes,
onStdout: (chunk) => chunks.push(chunk),
});
```
## Usage: Advanced
```ts
import { runEncode } from "@suaveplan/audio-codec-ffmpeg-native/runner/runner";
async function* pcm(): AsyncIterable<Uint8Array> {
yield interleaveFloat32(channelDataA);
yield interleaveFloat32(channelDataB);
}
const out: Uint8Array[] = [];
const controller = new AbortController();
const result = await runEncode({
binaryPath: "/opt/ffmpeg",
args: ["-f", "f32le", "-ar", "48000", "-ac", "2", "-i", "pipe:0", "-f", "flac", "pipe:1"],
inputs: pcm(),
onStdout: (chunk) => out.push(chunk),
signal: controller.signal,
timeoutMs: 30_000,
stderrCap: 8192,
});
console.log("stderr tail:", result.stderr);
```
## API Reference
- `runDecode(opts: RunDecodeOptions): Promise<RunResult>`
- `runEncode(opts: RunEncodeOptions): Promise<RunResult>`
- `RunnerCommon` — `binaryPath`, `args`, `signal?`, `timeoutMs?`, `spawn?`, `stderrCap?`.
- `RunResult` — `{ stderr, exitCode }` (exitCode is always `0` on resolve).
- Error codes raised: `SPAWN_FAILED`, `DECODE_FAILED`, `ENCODE_FAILED`,
`TIMEOUT`, `ABORTED`.
## Implementation Notes
The single shared `settle` guard guarantees the returned promise resolves or
rejects exactly once even when stdin pump, child `error` and `close` events
all fire in close succession. `child.on("error", ...)` is the spawn-time
failure path (ENOENT/EACCES); `child.on("close", ...)` is the post-exit
discriminator that funnels exit codes into `DECODE_FAILED` / `ENCODE_FAILED`
depending on which entry point caller used.
Timeout and abort both go through the same `killEscalate` helper. The 250 ms
grace window is deliberately short — ffmpeg honours SIGTERM cleanly in the
common case, and on misbehaving builds SIGKILL guarantees process death
without leaking PIDs. `timeout?.unref?.()` keeps the Node event loop from
being kept alive by the timer.
The stderr ring buffer keeps the **tail** of stderr because ffmpeg emits its
informative diagnostics (codec banner, then per-frame progress) at the
**start** of a successful run but the **end** of a failing run.

View File

@@ -0,0 +1,80 @@
# `stream-decoder`
## Purpose
`createStreamingDecoder` returns a `FfmpegStreamingDecoder` — the
`@suaveplan/audio-codecs` `StreamingDecoder` plus an additive Promise-returning
`flushAsync()` companion. It is backed by a single long-running ffmpeg child
process per decoder instance, lazily spawned on the first `write` so that a
decoder constructed but never fed never spends a PID.
`write` and `flush` honour the underlying interface's synchronous contract by
returning whatever pages have already been buffered out of stdout; `flushAsync`
is the safe end-of-stream method that ends stdin, awaits the child to exit,
and yields any trailing pages.
## Features
- Single persistent ffmpeg subprocess per decoder.
- Lazy spawn — the child is only created once the first chunk arrives.
- Sync `write(chunk): DecodeResult[]` returning all pages buffered to date.
- Sync `flush(): DecodeResult[]` for callers that pump synchronously.
- Additive `flushAsync(): Promise<DecodeResult[]>` for safe end-of-stream
semantics (mandatory whenever the caller wants the trailing decoded frames).
- `AbortSignal` and `timeoutMs` integrated — both raise the canonical
`ABORTED` / `TIMEOUT` codes on the next `flushAsync` awaited.
## Usage: Basic
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
const decoder = new FfmpegNativeCodec().createDecoder({ format: "ogg" });
for (const chunk of oggChunks) decoder.write(chunk);
const pages = await decoder.flushAsync();
decoder.close();
```
## Usage: Advanced
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
const codec = new FfmpegNativeCodec({ channels: 2, sampleRate: 48_000 });
const controller = new AbortController();
const decoder = codec.createDecoder({ signal: controller.signal });
try {
for await (const chunk of network()) decoder.write(chunk);
const tail = await decoder.flushAsync();
process.stdout.write(`decoded ${tail.length} trailing pages\n`);
} finally {
decoder.close();
}
```
## API Reference
- `createStreamingDecoder(opts: StreamingDecoderOptions): FfmpegStreamingDecoder`
- `FfmpegStreamingDecoder` — extends `StreamingDecoder` with
`flushAsync(): Promise<readonly DecodeResult[]>`.
- `StreamingDecoderOptions` — `binaryProvider`, `args`, `channels`,
`sampleRate`, `format`, optional `signal`, `timeoutMs`, `spawn`.
## Implementation Notes
The `@suaveplan/audio-codecs` `StreamingDecoder` contract is synchronous —
`write(chunk): readonly DecodeResult[]`. Since the underlying ffmpeg
subprocess is fundamentally asynchronous, the synchronous `write` returns
only the pages already buffered from earlier `stdout data` events; the
new chunk is piped into stdin in the same call but its decoded output
arrives in subsequent ticks.
This is why `flushAsync` exists and why the package docs guide callers to
prefer it. The base `flush` is still useful for callers driving the decoder
from an event loop tick that already drained stdout (e.g. a `WritableStream`
pipe).
Frame alignment matters: ffmpeg's `-f f32le` output is a stream of
4-byte-per-sample, channels-interleaved frames. The sync drain only
de-interleaves a whole number of frames and re-buffers any trailing bytes
for the next call, so partial chunks never corrupt the channel split.

View File

@@ -0,0 +1,75 @@
# `stream-encoder`
## Purpose
`createStreamingEncoder` returns a `FfmpegStreamingEncoder` — the
`@suaveplan/audio-codecs` `StreamingEncoder` plus an additive Promise-returning
`flushAsync()` companion. It backs the encoder with a single long-running
ffmpeg subprocess that consumes interleaved f32le PCM on stdin and emits the
selected container format on stdout, lazily spawned on the first `start()` so
that an encoder constructed but never used pays no process cost.
## Features
- Single persistent ffmpeg subprocess per encoder.
- Lazy spawn on first `start()`; channel count chosen by the caller.
- Strict `validatePcm` on every `write` raises `INVALID_PCM` on bad input.
- Sync `start` / `write` / `flush` honour the base contract; additive
`flushAsync(): Promise<Uint8Array>` waits for ffmpeg to drain.
- `AbortSignal` and `timeoutMs` integrated — both raise canonical
`ABORTED` / `TIMEOUT` codes on the next `flushAsync`.
## Usage: Basic
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
const encoder = new FfmpegNativeCodec().createEncoder({
sampleRate: 48_000,
format: "flac",
});
encoder.start(48_000, channelData.length);
encoder.write(channelData);
const tail = await encoder.flushAsync();
process.stdout.write(tail);
encoder.close();
```
## Usage: Advanced
```ts
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
const codec = new FfmpegNativeCodec({ timeoutMs: 10_000 });
const encoder = codec.createEncoder({ format: "opus" });
encoder.start(48_000, 2);
for (const block of pcmBlocks()) encoder.write(block);
const finalBytes = await encoder.flushAsync();
encoder.close();
```
## API Reference
- `createStreamingEncoder(opts: StreamingEncoderOptions): FfmpegStreamingEncoder`
- `FfmpegStreamingEncoder` — extends `StreamingEncoder` with
`flushAsync(): Promise<Uint8Array>`.
- `StreamingEncoderOptions` — `binaryProvider`, `argsFor(channels)`,
optional `signal`, `timeoutMs`, `spawn`.
## Implementation Notes
The base `StreamingEncoder.start()` is documented as returning header bytes;
ffmpeg emits headers on stdout as part of the normal encode flow, so we
return an empty `Uint8Array` from `start()` and let the headers fall out of
the first `write` / `flushAsync` call. This is consistent with how all the
real-world container formats (FLAC, Opus, MP3) actually framing their headers
on the same byte stream as their data.
`write` returns synchronously with whatever bytes ffmpeg has already pushed
out of stdout. For most encoders the first synchronous return after a single
`write` is empty because ffmpeg buffers frames; callers MUST await
`flushAsync` to retrieve trailing bytes.
`chosenChannels` is captured in `start()` (or, defensively, the length of
the first `write` call's `channelData`) because the encode argv embeds the
`-ac <n>` flag — once ffmpeg is running, the channel count cannot change.