first commit
This commit is contained in:
81
docs/media/audio/audio-codec-ffmpeg-native/README.md
Normal file
81
docs/media/audio/audio-codec-ffmpeg-native/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
79
docs/media/audio/audio-codec-ffmpeg-native/src/codec/pcm.md
Normal file
79
docs/media/audio/audio-codec-ffmpeg-native/src/codec/pcm.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user