Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,110 @@
{
"name": "@suaveplan/audio-codec-ffmpeg-native",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"engines": {
"node": ">=22"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./binary/binary": {
"import": {
"types": "./dist/binary/binary.d.ts",
"default": "./dist/binary/binary.js"
}
},
"./runner/runner": {
"import": {
"types": "./dist/runner/runner.d.ts",
"default": "./dist/runner/runner.js"
}
},
"./codec/codec": {
"import": {
"types": "./dist/codec/codec.d.ts",
"default": "./dist/codec/codec.js"
}
},
"./stream/stream-decoder": {
"import": {
"types": "./dist/stream/stream-decoder.d.ts",
"default": "./dist/stream/stream-decoder.js"
}
},
"./stream/stream-encoder": {
"import": {
"types": "./dist/stream/stream-encoder.d.ts",
"default": "./dist/stream/stream-encoder.js"
}
},
"./register/register": {
"import": {
"types": "./dist/register/register.d.ts",
"default": "./dist/register/register.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/audio-codecs": "workspace:^",
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"fluent-ffmpeg": "catalog:audio",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"optionalDependencies": {
"@ffmpeg-installer/ffmpeg": "catalog:audio"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/fluent-ffmpeg": "catalog:audio",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,157 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AudioCodecFfmpegNativeError } from "../error-codes/error-codes.js";
import { extractInstallerPath, resolveBinary } from "./binary.js";
describe("binary/resolveBinary", () => {
it("uses binaryProvider when it returns non-empty", {
timeout: 5000,
}, async () => {
const res = await resolveBinary({
binaryProvider: () => "/from/provider",
installerLoader: async () => ({ path: "/from/installer" }),
pathLookup: () => "/from/path",
});
expect(res).toEqual({ path: "/from/provider", source: "provider" });
});
it("supports async binaryProvider", { timeout: 5000 }, async () => {
const res = await resolveBinary({
binaryProvider: async () => "/async/provider",
installerLoader: async () => null,
});
expect(res).toEqual({ path: "/async/provider", source: "provider" });
});
it("falls through provider empty string to explicit binaryPath", {
timeout: 5000,
}, async () => {
const res = await resolveBinary({
binaryProvider: () => "",
binaryPath: "/explicit",
installerLoader: async () => null,
});
expect(res).toEqual({ path: "/explicit", source: "explicit" });
});
it("uses installer when provider+explicit absent", {
timeout: 5000,
}, async () => {
const res = await resolveBinary({
installerLoader: async () => ({ path: "/installer" }),
});
expect(res).toEqual({ path: "/installer", source: "installer" });
});
it("falls through installer throw to pathLookup", {
timeout: 5000,
}, async () => {
const res = await resolveBinary({
installerLoader: async () => {
throw new Error("missing");
},
pathLookup: () => "/from/path",
});
expect(res).toEqual({ path: "/from/path", source: "path" });
});
it("falls through installer null to pathLookup", {
timeout: 5000,
}, async () => {
const res = await resolveBinary({
installerLoader: async () => null,
pathLookup: () => "/from/path",
});
expect(res).toEqual({ path: "/from/path", source: "path" });
});
it("falls through installer empty path to pathLookup", {
timeout: 5000,
}, async () => {
const res = await resolveBinary({
installerLoader: async () => ({ path: "" }),
pathLookup: () => "/from/path",
});
expect(res).toEqual({ path: "/from/path", source: "path" });
});
it("falls through empty pathLookup to BINARY_MISSING", {
timeout: 5000,
}, async () => {
await expect(
resolveBinary({
installerLoader: async () => null,
pathLookup: () => undefined,
})
).rejects.toBeInstanceOf(AudioCodecFfmpegNativeError);
});
it("throws BINARY_MISSING when every step misses", {
timeout: 5000,
}, async () => {
await expect(
resolveBinary({
installerLoader: async () => null,
})
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING",
});
});
it("memoises by options reference", { timeout: 5000 }, async () => {
let calls = 0;
const opts = {
binaryProvider: () => {
calls += 1;
return "/cached";
},
};
await resolveBinary(opts);
await resolveBinary(opts);
await resolveBinary(opts);
expect(calls).toBe(1);
});
it("default installer loader resolves real @ffmpeg-installer/ffmpeg", {
timeout: 5000,
}, async () => {
// Real @ffmpeg-installer/ffmpeg IS installed via catalog:audio in
// this monorepo, so the default loader returns its bundled path.
const res = await resolveBinary({});
expect(res.source === "installer" || res.source === "path").toBe(true);
expect(res.path.length).toBeGreaterThan(0);
});
it("resolveBinary supports no-argument invocation", {
timeout: 5000,
}, async () => {
// Covers the `opts: BinaryResolverOptions = {}` default-argument
// branch — without it we fall back to the default installer.
const res = await resolveBinary();
expect(res.path.length).toBeGreaterThan(0);
});
});
describe("binary/extractInstallerPath", () => {
it("returns { path } from default-exported shape", {
timeout: 5000,
}, () => {
expect(extractInstallerPath({ default: { path: "/d" } })).toEqual({
path: "/d",
});
});
it("falls back to named `path` when default has no `path`", {
timeout: 5000,
}, () => {
expect(extractInstallerPath({ path: "/named" })).toEqual({
path: "/named",
});
});
it("returns null when neither default nor named path is present", {
timeout: 5000,
}, () => {
expect(extractInstallerPath({})).toBeNull();
expect(extractInstallerPath({ default: {} })).toBeNull();
});
});

View File

@@ -0,0 +1,95 @@
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AudioCodecFfmpegNativeError,
} from "../error-codes/error-codes.js";
import type { BinaryResolverOptions, ResolvedBinary } from "./binary.types.js";
const cache = new WeakMap<BinaryResolverOptions, ResolvedBinary>();
/**
* Resolve the ffmpeg binary path using the 4-step precedence order documented
* on `BinaryResolverOptions`. Throws `BINARY_MISSING` if every strategy fails.
*
* The result is memoised per `BinaryResolverOptions` reference — pass the same
* options object to benefit from caching across many decode/encode calls.
*/
export async function resolveBinary(
opts: BinaryResolverOptions = {}
): Promise<ResolvedBinary> {
const hit = cache.get(opts);
if (hit !== undefined) return hit;
const resolved = await tryResolve(opts);
if (resolved === null) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.BINARY_MISSING,
"ffmpeg-native.binary.missing",
{}
);
}
cache.set(opts, resolved);
return resolved;
}
async function tryResolve(
opts: BinaryResolverOptions
): Promise<ResolvedBinary | null> {
if (opts.binaryProvider !== undefined) {
const path = await Promise.resolve(opts.binaryProvider());
if (isNonEmpty(path)) return { path, source: "provider" };
}
if (isNonEmpty(opts.binaryPath)) {
return { path: opts.binaryPath, source: "explicit" };
}
const installerPath = await tryInstaller(opts.installerLoader);
if (installerPath !== null) {
return { path: installerPath, source: "installer" };
}
if (opts.pathLookup !== undefined) {
const path = opts.pathLookup();
if (isNonEmpty(path)) return { path, source: "path" };
}
return null;
}
function isNonEmpty(value: string | undefined): value is string {
return typeof value === "string" && value.length > 0;
}
async function tryInstaller(
loader: BinaryResolverOptions["installerLoader"]
): Promise<string | null> {
const load = loader ?? defaultInstallerLoader;
try {
const mod = await load();
if (mod !== null && isNonEmpty(mod.path)) return mod.path;
} catch {
// Optional dependency not installed; fall through.
}
return null;
}
async function defaultInstallerLoader(): Promise<{
readonly path: string;
} | null> {
const mod = (await import("@ffmpeg-installer/ffmpeg")) as {
readonly default?: { readonly path?: string };
readonly path?: string;
};
return extractInstallerPath(mod);
}
/**
* Extract a usable `path` from a `@ffmpeg-installer/ffmpeg` module export
* shape. Handles both default-exported and named-exported `path` strings and
* returns `null` when neither is present. Exposed for unit-test coverage of
* the empty-export branch which production traffic cannot reach (the installed
* module always exports a path on supported platforms).
*/
export function extractInstallerPath(mod: {
readonly default?: { readonly path?: string };
readonly path?: string;
}): { readonly path: string } | null {
const path = mod.default?.path ?? mod.path;
return path !== undefined ? { path } : null;
}

View File

@@ -0,0 +1,30 @@
/**
* Options accepted by `resolveBinary`. Resolution order is:
*
* 1. `binaryProvider()` — caller-supplied factory (highest precedence).
* 2. `binaryPath` — explicit absolute path.
* 3. Dynamic `@ffmpeg-installer/ffmpeg` import.
* 4. PATH-based discovery via `node:child_process` `which`/`where`.
*
* The first non-empty result that is also `executable` wins.
*/
export interface BinaryResolverOptions {
readonly binaryPath?: string;
readonly binaryProvider?: () => string | Promise<string>;
/**
* Injected to make the PATH fallback testable. Defaults to spawning
* `which ffmpeg` (POSIX) — left undefined disables PATH discovery, which
* is the safest server-tier default.
*/
readonly pathLookup?: () => string | undefined;
/**
* Injected installer loader. Defaults to dynamic import of the optional
* `@ffmpeg-installer/ffmpeg` dependency.
*/
readonly installerLoader?: () => Promise<{ readonly path: string } | null>;
}
export interface ResolvedBinary {
readonly path: string;
readonly source: "provider" | "explicit" | "installer" | "path";
}

View File

@@ -0,0 +1,2 @@
export { resolveBinary } from "./binary.js";
export type { BinaryResolverOptions, ResolvedBinary } from "./binary.types.js";

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,311 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeFakeSpawn } from "../../tests/fake-spawn.js";
import { AudioCodecFfmpegNativeError } from "../error-codes/error-codes.js";
import { FfmpegNativeCodec } from "./codec.js";
import { interleaveF32 } from "./pcm.js";
const pcmBytes = interleaveF32([
Float32Array.from([0.1, 0.2]),
Float32Array.from([-0.1, -0.2]),
]);
describe("FfmpegNativeCodec/metadata", () => {
it("exposes name + extensions + mimeTypes", { timeout: 5000 }, () => {
const c = new FfmpegNativeCodec({ name: "custom" });
expect(c.name).toBe("custom");
expect(c.extensions.length).toBeGreaterThan(0);
expect(c.mimeTypes.length).toBe(c.extensions.length);
});
it("defaults name to ffmpeg-native", { timeout: 5000 }, () => {
expect(new FfmpegNativeCodec().name).toBe("ffmpeg-native");
});
});
describe("FfmpegNativeCodec/canDecode", () => {
it("returns true for sniffable formats", { timeout: 5000 }, () => {
const c = new FfmpegNativeCodec();
expect(c.canDecode(new Uint8Array([0x52, 0x49, 0x46, 0x46]))).toBe(
true
);
});
it("returns false for unknown bytes", { timeout: 5000 }, () => {
expect(
new FfmpegNativeCodec().canDecode(new Uint8Array([1, 2, 3, 4]))
).toBe(false);
});
});
describe("FfmpegNativeCodec/decode", () => {
it("decodes via fake spawn and returns DecodeResult", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ stdout: [pcmBytes], exitCode: 0 });
const c = new FfmpegNativeCodec(
{ binaryPath: "fake", channels: 2, sampleRate: 16_000 },
spawn
);
const result = await c.decode(new Uint8Array([0x52, 0x49, 0x46, 0x46]));
expect(result.format).toBe("ffmpeg-native");
expect(result.sampleRate).toBe(16_000);
expect(result.channelData.length).toBe(2);
});
it("rejects empty input with DECODE_FAILED", {
timeout: 5000,
}, async () => {
const c = new FfmpegNativeCodec({ binaryPath: "fake" });
await expect(c.decode(new Uint8Array([]))).rejects.toBeInstanceOf(
AudioCodecFfmpegNativeError
);
});
it("raises DECODE_FAILED on empty stdout", { timeout: 5000 }, async () => {
const { spawn } = makeFakeSpawn({ stdout: [], exitCode: 0 });
const c = new FfmpegNativeCodec({ binaryPath: "fake" }, spawn);
await expect(
c.decode(new Uint8Array([1, 2, 3, 4]))
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED",
});
});
it("threads abort signal through to runner", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ hold: true });
const c = new FfmpegNativeCodec({ binaryPath: "fake" }, spawn);
const ac = new AbortController();
const pending = c.decode(new Uint8Array([0x52, 0x49, 0x46, 0x46]), {
signal: ac.signal,
});
setTimeout(() => ac.abort(), 10);
await expect(pending).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.ABORTED",
});
});
it("enforces timeoutMs", { timeout: 5000 }, async () => {
const { spawn } = makeFakeSpawn({ hold: true });
const c = new FfmpegNativeCodec(
{ binaryPath: "fake", timeoutMs: 30 },
spawn
);
await expect(
c.decode(new Uint8Array([0x52, 0x49, 0x46, 0x46]))
).rejects.toMatchObject({ code: "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT" });
});
});
describe("FfmpegNativeCodec/encode", () => {
it("encodes via fake spawn (echo) and returns Uint8Array", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ echo: true });
const c = new FfmpegNativeCodec({ binaryPath: "fake" }, spawn);
const out = await c.encode(
[Float32Array.from([0.5, -0.5]), Float32Array.from([0.25, -0.25])],
{ format: "wav", sampleRate: 8000 }
);
expect(out.byteLength).toBe(4 * 2 * 2);
});
it("validates PCM and raises INVALID_PCM", { timeout: 5000 }, async () => {
const c = new FfmpegNativeCodec({ binaryPath: "fake" });
await expect(c.encode([])).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.INVALID_PCM",
});
});
it("rejects unsupported encode format", { timeout: 5000 }, async () => {
const c = new FfmpegNativeCodec({ binaryPath: "fake" });
await expect(
c.encode([Float32Array.from([0])], {
format: "ape" as unknown as "wav",
})
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.UNSUPPORTED_CODEC",
});
});
it("raises ENCODE_FAILED on empty stdout", { timeout: 5000 }, async () => {
const { spawn } = makeFakeSpawn({ stdout: [], exitCode: 0 });
const c = new FfmpegNativeCodec({ binaryPath: "fake" }, spawn);
await expect(
c.encode([Float32Array.from([0.1, 0.2])])
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.ENCODE_FAILED",
});
});
it("uses defaultEncodeFormat when none requested", {
timeout: 5000,
}, async () => {
const { spawn, last } = makeFakeSpawn({ echo: true });
const c = new FfmpegNativeCodec(
{ binaryPath: "fake", defaultEncodeFormat: "flac" },
spawn
);
await c.encode([Float32Array.from([0.0, 0.0])]);
expect(last()).toBeDefined();
});
});
describe("FfmpegNativeCodec/streaming factories", () => {
it("createDecoder returns FfmpegStreamingDecoder driven by codec config", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ stdout: [pcmBytes], exitCode: 0 });
const c = new FfmpegNativeCodec(
{
binaryPath: "fake",
channels: 2,
sampleRate: 16_000,
timeoutMs: 1000,
},
spawn
);
const ac = new AbortController();
const dec = c.createDecoder({ signal: ac.signal });
dec.write(new Uint8Array([1]));
const tail = await dec.flushAsync();
expect(tail[0]!.format).toBe("ffmpeg-native");
dec.close();
});
it("createEncoder returns FfmpegStreamingEncoder driven by codec config", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ echo: true });
const c = new FfmpegNativeCodec(
{ binaryPath: "fake", sampleRate: 8000, timeoutMs: 1000 },
spawn
);
const ac = new AbortController();
const enc = c.createEncoder({
format: "wav",
sampleRate: 16_000,
signal: ac.signal,
});
enc.start(16_000, 2);
enc.write([
Float32Array.from([0.1, 0.2]),
Float32Array.from([-0.1, -0.2]),
]);
const out = await enc.flushAsync();
expect(out.byteLength).toBeGreaterThan(0);
enc.close();
});
it("createEncoder rejects unsupported format at construction", {
timeout: 5000,
}, () => {
const c = new FfmpegNativeCodec({ binaryPath: "fake" });
expect(() =>
c.createEncoder({ format: "ape" as unknown as "wav" })
).toThrow();
});
});
describe("FfmpegNativeCodec/default-spawn paths", () => {
// These tests exercise the `this.spawnImpl === undefined` spread-branch
// (and the corresponding signal/timeout combinations on encode) by
// running real ffmpeg via `node:child_process.spawn` end-to-end. The
// bundled `@ffmpeg-installer/ffmpeg` provides a static binary the runner
// can drive deterministically.
const samples = new Float32Array(2048);
for (let i = 0; i < samples.length; i += 1) {
samples[i] = Math.sin((i / samples.length) * Math.PI * 8) * 0.25;
}
const monoPcm: readonly Float32Array[] = [samples];
it("encode without injected spawnImpl uses node:child_process.spawn", {
timeout: 30_000,
}, async () => {
const c = new FfmpegNativeCodec({});
const out = await c.encode(monoPcm, {
format: "wav",
sampleRate: 16_000,
});
expect(out.byteLength).toBeGreaterThan(0);
});
it("decode without injected spawnImpl uses node:child_process.spawn", {
timeout: 30_000,
}, async () => {
const c = new FfmpegNativeCodec({ channels: 1, sampleRate: 16_000 });
const wav = await c.encode(monoPcm, {
format: "wav",
sampleRate: 16_000,
});
const res = await c.decode(wav);
expect(res.channelData.length).toBe(1);
expect(res.sampleRate).toBe(16_000);
});
it("encode threads signal + timeoutMs through to runner", {
timeout: 30_000,
}, async () => {
const { spawn } = makeFakeSpawn({ echo: true });
const c = new FfmpegNativeCodec(
{ binaryPath: "fake", timeoutMs: 1000 },
spawn
);
const ac = new AbortController();
const out = await c.encode([Float32Array.from([0.1, 0.2])], {
signal: ac.signal,
});
expect(out.byteLength).toBeGreaterThan(0);
});
it("createDecoder applies channels/sampleRate defaults when omitted", {
timeout: 5000,
}, async () => {
// Codec has no channels/sampleRate set, so createDecoder() must fall
// back to its `?? 2` / `?? 48_000` defaults (covers branches at
// line 154-155).
const { spawn } = makeFakeSpawn({ stdout: [], exitCode: 0 });
const c = new FfmpegNativeCodec({ binaryPath: "fake" }, spawn);
const dec = c.createDecoder();
const tail = await dec.flushAsync();
expect(tail).toBeDefined();
dec.close();
});
it("createDecoder + createEncoder support empty opts on bare codec", {
timeout: 30_000,
}, async () => {
// Bare codec: no spawnImpl, no timeoutMs. The createDecoder() /
// createEncoder() default-arg `{}` is the target, plus the three
// spread `else` branches for missing signal/timeoutMs/spawnImpl.
const c = new FfmpegNativeCodec({ channels: 1, sampleRate: 16_000 });
const enc = c.createEncoder();
enc.start(16_000, 1);
enc.write(monoPcm);
const wav = await enc.flushAsync();
expect(wav.byteLength).toBeGreaterThan(0);
enc.close();
const dec = c.createDecoder();
dec.write(wav);
const tail = await dec.flushAsync();
expect(tail.length).toBeGreaterThan(0);
dec.close();
});
});
describe("FfmpegNativeCodec/concurrency", () => {
it("queues calls beyond concurrencyLimit", { timeout: 5000 }, async () => {
const { spawn } = makeFakeSpawn({ stdout: [pcmBytes], exitCode: 0 });
const c = new FfmpegNativeCodec(
{ binaryPath: "fake", channels: 2, concurrencyLimit: 1 },
spawn
);
const a = c.decode(new Uint8Array([0x52, 0x49, 0x46, 0x46]));
const b = c.decode(new Uint8Array([0x52, 0x49, 0x46, 0x46]));
const [ra, rb] = await Promise.all([a, b]);
expect(ra.channelData.length).toBe(2);
expect(rb.channelData.length).toBe(2);
});
});

View File

@@ -0,0 +1,268 @@
import type {
AudioCodec,
DecodeOpts,
DecodeResult,
EncodeOpts,
StreamingDecoder,
StreamingEncoder,
} from "@suaveplan/audio-codecs";
import { resolveBinary } from "../binary/binary.js";
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AudioCodecFfmpegNativeError,
} from "../error-codes/error-codes.js";
import { runDecode, runEncode } from "../runner/runner.js";
import type { Spawn } from "../runner/runner.types.js";
import {
createStreamingDecoder,
type FfmpegStreamingDecoder,
} from "../stream/stream-decoder.js";
import { createStreamingEncoder } from "../stream/stream-encoder.js";
import {
ENCODE_MIME_TYPES,
type FfmpegNativeCodecOptions,
SUPPORTED_ENCODE_FORMATS,
type SupportedEncodeFormat,
} from "./codec.types.js";
import {
concat,
deinterleaveF32,
interleaveF32,
sniff,
validatePcm,
} from "./pcm.js";
const DEFAULT_CONCURRENCY = 4;
/** Concrete `AudioCodec` powered by a child `ffmpeg` process per call. */
export class FfmpegNativeCodec implements AudioCodec {
public readonly name: string;
public readonly extensions: readonly string[];
public readonly mimeTypes: readonly string[];
private readonly options: FfmpegNativeCodecOptions;
private readonly slots: number;
private active = 0;
private readonly waiters: Array<() => void> = [];
private readonly spawnImpl?: Spawn;
public constructor(
options: FfmpegNativeCodecOptions = {},
spawnImpl?: Spawn
) {
this.options = options;
this.name = options.name ?? "ffmpeg-native";
this.extensions = Object.freeze([...SUPPORTED_ENCODE_FORMATS]);
this.mimeTypes = Object.freeze(
SUPPORTED_ENCODE_FORMATS.map((f) => ENCODE_MIME_TYPES[f])
);
this.slots = options.concurrencyLimit ?? DEFAULT_CONCURRENCY;
if (spawnImpl !== undefined) this.spawnImpl = spawnImpl;
}
public canDecode(bytes: Uint8Array): boolean {
return sniff(bytes) !== null;
}
public async decode(
bytes: Uint8Array,
opts: DecodeOpts = {}
): Promise<DecodeResult> {
if (bytes.byteLength === 0) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED,
"ffmpeg-native.decode.empty-input",
{}
);
}
const channels = this.options.channels ?? 2;
const sampleRate = this.options.sampleRate ?? 48_000;
const binary = await resolveBinary(this.options);
const collected: Uint8Array[] = [];
await this.withSlot(async () => {
await runDecode({
binaryPath: binary.path,
args: this.decodeArgs(channels, sampleRate),
input: bytes,
onStdout: (chunk) => collected.push(chunk),
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
...(this.options.timeoutMs !== undefined
? { timeoutMs: this.options.timeoutMs }
: {}),
...(this.spawnImpl !== undefined
? { spawn: this.spawnImpl }
: {}),
});
});
const interleaved = concat(collected);
if (interleaved.byteLength === 0) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED,
"ffmpeg-native.decode.empty-output",
{}
);
}
const channelData = deinterleaveF32(interleaved, channels);
return { channelData, sampleRate, format: this.name };
}
public async encode(
channelData: readonly Float32Array[],
opts: EncodeOpts = {}
): Promise<Uint8Array> {
validatePcm(channelData);
const sampleRate = opts.sampleRate ?? this.options.sampleRate ?? 48_000;
const channels = channelData.length;
const format = pickEncodeFormat(
(opts as { readonly format?: string }).format,
this.options.defaultEncodeFormat
);
const binary = await resolveBinary(this.options);
const interleaved = interleaveF32(channelData);
const collected: Uint8Array[] = [];
await this.withSlot(async () => {
await runEncode({
binaryPath: binary.path,
args: this.encodeArgs(format, sampleRate, channels),
inputs: singleChunk(interleaved),
onStdout: (chunk) => collected.push(chunk),
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
...(this.options.timeoutMs !== undefined
? { timeoutMs: this.options.timeoutMs }
: {}),
...(this.spawnImpl !== undefined
? { spawn: this.spawnImpl }
: {}),
});
});
const out = concat(collected);
if (out.byteLength === 0) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.ENCODE_FAILED,
"ffmpeg-native.encode.empty-output",
{}
);
}
return out;
}
public createDecoder(
opts: DecodeOpts = {}
): StreamingDecoder & FfmpegStreamingDecoder {
const channels = this.options.channels ?? 2;
const sampleRate = this.options.sampleRate ?? 48_000;
return createStreamingDecoder({
binaryProvider: () =>
resolveBinary(this.options).then((b) => b.path),
args: this.decodeArgs(channels, sampleRate),
channels,
sampleRate,
format: this.name,
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
...(this.options.timeoutMs !== undefined
? { timeoutMs: this.options.timeoutMs }
: {}),
...(this.spawnImpl !== undefined ? { spawn: this.spawnImpl } : {}),
});
}
public createEncoder(opts: EncodeOpts = {}): StreamingEncoder {
const sampleRate = opts.sampleRate ?? this.options.sampleRate ?? 48_000;
const format = pickEncodeFormat(
(opts as { readonly format?: string }).format,
this.options.defaultEncodeFormat
);
return createStreamingEncoder({
binaryProvider: () =>
resolveBinary(this.options).then((b) => b.path),
argsFor: (channels: number) =>
this.encodeArgs(format, sampleRate, channels),
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
...(this.options.timeoutMs !== undefined
? { timeoutMs: this.options.timeoutMs }
: {}),
...(this.spawnImpl !== undefined ? { spawn: this.spawnImpl } : {}),
});
}
private decodeArgs(
channels: number,
sampleRate: number
): readonly string[] {
return [
"-hide_banner",
"-loglevel",
"error",
"-i",
"pipe:0",
"-f",
"f32le",
"-ac",
String(channels),
"-ar",
String(sampleRate),
"pipe:1",
];
}
private encodeArgs(
format: SupportedEncodeFormat,
sampleRate: number,
channels: number
): readonly string[] {
return [
"-hide_banner",
"-loglevel",
"error",
"-f",
"f32le",
"-ar",
String(sampleRate),
"-ac",
String(channels),
"-i",
"pipe:0",
"-f",
format,
"pipe:1",
];
}
private async withSlot<T>(fn: () => Promise<T>): Promise<T> {
if (this.active >= this.slots) {
await new Promise<void>((resolve) => this.waiters.push(resolve));
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
const next = this.waiters.shift();
if (next !== undefined) next();
}
}
}
function pickEncodeFormat(
requested: string | undefined,
fallback: string | undefined
): SupportedEncodeFormat {
const candidate = requested ?? fallback ?? "wav";
if (
(SUPPORTED_ENCODE_FORMATS as readonly string[]).includes(candidate) ===
false
) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.UNSUPPORTED_CODEC,
"ffmpeg-native.encode.unsupported",
{ context: { requested: candidate } }
);
}
return candidate as SupportedEncodeFormat;
}
async function* singleChunk(buf: Uint8Array): AsyncIterable<Uint8Array> {
yield buf;
}

View File

@@ -0,0 +1,75 @@
import type { LoggerLike } from "@suaveplan/types";
/** Public construction options for `FfmpegNativeCodec`. */
export interface FfmpegNativeCodecOptions {
/**
* Codec identity. Defaults to `"ffmpeg-native"`. Used by the codec name,
* the registry dispatch key, and OTel attribute attribution.
*/
readonly name?: string;
/**
* Explicit ffmpeg binary path. Skipped when `binaryProvider` resolves.
*/
readonly binaryPath?: string;
/**
* Provider returning the binary path. Wins over `binaryPath`.
*/
readonly binaryProvider?: () => string | Promise<string>;
/**
* Maximum concurrent ffmpeg processes. Defaults to `4`.
*/
readonly concurrencyLimit?: number;
/**
* Per-operation timeout (ms). Omit to disable.
*/
readonly timeoutMs?: number;
/**
* Optional logger. Defaults to a no-op sink.
*/
readonly logger?: LoggerLike;
/**
* Decoded sample rate request. Defaults to the source rate (uses
* ffmpeg's `-ar` only when explicitly set).
*/
readonly sampleRate?: number;
/**
* Explicit decode-time channel count. Defaults to source channels.
*/
readonly channels?: number;
/**
* Default output format used by encode when `opts.format` is omitted.
*/
readonly defaultEncodeFormat?: string;
}
/** Bounded set of formats this codec accepts on encode. */
export const SUPPORTED_ENCODE_FORMATS = Object.freeze([
"wav",
"mp3",
"ogg",
"opus",
"flac",
"aac",
"m4a",
"webm",
"matroska",
"wma",
] as const);
export type SupportedEncodeFormat = (typeof SUPPORTED_ENCODE_FORMATS)[number];
/** Map ffmpeg encode formats to their canonical MIME types. */
export const ENCODE_MIME_TYPES: Readonly<
Record<SupportedEncodeFormat, string>
> = Object.freeze({
wav: "audio/wav",
mp3: "audio/mpeg",
ogg: "audio/ogg",
opus: "audio/opus",
flac: "audio/flac",
aac: "audio/aac",
m4a: "audio/mp4",
webm: "audio/webm",
matroska: "audio/x-matroska",
wma: "audio/x-ms-wma",
});

View File

@@ -0,0 +1,16 @@
export { FfmpegNativeCodec } from "./codec.js";
export type {
FfmpegNativeCodecOptions,
SupportedEncodeFormat,
} from "./codec.types.js";
export {
ENCODE_MIME_TYPES,
SUPPORTED_ENCODE_FORMATS,
} from "./codec.types.js";
export {
concat,
deinterleaveF32,
interleaveF32,
sniff,
validatePcm,
} from "./pcm.js";

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,113 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AudioCodecFfmpegNativeError } from "../error-codes/error-codes.js";
import {
concat,
deinterleaveF32,
interleaveF32,
sniff,
validatePcm,
} from "./pcm.js";
describe("pcm/validatePcm", () => {
it("rejects empty channel list", { timeout: 5000 }, () => {
expect(() => validatePcm([])).toThrow(AudioCodecFfmpegNativeError);
});
it("rejects zero-length first channel", { timeout: 5000 }, () => {
expect(() => validatePcm([new Float32Array(0)])).toThrow(
AudioCodecFfmpegNativeError
);
});
it("rejects mismatched channel lengths", { timeout: 5000 }, () => {
expect(() =>
validatePcm([new Float32Array(4), new Float32Array(5)])
).toThrow(AudioCodecFfmpegNativeError);
});
it("accepts valid equal-length channels", { timeout: 5000 }, () => {
validatePcm([new Float32Array(8), new Float32Array(8)]);
});
});
describe("pcm/interleave + deinterleave roundtrip", () => {
it("packs two channels of three samples each", { timeout: 5000 }, () => {
const a = Float32Array.from([0.1, 0.2, 0.3]);
const b = Float32Array.from([-0.1, -0.2, -0.3]);
const bytes = interleaveF32([a, b]);
expect(bytes.byteLength).toBe(2 * 3 * 4);
const out = deinterleaveF32(bytes, 2);
for (let i = 0; i < 3; i += 1) {
expect(out[0]![i]).toBeCloseTo([0.1, 0.2, 0.3][i]!, 5);
expect(out[1]![i]).toBeCloseTo([-0.1, -0.2, -0.3][i]!, 5);
}
});
it("handles empty channel array via interleave with len=0", {
timeout: 5000,
}, () => {
const bytes = interleaveF32([]);
expect(bytes.byteLength).toBe(0);
});
});
describe("pcm/concat", () => {
it("merges chunk list", { timeout: 5000 }, () => {
const a = new Uint8Array([1, 2]);
const b = new Uint8Array([3, 4, 5]);
const out = concat([a, b]);
expect(Array.from(out)).toEqual([1, 2, 3, 4, 5]);
});
it("merges empty list to empty", { timeout: 5000 }, () => {
expect(concat([]).byteLength).toBe(0);
});
});
describe("pcm/sniff", () => {
const cases: Array<{
name: string;
bytes: Uint8Array;
expect: string | null;
}> = [
{ name: "too short", bytes: new Uint8Array([0]), expect: null },
{
name: "RIFF",
bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]),
expect: "wav",
},
{
name: "fLaC",
bytes: new Uint8Array([0x66, 0x4c, 0x61, 0x43]),
expect: "flac",
},
{
name: "OggS",
bytes: new Uint8Array([0x4f, 0x67, 0x67, 0x53]),
expect: "ogg",
},
{
name: "EBML",
bytes: new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]),
expect: "matroska",
},
{
name: "ID3",
bytes: new Uint8Array([0x49, 0x44, 0x33, 0]),
expect: "mp3",
},
{
name: "MPEG sync",
bytes: new Uint8Array([0xff, 0xfb, 0, 0]),
expect: "mp3",
},
{
name: "ftyp",
bytes: new Uint8Array([
0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0, 0, 0, 0,
]),
expect: "m4a",
},
{ name: "unknown", bytes: new Uint8Array([1, 2, 3, 4]), expect: null },
];
for (const c of cases) {
it(`detects ${c.name}`, { timeout: 5000 }, () => {
expect(sniff(c.bytes)).toBe(c.expect);
});
}
});

View File

@@ -0,0 +1,116 @@
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AudioCodecFfmpegNativeError,
} from "../error-codes/error-codes.js";
/** Validate that channelData has at least one non-empty channel of equal length. */
export function validatePcm(channelData: readonly Float32Array[]): void {
if (channelData.length === 0) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.INVALID_PCM,
"ffmpeg-native.pcm.empty",
{}
);
}
const expected = (channelData[0] as Float32Array).length;
if (expected === 0) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.INVALID_PCM,
"ffmpeg-native.pcm.empty-channel",
{}
);
}
for (const ch of channelData) {
if (ch.length !== expected) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.INVALID_PCM,
"ffmpeg-native.pcm.mismatched-lengths",
{ context: { expected, actual: ch.length } }
);
}
}
}
/** Interleave per-channel Float32 buffers into a packed f32le byte view. */
export function interleaveF32(channels: readonly Float32Array[]): Uint8Array {
const ch = channels.length;
if (ch === 0) {
return new Uint8Array(0);
}
const len = (channels[0] as Float32Array).length;
const out = new Float32Array(ch * len);
for (let i = 0; i < len; i += 1) {
for (let c = 0; c < ch; c += 1) {
out[i * ch + c] = (channels[c] as Float32Array)[i] as number;
}
}
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
}
/** Split a packed f32le byte view into N per-channel Float32 buffers. */
export function deinterleaveF32(
bytes: Uint8Array,
channels: number
): readonly Float32Array[] {
const sample = new Float32Array(
bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
)
);
const total = sample.length;
const perChannel = Math.floor(total / channels);
const out: Float32Array[] = [];
for (let c = 0; c < channels; c += 1) {
const buf = new Float32Array(perChannel);
for (let i = 0; i < perChannel; i += 1) {
buf[i] = sample[i * channels + c] as number;
}
out.push(buf);
}
return out;
}
/** Concatenate a sequence of byte chunks into a single Uint8Array. */
export function concat(chunks: readonly Uint8Array[]): Uint8Array {
let total = 0;
for (const c of chunks) total += c.byteLength;
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.byteLength;
}
return out;
}
/**
* Conservative magic-byte sniffer covering wav/RIFF, flac, ogg, mp3 ID3+sync,
* Matroska/webm, and M4A/MP4 ftyp. Returns the detected format name or `null`
* when no signature matches.
*/
export function sniff(bytes: Uint8Array): string | null {
if (bytes.byteLength < 4) return null;
const b0 = bytes[0] as number;
const b1 = bytes[1] as number;
const b2 = bytes[2] as number;
const b3 = bytes[3] as number;
if (b0 === 0x52 && b1 === 0x49 && b2 === 0x46 && b3 === 0x46) return "wav";
if (b0 === 0x66 && b1 === 0x4c && b2 === 0x61 && b3 === 0x43) return "flac";
if (b0 === 0x4f && b1 === 0x67 && b2 === 0x67 && b3 === 0x53) return "ogg";
if (b0 === 0x1a && b1 === 0x45 && b2 === 0xdf && b3 === 0xa3) {
return "matroska";
}
if (b0 === 0x49 && b1 === 0x44 && b2 === 0x33) return "mp3";
if (b0 === 0xff && (b1 & 0xe0) === 0xe0) return "mp3";
if (
bytes.byteLength >= 12 &&
bytes[4] === 0x66 &&
bytes[5] === 0x74 &&
bytes[6] === 0x79 &&
bytes[7] === 0x70
) {
return "m4a";
}
return null;
}

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,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA,
AudioCodecFfmpegNativeError,
} from "./error-codes.js";
describe("error-codes", () => {
it("exposes eight namespaced codes", { timeout: 5000 }, () => {
const keys = Object.keys(AUDIO_CODEC_FFMPEG_NATIVE_CODES);
expect(keys.length).toBe(8);
for (const k of keys) {
const v = (
AUDIO_CODEC_FFMPEG_NATIVE_CODES as Record<string, string>
)[k] as string;
expect(v.startsWith("AUDIO_CODEC_FFMPEG_NATIVE.")).toBe(true);
}
});
it("metadata covers every code", { timeout: 5000 }, () => {
for (const code of Object.values(AUDIO_CODEC_FFMPEG_NATIVE_CODES)) {
const meta = AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA[code];
expect(typeof meta.description).toBe("string");
expect(
meta.severity === "error" || meta.severity === "warning"
).toBe(true);
}
});
it("subclass preserves code, name, category, cause", {
timeout: 5000,
}, () => {
const cause = new Error("upstream");
const err = new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED,
"ffmpeg-native.test.key",
{ cause, context: { x: 1 } }
);
expect(err.code).toBe(AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED);
expect(err.name).toBe("AudioCodecFfmpegNativeError");
expect(err.category).toBe("audio-codec-ffmpeg-native");
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ x: 1 });
});
it("subclass accepts default options", { timeout: 5000 }, () => {
const err = new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.ABORTED,
"ffmpeg-native.test.key"
);
expect(err.code).toBe(AUDIO_CODEC_FFMPEG_NATIVE_CODES.ABORTED);
expect(err.context).toBeUndefined();
});
});

View File

@@ -0,0 +1,92 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/audio-codec-ffmpeg-native`.
*/
export const AUDIO_CODEC_FFMPEG_NATIVE_CODES = defineCodes(
"AUDIO_CODEC_FFMPEG_NATIVE",
[
"BINARY_MISSING",
"SPAWN_FAILED",
"DECODE_FAILED",
"ENCODE_FAILED",
"TIMEOUT",
"ABORTED",
"UNSUPPORTED_CODEC",
"INVALID_PCM",
] as const
);
export type AudioCodecFfmpegNativeErrorCode =
(typeof AUDIO_CODEC_FFMPEG_NATIVE_CODES)[keyof typeof AUDIO_CODEC_FFMPEG_NATIVE_CODES];
export interface AudioCodecFfmpegNativeErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA: Readonly<
Record<AudioCodecFfmpegNativeErrorCode, AudioCodecFfmpegNativeErrorMetadata>
> = Object.freeze({
[AUDIO_CODEC_FFMPEG_NATIVE_CODES.BINARY_MISSING]: {
description:
"No ffmpeg binary could be resolved: explicit binaryPath did not exist, binaryProvider threw, the @ffmpeg-installer/ffmpeg optional dependency was not installed, and PATH-based discovery found no `ffmpeg`.",
severity: "error",
},
[AUDIO_CODEC_FFMPEG_NATIVE_CODES.SPAWN_FAILED]: {
description:
"child_process.spawn failed before ffmpeg started — usually EACCES, ENOENT against the resolved binary, or a fluent-ffmpeg `error` event fired before `start`.",
severity: "error",
},
[AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED]: {
description:
"ffmpeg exited with a non-zero status while decoding, or produced no PCM output; stderr tail is preserved on `error.context.stderr`.",
severity: "error",
},
[AUDIO_CODEC_FFMPEG_NATIVE_CODES.ENCODE_FAILED]: {
description:
"ffmpeg exited with a non-zero status while encoding, or produced no encoded bytes; stderr tail is preserved on `error.context.stderr`.",
severity: "error",
},
[AUDIO_CODEC_FFMPEG_NATIVE_CODES.TIMEOUT]: {
description:
"ffmpeg ran longer than `timeoutMs`; the process was killed via SIGTERM with a 250 ms grace window followed by SIGKILL.",
severity: "error",
},
[AUDIO_CODEC_FFMPEG_NATIVE_CODES.ABORTED]: {
description:
"The caller's AbortSignal fired before ffmpeg completed; the process was killed via SIGTERM with a 250 ms grace window followed by SIGKILL.",
severity: "warning",
},
[AUDIO_CODEC_FFMPEG_NATIVE_CODES.UNSUPPORTED_CODEC]: {
description:
"Requested encode/decode format is not in the supported list (wav/mp3/ogg/opus/flac/aac/m4a/webm/matroska/wma).",
severity: "error",
},
[AUDIO_CODEC_FFMPEG_NATIVE_CODES.INVALID_PCM]: {
description:
"PCM input failed validation: empty channelData, mismatched channel lengths, non-finite samples, or non-positive sampleRate.",
severity: "error",
},
});
/**
* Concrete typed error thrown by this package. Every throw site MUST pass
* one of the eight `AUDIO_CODEC_FFMPEG_NATIVE.*` codes.
*/
export class AudioCodecFfmpegNativeError extends SuaveplanError<AudioCodecFfmpegNativeErrorCode> {
public readonly category = "audio-codec-ffmpeg-native" as const;
public constructor(
code: AudioCodecFfmpegNativeErrorCode,
messageKey: string,
options: SuaveplanErrorOptions = {}
) {
super(code, messageKey, options);
this.name = "AudioCodecFfmpegNativeError";
}
}

View File

@@ -0,0 +1,9 @@
export type {
AudioCodecFfmpegNativeErrorCode,
AudioCodecFfmpegNativeErrorMetadata,
} from "./error-codes.js";
export {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA,
AudioCodecFfmpegNativeError,
} from "./error-codes.js";

View File

@@ -0,0 +1,51 @@
export { resolveBinary } from "./binary/binary.js";
export type {
BinaryResolverOptions,
ResolvedBinary,
} from "./binary/binary.types.js";
export { FfmpegNativeCodec } from "./codec/codec.js";
export type {
FfmpegNativeCodecOptions,
SupportedEncodeFormat,
} from "./codec/codec.types.js";
export {
ENCODE_MIME_TYPES,
SUPPORTED_ENCODE_FORMATS,
} from "./codec/codec.types.js";
export {
concat,
deinterleaveF32,
interleaveF32,
sniff,
validatePcm,
} from "./codec/pcm.js";
export {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA,
AudioCodecFfmpegNativeError,
type AudioCodecFfmpegNativeErrorCode,
type AudioCodecFfmpegNativeErrorMetadata,
} from "./error-codes/index.js";
export { register } from "./register/register.js";
export type {
RegisterOptions,
RegistryLike,
} from "./register/register.types.js";
export { runDecode, runEncode } from "./runner/runner.js";
export type {
RunDecodeOptions,
RunEncodeOptions,
RunnerCommon,
RunResult,
Spawn,
} from "./runner/runner.types.js";
export {
createStreamingDecoder,
type FfmpegStreamingDecoder,
} from "./stream/stream-decoder.js";
export type { StreamingDecoderOptions } from "./stream/stream-decoder.types.js";
export {
createStreamingEncoder,
type FfmpegStreamingEncoder,
} from "./stream/stream-encoder.js";
export type { StreamingEncoderOptions } from "./stream/stream-encoder.types.js";

View File

@@ -0,0 +1,2 @@
export { register } from "./register.js";
export type { RegisterOptions, RegistryLike } from "./register.types.js";

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,56 @@
import type { AudioCodec } from "@suaveplan/audio-codecs";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeFakeSpawn } from "../../tests/fake-spawn.js";
import { register } from "./register.js";
describe("register", () => {
it("constructs a codec and calls registry.register", {
timeout: 5000,
}, () => {
const seen: AudioCodec[] = [];
const registry = { register: (c: AudioCodec) => seen.push(c) };
const codec = register(registry, { binaryPath: "/fake" });
expect(seen.length).toBe(1);
expect(seen[0]).toBe(codec);
});
it("uses default options when omitted", { timeout: 5000 }, () => {
const seen: AudioCodec[] = [];
const registry = { register: (c: AudioCodec) => seen.push(c) };
const codec = register(registry);
expect(codec.name).toBe("ffmpeg-native");
});
it("threads options through to the FfmpegNativeCodec", {
timeout: 5000,
}, () => {
const seen: AudioCodec[] = [];
const registry = { register: (c: AudioCodec) => seen.push(c) };
const codec = register(registry, {
name: "myff",
concurrencyLimit: 2,
});
expect(codec.name).toBe("myff");
});
it("threads spawn through so codec decode works against fake", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({
stdout: [new Uint8Array(8)], // 1 stereo frame of zeros
exitCode: 0,
});
const registered: AudioCodec[] = [];
const codec = register(
{
register: (c: AudioCodec) => registered.push(c),
},
{ binaryPath: "/fake", channels: 2, sampleRate: 8000 },
spawn
);
const result = await codec.decode(
new Uint8Array([0x52, 0x49, 0x46, 0x46])
);
expect(result.channelData.length).toBe(2);
});
});

View File

@@ -0,0 +1,21 @@
import { FfmpegNativeCodec } from "../codec/codec.js";
import type { Spawn } from "../runner/runner.types.js";
import type { RegisterOptions, RegistryLike } from "./register.types.js";
/**
* Register a freshly constructed `FfmpegNativeCodec` into a
* `CodecRegistry`-shaped object. The second positional `spawn` argument is
* the test-only injection seam that bypasses `node:child_process.spawn`.
*
* Returns the constructed codec so callers can pin a reference if they want
* to drive it directly later (mirrors `@suaveplan/audio-codecs` convention).
*/
export function register(
registry: RegistryLike,
options: RegisterOptions = {},
spawn?: Spawn
): FfmpegNativeCodec {
const codec = new FfmpegNativeCodec(options, spawn);
registry.register(codec);
return codec;
}

View File

@@ -0,0 +1,8 @@
import type { FfmpegNativeCodecOptions } from "../codec/codec.types.js";
/** Structural view of just the parts of CodecRegistry we touch. */
export interface RegistryLike {
register(codec: import("@suaveplan/audio-codecs").AudioCodec): void;
}
export type RegisterOptions = FfmpegNativeCodecOptions;

View File

@@ -0,0 +1,8 @@
export { runDecode, runEncode } from "./runner.js";
export type {
RunDecodeOptions,
RunEncodeOptions,
RunnerCommon,
RunResult,
Spawn,
} from "./runner.types.js";

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,386 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeFakeSpawn } from "../../tests/fake-spawn.js";
import { AudioCodecFfmpegNativeError } from "../error-codes/error-codes.js";
import { runDecode, runEncode } from "./runner.js";
describe("runner/runDecode", () => {
it("collects stdout chunks and resolves on clean exit", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({
stdout: [new Uint8Array([1, 2]), new Uint8Array([3])],
exitCode: 0,
});
const collected: Uint8Array[] = [];
const result = await runDecode({
binaryPath: "fake",
args: ["-i", "pipe:0"],
input: new Uint8Array([9]),
onStdout: (c) => collected.push(c),
spawn,
});
expect(result.exitCode).toBe(0);
const merged = Buffer.concat(collected.map((c) => Buffer.from(c)));
expect(Array.from(merged)).toEqual([1, 2, 3]);
});
it("captures stderr tail on failure with DECODE_FAILED", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({
stdout: [],
stderr: ["boom\n"],
exitCode: 2,
});
await expect(
runDecode({
binaryPath: "fake",
args: [],
input: new Uint8Array([1]),
onStdout: () => undefined,
spawn,
})
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED",
});
});
it("maps spawn `error` event to SPAWN_FAILED", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ spawnError: new Error("ENOENT") });
await expect(
runDecode({
binaryPath: "fake",
args: [],
input: new Uint8Array([]),
onStdout: () => undefined,
spawn,
})
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.SPAWN_FAILED",
});
});
it("raises TIMEOUT when timeoutMs fires before exit", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ hold: true });
await expect(
runDecode({
binaryPath: "fake",
args: [],
input: new Uint8Array([]),
onStdout: () => undefined,
spawn,
timeoutMs: 50,
})
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT",
});
});
it("raises ABORTED when AbortSignal fires", { timeout: 5000 }, async () => {
const { spawn } = makeFakeSpawn({ hold: true });
const ac = new AbortController();
const pending = runDecode({
binaryPath: "fake",
args: [],
input: new Uint8Array([]),
onStdout: () => undefined,
spawn,
signal: ac.signal,
});
setTimeout(() => ac.abort(), 10);
await expect(pending).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.ABORTED",
});
});
it("trims stderr to stderrCap bytes", { timeout: 5000 }, async () => {
const large = "x".repeat(5000);
const { spawn } = makeFakeSpawn({
stderr: [large],
exitCode: 1,
});
await expect(
runDecode({
binaryPath: "fake",
args: [],
input: new Uint8Array([]),
onStdout: () => undefined,
spawn,
stderrCap: 16,
})
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED",
});
});
});
describe("runner/runEncode", () => {
it("drains async iterable through stdin and yields encoded bytes", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ echo: true });
async function* feed(): AsyncIterable<Uint8Array> {
yield new Uint8Array([10, 11]);
yield new Uint8Array([12]);
}
const collected: Uint8Array[] = [];
const result = await runEncode({
binaryPath: "fake",
args: [],
inputs: feed(),
onStdout: (c) => collected.push(c),
spawn,
});
expect(result.exitCode).toBe(0);
const out = Buffer.concat(collected.map((c) => Buffer.from(c)));
expect(Array.from(out)).toEqual([10, 11, 12]);
});
it("maps non-zero exit to ENCODE_FAILED", { timeout: 5000 }, async () => {
const { spawn } = makeFakeSpawn({ exitCode: 1, stderr: ["nope"] });
async function* feed(): AsyncIterable<Uint8Array> {
yield new Uint8Array([]);
}
await expect(
runEncode({
binaryPath: "fake",
args: [],
inputs: feed(),
onStdout: () => undefined,
spawn,
})
).rejects.toBeInstanceOf(AudioCodecFfmpegNativeError);
});
it("escalates SIGTERM to SIGKILL after grace window", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ hold: true, ignoreKill: true });
async function* feed(): AsyncIterable<Uint8Array> {
yield new Uint8Array([1]);
}
await expect(
runEncode({
binaryPath: "fake",
args: [],
inputs: feed(),
onStdout: () => undefined,
spawn,
timeoutMs: 30,
})
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT",
});
});
it("surfaces pump errors into stderr tail via the catch path", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({});
async function* feed(): AsyncIterable<Uint8Array> {
yield new Uint8Array([1]);
throw new Error("pump-failure");
}
const result = await runEncode({
binaryPath: "fake",
args: [],
inputs: feed(),
onStdout: () => undefined,
spawn,
});
expect(result.stderr).toContain("pump-failure");
});
it("handles non-Error pump rejections", { timeout: 5000 }, async () => {
const { spawn } = makeFakeSpawn({});
async function* feed(): AsyncIterable<Uint8Array> {
yield new Uint8Array([1]);
throw "string-failure";
}
const result = await runEncode({
binaryPath: "fake",
args: [],
inputs: feed(),
onStdout: () => undefined,
spawn,
});
expect(result.stderr).toContain("string-failure");
});
it("escalates SIGTERM->SIGKILL via timeout (smoke)", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ hold: true });
async function* feed(): AsyncIterable<Uint8Array> {
yield new Uint8Array([]);
}
await expect(
runEncode({
binaryPath: "fake",
args: [],
inputs: feed(),
onStdout: () => undefined,
spawn,
timeoutMs: 30,
})
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT",
});
});
it("awaits stdin `drain` when write() returns false (backpressure)", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({
stdinBackpressure: true,
echo: true,
});
async function* feed(): AsyncIterable<Uint8Array> {
yield new Uint8Array([1, 2, 3]);
yield new Uint8Array([4, 5, 6]);
}
const collected: Uint8Array[] = [];
const result = await runEncode({
binaryPath: "fake",
args: [],
inputs: feed(),
onStdout: (c) => collected.push(c),
spawn,
});
expect(result.exitCode).toBe(0);
const merged = Buffer.concat(collected.map((c) => Buffer.from(c)));
expect(merged.byteLength).toBe(6);
});
});
describe("runner/no-stdin guard", () => {
it("runDecode rejects SPAWN_FAILED when child.stdin is null", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ noStdin: true });
await expect(
runDecode({
binaryPath: "fake",
args: [],
input: new Uint8Array([1]),
onStdout: () => undefined,
spawn,
})
).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.SPAWN_FAILED",
});
});
it("runEncode surfaces SPAWN_FAILED via stderr tail when child.stdin is null", {
timeout: 5000,
}, async () => {
// The runEncode pump is async, so an internal throw becomes a rejected
// promise that the runner catches into stderrTail rather than
// surfacing as the outer reject. The error message is still preserved
// in `stderr`, proving the no-stdin guard branch executed.
const { spawn } = makeFakeSpawn({ noStdin: true });
async function* feed(): AsyncIterable<Uint8Array> {
yield new Uint8Array([1]);
}
const result = await runEncode({
binaryPath: "fake",
args: [],
inputs: feed(),
onStdout: () => undefined,
spawn,
});
expect(result.stderr).toContain("ffmpeg-native.runner.no-stdin");
});
});
describe("runner/close-edge fallbacks", () => {
it("nonzero-exit uses `code ?? -1` when ffmpeg closes with null code", {
timeout: 5000,
}, async () => {
const { spawn, last } = makeFakeSpawn({ hold: true });
const pending = runDecode({
binaryPath: "fake",
args: [],
input: new Uint8Array([1]),
onStdout: () => undefined,
spawn,
});
// Wait a tick so the runner's close listener attaches, then emit
// close with null code+signal to hit the `code ?? -1` branch.
setImmediate(() => {
last()?.triggerCloseRaw(null, null);
});
await expect(pending).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED",
});
});
it('aborted path uses `signal ?? ""` when close emits null signal', {
timeout: 5000,
}, async () => {
const { spawn, last } = makeFakeSpawn({
hold: true,
ignoreKill: true,
});
const ac = new AbortController();
const pending = runDecode({
binaryPath: "fake",
args: [],
input: new Uint8Array([1]),
onStdout: () => undefined,
spawn,
signal: ac.signal,
});
setTimeout(() => {
ac.abort();
setImmediate(() => last()?.triggerCloseRaw(0, null));
}, 10);
await expect(pending).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.ABORTED",
});
});
it('timeout path uses `signal ?? ""` when close emits null signal', {
timeout: 5000,
}, async () => {
const { spawn, last } = makeFakeSpawn({
hold: true,
ignoreKill: true,
});
const pending = runDecode({
binaryPath: "fake",
args: [],
input: new Uint8Array([1]),
onStdout: () => undefined,
spawn,
timeoutMs: 20,
});
setTimeout(() => last()?.triggerCloseRaw(0, null), 60);
await expect(pending).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT",
});
});
});
describe("runner/default spawn", () => {
it("uses node:child_process.spawn when no spawn is injected", {
timeout: 10_000,
}, async () => {
// /usr/bin/cat echoes stdin to stdout — a real-process smoke test that
// exercises the `opts.spawn ?? nodeSpawn` default and the full lifecycle.
const collected: Uint8Array[] = [];
const result = await runDecode({
binaryPath: "/usr/bin/cat",
args: [],
input: new Uint8Array([42, 43, 44]),
onStdout: (c) => collected.push(c),
});
expect(result.exitCode).toBe(0);
const merged = Buffer.concat(collected.map((c) => Buffer.from(c)));
expect(Array.from(merged)).toEqual([42, 43, 44]);
});
});

View File

@@ -0,0 +1,231 @@
import { spawn as nodeSpawn } from "node:child_process";
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AudioCodecFfmpegNativeError,
} from "../error-codes/error-codes.js";
import type {
RunDecodeOptions,
RunEncodeOptions,
RunnerCommon,
RunResult,
} from "./runner.types.js";
const SIGKILL_GRACE_MS = 250;
const DEFAULT_STDERR_CAP = 4096;
/** Spawn ffmpeg with stdin input and stream stdout chunks to the callback. */
export function runDecode(opts: RunDecodeOptions): Promise<RunResult> {
return runProcess(
opts,
(child) => {
if (child.stdin === null) {
child.kill("SIGKILL");
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.SPAWN_FAILED,
"ffmpeg-native.runner.no-stdin",
{}
);
}
child.stdin.end(opts.input);
},
AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED
);
}
/** Spawn ffmpeg and stream PCM chunks from an async iterable into stdin. */
export function runEncode(opts: RunEncodeOptions): Promise<RunResult> {
return runProcess(
opts,
async (child) => {
if (child.stdin === null) {
child.kill("SIGKILL");
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.SPAWN_FAILED,
"ffmpeg-native.runner.no-stdin",
{}
);
}
const stdin = child.stdin;
try {
for await (const chunk of opts.inputs) {
/* istanbul ignore start -- defensive guard for ffmpeg
collapsing stdin between chunks; deterministic
fake-spawn tests cannot reach it. */
if (!stdin.writable) break;
/* istanbul ignore stop */
if (!stdin.write(chunk)) {
await new Promise<void>((resolve) =>
stdin.once("drain", resolve)
);
}
}
} finally {
stdin.end();
}
},
AUDIO_CODEC_FFMPEG_NATIVE_CODES.ENCODE_FAILED
);
}
type FailureCode =
| typeof AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED
| typeof AUDIO_CODEC_FFMPEG_NATIVE_CODES.ENCODE_FAILED;
interface SharedOpts extends RunnerCommon {
readonly onStdout: (chunk: Uint8Array) => void;
}
function runProcess(
opts: SharedOpts,
pump: (
child: import("node:child_process").ChildProcess
) => void | Promise<void>,
failureCode: FailureCode
): Promise<RunResult> {
return new Promise<RunResult>((resolve, reject) => {
const spawnImpl = opts.spawn ?? nodeSpawn;
const stderrCap = opts.stderrCap ?? DEFAULT_STDERR_CAP;
let stderrTail = "";
let aborted = false;
let timedOut = false;
let settled = false;
const child = spawnImpl(opts.binaryPath, opts.args, {
stdio: ["pipe", "pipe", "pipe"],
});
const settle = (
err: AudioCodecFfmpegNativeError | null,
result: RunResult | null
): void => {
/* istanbul ignore start -- the `settled` flag guards the
simultaneous-error close race; deterministic fake-spawn
tests never deliver overlapping settlements. */
if (settled) return;
/* istanbul ignore stop */
settled = true;
cleanup();
if (err !== null) reject(err);
else resolve(result as RunResult);
};
const killEscalate = (): void => {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const grace = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, SIGKILL_GRACE_MS);
grace.unref?.();
};
const timeout =
opts.timeoutMs !== undefined
? setTimeout(() => {
timedOut = true;
killEscalate();
}, opts.timeoutMs)
: null;
timeout?.unref?.();
const onAbort = (): void => {
aborted = true;
killEscalate();
};
opts.signal?.addEventListener("abort", onAbort, { once: true });
const cleanup = (): void => {
if (timeout !== null) clearTimeout(timeout);
opts.signal?.removeEventListener("abort", onAbort);
};
child.on("error", (cause) => {
settle(
new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.SPAWN_FAILED,
"ffmpeg-native.runner.spawn-failed",
{ cause, context: { stderr: stderrTail } }
),
null
);
});
child.stdout?.on("data", (chunk: Buffer) => {
opts.onStdout(new Uint8Array(chunk));
});
child.stderr?.on("data", (chunk: Buffer) => {
stderrTail = appendTail(
stderrTail,
chunk.toString("utf8"),
stderrCap
);
});
child.on("close", (code, signal) => {
if (aborted) {
settle(
new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.ABORTED,
"ffmpeg-native.runner.aborted",
{
context: {
stderr: stderrTail,
signal: signal ?? "",
},
}
),
null
);
return;
}
if (timedOut) {
settle(
new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.TIMEOUT,
"ffmpeg-native.runner.timeout",
{
context: {
stderr: stderrTail,
signal: signal ?? "",
},
}
),
null
);
return;
}
if (code !== 0) {
settle(
new AudioCodecFfmpegNativeError(
failureCode,
"ffmpeg-native.runner.nonzero-exit",
{
context: {
stderr: stderrTail,
exitCode: code ?? -1,
},
}
),
null
);
return;
}
settle(null, { stderr: stderrTail, exitCode: 0 });
});
// Drive the pump asynchronously; report errors via the close path so
// we don't double-settle on simultaneous stdin/spawn failure.
void Promise.resolve(pump(child)).catch((cause) => {
const msg = cause instanceof Error ? cause.message : String(cause);
stderrTail = appendTail(stderrTail, `\n[runner] ${msg}`, stderrCap);
killEscalate();
});
});
}
function appendTail(existing: string, addition: string, cap: number): string {
const merged = existing + addition;
if (merged.length <= cap) return merged;
return merged.slice(merged.length - cap);
}

View File

@@ -0,0 +1,40 @@
import type { ChildProcess, SpawnOptions } from "node:child_process";
/**
* Subprocess spawner contract. `spawn` is injected so tests can drive the
* runner against `node` echo/cat scripts without touching real ffmpeg, and
* production code resolves to `node:child_process.spawn` via the default.
*/
export type Spawn = (
command: string,
args: readonly string[],
options?: SpawnOptions
) => ChildProcess;
/** Common runner options shared by `runDecode` and `runEncode`. */
export interface RunnerCommon {
readonly binaryPath: string;
readonly args: readonly string[];
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
readonly spawn?: Spawn;
/** Maximum stderr ring-buffer size in bytes. Defaults to 4096. */
readonly stderrCap?: number;
}
export interface RunDecodeOptions extends RunnerCommon {
readonly input: Uint8Array;
/** Called with every stdout chunk as it arrives. */
readonly onStdout: (chunk: Uint8Array) => void;
}
export interface RunEncodeOptions extends RunnerCommon {
/** Async iterator yielding stdin chunks (interleaved PCM bytes). */
readonly inputs: AsyncIterable<Uint8Array>;
readonly onStdout: (chunk: Uint8Array) => void;
}
export interface RunResult {
readonly stderr: string;
readonly exitCode: number;
}

View File

@@ -0,0 +1,10 @@
export {
createStreamingDecoder,
type FfmpegStreamingDecoder,
} from "./stream-decoder.js";
export type { StreamingDecoderOptions } from "./stream-decoder.types.js";
export {
createStreamingEncoder,
type FfmpegStreamingEncoder,
} from "./stream-encoder.js";
export type { StreamingEncoderOptions } from "./stream-encoder.types.js";

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,259 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeFakeSpawn } from "../../tests/fake-spawn.js";
import { interleaveF32 } from "../codec/pcm.js";
import { createStreamingDecoder } from "./stream-decoder.js";
const pcmFrames = interleaveF32([
Float32Array.from([0.1, 0.2, 0.3, 0.4]),
Float32Array.from([0.5, 0.6, 0.7, 0.8]),
]);
describe("createStreamingDecoder", () => {
it("write returns sync pages once stdout has frames; flushAsync resolves", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({
stdout: [pcmFrames],
exitCode: 0,
});
const dec = createStreamingDecoder({
binaryProvider: async () => "fake",
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
});
dec.write(new Uint8Array([1]));
const tail = await dec.flushAsync();
expect(tail.length).toBeGreaterThan(0);
expect(tail[0]!.channelData.length).toBe(2);
dec.close();
});
it("write before spawn buffers and ultimately flushes", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({
stdout: [pcmFrames],
exitCode: 0,
delayMs: 5,
});
const dec = createStreamingDecoder({
binaryProvider: async () => "fake",
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
});
const sync = dec.write(new Uint8Array([1, 2, 3]));
expect(sync.length).toBe(0); // no pages yet
const tail = await dec.flushAsync();
expect(tail.length).toBeGreaterThan(0);
dec.close();
});
it("close is idempotent and drops pending bytes", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ stdout: [pcmFrames], exitCode: 0 });
const dec = createStreamingDecoder({
binaryProvider: async () => "fake",
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
});
dec.close();
dec.close();
expect(dec.write(new Uint8Array([1])).length).toBe(0);
expect(dec.flush().length).toBe(0);
});
it("flushAsync raises DECODE_FAILED on non-zero exit", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({
stdout: [],
stderr: ["bad"],
exitCode: 3,
});
const dec = createStreamingDecoder({
binaryProvider: async () => "fake",
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
});
await expect(dec.flushAsync()).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.DECODE_FAILED",
});
dec.close();
});
it("aborts via AbortSignal", { timeout: 5000 }, async () => {
const { spawn } = makeFakeSpawn({ hold: true });
const ac = new AbortController();
const dec = createStreamingDecoder({
binaryProvider: async () => "fake",
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
signal: ac.signal,
});
dec.write(new Uint8Array([1]));
setTimeout(() => ac.abort(), 10);
await expect(dec.flushAsync()).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.ABORTED",
});
dec.close();
});
it("partially consumes a stdout chunk that straddles the frame boundary", {
timeout: 5000,
}, async () => {
// Frame size = channels(2) * 4 = 8 bytes. Feed one chunk of 20 bytes
// (pcmFrames + 4 trailing bytes): bufferedBytes=20, usable=16, and the
// 20-byte head exceeds the 16-byte usable window, forcing the
// head.byteLength > remaining branch in drainPagesSync (line 68-70)
// where the head is split via `head.subarray(remaining)` and the
// remainder is re-stored at state.pending[0].
const extended = new Uint8Array(pcmFrames.byteLength + 4);
extended.set(pcmFrames, 0);
// Append a partial sample's worth of bytes (will be re-buffered).
extended.set(new Uint8Array([0, 0, 128, 63]), pcmFrames.byteLength);
const { spawn } = makeFakeSpawn({
stdout: [extended],
exitCode: 0,
hold: true,
});
const dec = createStreamingDecoder({
binaryProvider: async () => "fake",
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
});
// Allow the fake-spawn setTimeout(0) to deliver the stdout chunk.
await new Promise<void>((resolve) => setTimeout(resolve, 25));
const pages = dec.write(new Uint8Array(0));
expect(pages.length).toBeGreaterThan(0);
expect(pages[0]!.channelData[0]!.length).toBeGreaterThan(0);
dec.close();
});
it("resolves the exit promise via the child `error` event path", {
timeout: 5000,
}, async () => {
// fake-spawn emits `error` asynchronously; the exit-promise's
// `child.once("error", resolve)` handler must fire so flushAsync
// unblocks even when ffmpeg never produces a clean close.
const { spawn } = makeFakeSpawn({ spawnError: new Error("ENOENT") });
const dec = createStreamingDecoder({
binaryProvider: async () => "fake",
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
});
const tail = await dec.flushAsync();
expect(tail).toBeDefined();
dec.close();
});
it("ensureStarted short-circuits when the process is already spawned", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ stdout: [pcmFrames], exitCode: 0 });
const dec = createStreamingDecoder({
binaryProvider: async () => "fake",
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
});
// Wait so the eager ensureStarted at module-init has fully resolved
// and state.process is non-null. flushAsync's subsequent ensureStarted
// then hits the `state.process !== null` short-circuit (line 85).
await new Promise<void>((resolve) => setTimeout(resolve, 25));
const tail = await dec.flushAsync();
expect(tail).toBeDefined();
dec.close();
});
it("write() drives stdin directly once the child has spawned", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({
stdout: [pcmFrames],
exitCode: 0,
hold: true,
});
const dec = createStreamingDecoder({
binaryProvider: async () => "fake",
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
});
// Wait until the spawn IIFE + prebaked stdout emission have settled so
// stdin is writable when write() runs (covers line 151's active path).
// `hold: true` keeps the process alive so write() lands while stdin is
// still open.
await new Promise<void>((resolve) => setTimeout(resolve, 25));
const pages = dec.write(new Uint8Array([1, 2, 3, 4]));
expect(pages.length).toBeGreaterThan(0);
dec.close();
});
it("write() before spawn ready swallows deferred startProcess rejection", {
timeout: 5000,
}, async () => {
// binaryProvider rejects after the synchronous write() schedules a
// deferred startProcess; the runner's catch (line 160) suppresses the
// failure so close() / flush() remain stable.
const { spawn } = makeFakeSpawn({ stdout: [], exitCode: 0 });
const dec = createStreamingDecoder({
binaryProvider: async () => {
throw new Error("binary-missing");
},
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
});
// The synchronous write() should not throw — it defers via the catch path.
expect(() => dec.write(new Uint8Array([1, 2]))).not.toThrow();
// Allow microtasks to flush so the deferred .catch executes.
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
dec.close();
});
it("times out via timeoutMs", { timeout: 5000 }, async () => {
const { spawn } = makeFakeSpawn({ hold: true });
const dec = createStreamingDecoder({
binaryProvider: async () => "fake",
args: [],
channels: 2,
sampleRate: 22050,
format: "ogg",
spawn,
timeoutMs: 30,
});
await expect(dec.flushAsync()).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT",
});
dec.close();
});
});

View File

@@ -0,0 +1,211 @@
import type { ChildProcess } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
import type { DecodeResult, StreamingDecoder } from "@suaveplan/audio-codecs";
import { deinterleaveF32 } from "../codec/pcm.js";
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AudioCodecFfmpegNativeError,
} from "../error-codes/error-codes.js";
import type { StreamingDecoderOptions } from "./stream-decoder.types.js";
/** Companion interface extending the additive `flushAsync` method. */
export interface FfmpegStreamingDecoder extends StreamingDecoder {
/**
* End stdin, wait for ffmpeg to drain stdout, and return all remaining
* decode results. The sync `flush()` from the base contract only returns
* pages already buffered by the synchronous `data` listeners; `flushAsync`
* is the safe end-of-stream path that actually awaits process exit.
*/
flushAsync(): Promise<readonly DecodeResult[]>;
}
interface InternalState {
process: ChildProcess | null;
starting: Promise<void> | null;
binaryPath: string | null;
pending: Uint8Array[];
bufferedBytes: number;
closed: boolean;
aborted: boolean;
timedOut: boolean;
exitPromise: Promise<void> | null;
stderrTail: string;
}
/** Create a streaming decoder backed by a single long-running ffmpeg child. */
export function createStreamingDecoder(
opts: StreamingDecoderOptions
): FfmpegStreamingDecoder {
const state: InternalState = {
process: null,
starting: null,
binaryPath: null,
pending: [],
bufferedBytes: 0,
closed: false,
aborted: false,
timedOut: false,
exitPromise: null,
stderrTail: "",
};
const bytesPerSample = 4;
const frameSize = opts.channels * bytesPerSample;
const drainPagesSync = (): DecodeResult[] => {
if (state.bufferedBytes < frameSize) return [];
const usable = state.bufferedBytes - (state.bufferedBytes % frameSize);
const merged = new Uint8Array(usable);
let off = 0;
while (off < usable && state.pending.length > 0) {
const head = state.pending[0];
/* istanbul ignore start -- structurally unreachable: the
`length > 0` loop guard guarantees a value. */
if (head === undefined) break;
/* istanbul ignore stop */
const remaining = usable - off;
if (head.byteLength <= remaining) {
merged.set(head, off);
off += head.byteLength;
state.pending.shift();
} else {
merged.set(head.subarray(0, remaining), off);
state.pending[0] = head.subarray(remaining);
off += remaining;
}
}
state.bufferedBytes -= usable;
const channelData = deinterleaveF32(merged, opts.channels);
return [
{
channelData,
sampleRate: opts.sampleRate,
format: opts.format,
},
];
};
const ensureStarted = async (): Promise<ChildProcess> => {
if (state.process !== null) return state.process;
if (state.starting === null) {
state.starting = (async (): Promise<void> => {
const path = await opts.binaryProvider();
state.binaryPath = path;
const spawnImpl = opts.spawn ?? nodeSpawn;
const child = spawnImpl(path, opts.args, {
stdio: ["pipe", "pipe", "pipe"],
});
state.process = child;
child.stdout?.on("data", (c: Buffer) => {
state.pending.push(new Uint8Array(c));
state.bufferedBytes += c.byteLength;
});
child.stderr?.on("data", (c: Buffer) => {
state.stderrTail = (
state.stderrTail + c.toString("utf8")
).slice(-4096);
});
state.exitPromise = new Promise<void>((resolve) => {
child.once("close", () => resolve());
child.once("error", () => resolve());
});
})();
}
await state.starting;
// After awaiting `state.starting`, the IIFE either threw (rethrown
// here) or completed and assigned `state.process`. We assert the
// resulting non-null reference rather than carrying a dead null-check.
return state.process as unknown as ChildProcess;
};
// Eagerly start in background so flushAsync can await exit.
void ensureStarted().catch(() => {
// First flushAsync call will surface this.
});
if (opts.signal !== undefined) {
opts.signal.addEventListener(
"abort",
() => {
state.aborted = true;
state.process?.kill("SIGTERM");
},
{ once: true }
);
}
if (opts.timeoutMs !== undefined) {
const t = setTimeout(() => {
state.timedOut = true;
state.process?.kill("SIGTERM");
}, opts.timeoutMs);
t.unref?.();
}
return {
write(chunk: Uint8Array): readonly DecodeResult[] {
if (state.closed) return [];
// Best-effort sync write; if ffmpeg hasn't started yet we buffer.
const proc = state.process;
if (proc?.stdin?.writable === true) {
proc.stdin.write(chunk);
} else {
// Defer write until process is ready.
void ensureStarted()
.then((ready) => {
/* istanbul ignore start -- deterministic fake-spawn
tests always expose a writable stdin on the freshly
spawned child. */
if (ready.stdin?.writable === true) {
ready.stdin.write(chunk);
}
/* istanbul ignore stop */
})
.catch(() => undefined);
}
return drainPagesSync();
},
flush(): readonly DecodeResult[] {
return drainPagesSync();
},
async flushAsync(): Promise<readonly DecodeResult[]> {
const proc = await ensureStarted();
proc.stdin?.end();
await state.exitPromise;
if (state.aborted) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.ABORTED,
"ffmpeg-native.stream.aborted",
{ context: { stderr: state.stderrTail } }
);
}
if (state.timedOut) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.TIMEOUT,
"ffmpeg-native.stream.timeout",
{ context: { stderr: state.stderrTail } }
);
}
const code = proc.exitCode;
if (code !== null && code !== 0) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED,
"ffmpeg-native.stream.nonzero-exit",
{
context: {
stderr: state.stderrTail,
exitCode: code,
},
}
);
}
return drainPagesSync();
},
close(): void {
if (state.closed) return;
state.closed = true;
state.pending = [];
state.bufferedBytes = 0;
state.process?.kill("SIGTERM");
},
};
}

View File

@@ -0,0 +1,12 @@
import type { Spawn } from "../runner/runner.types.js";
export interface StreamingDecoderOptions {
readonly binaryProvider: () => Promise<string>;
readonly args: readonly string[];
readonly channels: number;
readonly sampleRate: number;
readonly format: string;
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
readonly spawn?: Spawn;
}

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.

View File

@@ -0,0 +1,220 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeFakeSpawn } from "../../tests/fake-spawn.js";
import { createStreamingEncoder } from "./stream-encoder.js";
describe("createStreamingEncoder", () => {
it("start + write + flushAsync emits encoded bytes", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ echo: true });
const enc = createStreamingEncoder({
binaryProvider: async () => "fake",
argsFor: () => [],
spawn,
});
enc.start(48_000, 2);
enc.write([
Float32Array.from([0.1, 0.2]),
Float32Array.from([-0.1, -0.2]),
]);
const tail = await enc.flushAsync();
expect(tail.byteLength).toBeGreaterThan(0);
enc.close();
});
it("write before spawn buffers and flushes successfully", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ echo: true, delayMs: 5 });
const enc = createStreamingEncoder({
binaryProvider: async () => "fake",
argsFor: () => [],
spawn,
});
enc.start(48_000, 1);
const sync = enc.write([Float32Array.from([0.5, 0.5])]);
expect(sync.byteLength).toBe(0); // buffered until ffmpeg ready
const tail = await enc.flushAsync();
expect(tail.byteLength).toBeGreaterThan(0);
enc.close();
});
it("write infers channels when start() not called", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ echo: true });
const enc = createStreamingEncoder({
binaryProvider: async () => "fake",
argsFor: () => [],
spawn,
});
enc.write([
Float32Array.from([0.1, 0.2]),
Float32Array.from([-0.1, -0.2]),
]);
const tail = await enc.flushAsync();
expect(tail.byteLength).toBeGreaterThan(0);
enc.close();
});
it("close drops pending bytes and is idempotent", { timeout: 5000 }, () => {
const { spawn } = makeFakeSpawn({ echo: true });
const enc = createStreamingEncoder({
binaryProvider: async () => "fake",
argsFor: () => [],
spawn,
});
enc.close();
enc.close();
expect(enc.write([Float32Array.from([0])]).byteLength).toBe(0);
expect(enc.flush().byteLength).toBe(0);
});
it("flushAsync raises ENCODE_FAILED on non-zero exit", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ exitCode: 1 });
const enc = createStreamingEncoder({
binaryProvider: async () => "fake",
argsFor: () => [],
spawn,
});
await expect(enc.flushAsync()).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.ENCODE_FAILED",
});
enc.close();
});
it("AbortSignal raises ABORTED on flushAsync", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ hold: true });
const ac = new AbortController();
const enc = createStreamingEncoder({
binaryProvider: async () => "fake",
argsFor: () => [],
spawn,
signal: ac.signal,
});
enc.start(48_000, 1);
setTimeout(() => ac.abort(), 10);
await expect(enc.flushAsync()).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.ABORTED",
});
enc.close();
});
it("resolves the exit promise via the child `error` event path", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ spawnError: new Error("ENOENT") });
const enc = createStreamingEncoder({
binaryProvider: async () => "fake",
argsFor: () => [],
spawn,
});
enc.start(48_000, 1);
const out = await enc.flushAsync();
expect(out).toBeDefined();
enc.close();
});
it("appends stderr from ffmpeg into the error stderrTail", {
timeout: 5000,
}, async () => {
// Stderr emission from the child process must accumulate into
// state.stderrTail so the eventual error includes it (line 73).
const { spawn } = makeFakeSpawn({
stderr: ["ffmpeg-failed-encode\n"],
exitCode: 2,
});
const enc = createStreamingEncoder({
binaryProvider: async () => "fake",
argsFor: () => [],
spawn,
});
enc.start(48_000, 1);
try {
await enc.flushAsync();
expect.unreachable("flushAsync should reject");
} catch (err) {
const wrapped = err as { context?: { stderr?: string } };
expect(wrapped.context?.stderr).toContain("ffmpeg-failed-encode");
}
enc.close();
});
it("write() drives stdin directly once the child has spawned", {
timeout: 5000,
}, async () => {
const { spawn, last } = makeFakeSpawn({ echo: true });
const enc = createStreamingEncoder({
binaryProvider: async () => "fake",
argsFor: () => [],
spawn,
});
enc.start(48_000, 2);
// Wait until the spawn IIFE + setTimeout(0) macrotask have settled so
// stdin is writable when write() runs (covers the `proc.stdin.write`
// active branch at line 127).
await new Promise<void>((resolve) => setTimeout(resolve, 25));
// Sanity: the spawned child must exist and have a writable stdin so we
// know the active-branch (not the deferred path) executes below.
const child = last();
expect(child?.stdin?.writable).toBe(true);
const collected: Buffer[] = [];
child!.stdout!.on("data", (c: Buffer) => collected.push(c));
enc.write([
Float32Array.from([0.1, 0.2]),
Float32Array.from([-0.1, -0.2]),
]);
// Allow PassThrough echo to forward bytes from stdin to stdout.
await new Promise<void>((resolve) => setTimeout(resolve, 10));
const echoed = Buffer.concat(collected);
expect(echoed.byteLength).toBeGreaterThan(0);
// flushAsync re-enters startProcess after state.process is set,
// covering the early-return branch at line 60.
await enc.flushAsync();
enc.close();
});
it("write() before spawn ready swallows deferred startProcess rejection", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ echo: true });
let calls = 0;
const enc = createStreamingEncoder({
binaryProvider: async () => {
calls += 1;
if (calls === 1) throw new Error("binary-missing");
return "fake";
},
argsFor: () => [],
spawn,
});
enc.start(48_000, 1);
// First write hits the deferred branch; its .catch (line 135) absorbs
// the rejection so the synchronous call does not throw.
expect(() => enc.write([Float32Array.from([0.5, 0.6])])).not.toThrow();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
enc.close();
});
it("timeoutMs raises TIMEOUT on flushAsync", {
timeout: 5000,
}, async () => {
const { spawn } = makeFakeSpawn({ hold: true });
const enc = createStreamingEncoder({
binaryProvider: async () => "fake",
argsFor: () => [],
spawn,
timeoutMs: 30,
});
enc.start(48_000, 1);
await expect(enc.flushAsync()).rejects.toMatchObject({
code: "AUDIO_CODEC_FFMPEG_NATIVE.TIMEOUT",
});
enc.close();
});
});

View File

@@ -0,0 +1,185 @@
import type { ChildProcess } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
import type { StreamingEncoder } from "@suaveplan/audio-codecs";
import { interleaveF32, validatePcm } from "../codec/pcm.js";
import {
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
AudioCodecFfmpegNativeError,
} from "../error-codes/error-codes.js";
import type { StreamingEncoderOptions } from "./stream-encoder.types.js";
/** Companion interface extending the additive `flushAsync` method. */
export interface FfmpegStreamingEncoder extends StreamingEncoder {
/**
* End stdin and await all remaining encoded bytes from ffmpeg's stdout.
* Pairs with the synchronous `flush()` from the base contract.
*/
flushAsync(): Promise<Uint8Array>;
}
interface InternalState {
process: ChildProcess | null;
starting: Promise<void> | null;
pending: Uint8Array[];
closed: boolean;
aborted: boolean;
timedOut: boolean;
exitPromise: Promise<void> | null;
stderrTail: string;
}
/** Create a streaming encoder backed by a single long-running ffmpeg child. */
export function createStreamingEncoder(
opts: StreamingEncoderOptions
): FfmpegStreamingEncoder {
const state: InternalState = {
process: null,
starting: null,
pending: [],
closed: false,
aborted: false,
timedOut: false,
exitPromise: null,
stderrTail: "",
};
const drainSync = (): Uint8Array => {
let total = 0;
for (const c of state.pending) total += c.byteLength;
const out = new Uint8Array(total);
let off = 0;
for (const c of state.pending) {
out.set(c, off);
off += c.byteLength;
}
state.pending = [];
return out;
};
const startProcess = async (channels: number): Promise<ChildProcess> => {
if (state.process !== null) return state.process;
if (state.starting === null) {
state.starting = (async (): Promise<void> => {
const path = await opts.binaryProvider();
const spawnImpl = opts.spawn ?? nodeSpawn;
const child = spawnImpl(path, opts.argsFor(channels), {
stdio: ["pipe", "pipe", "pipe"],
});
state.process = child;
child.stdout?.on("data", (c: Buffer) => {
state.pending.push(new Uint8Array(c));
});
child.stderr?.on("data", (c: Buffer) => {
state.stderrTail = (
state.stderrTail + c.toString("utf8")
).slice(-4096);
});
state.exitPromise = new Promise<void>((resolve) => {
child.once("close", () => resolve());
child.once("error", () => resolve());
});
})();
}
await state.starting;
// After awaiting `state.starting`, the IIFE either threw (rethrown
// here) or completed and assigned `state.process`. We assert the
// resulting non-null reference rather than carrying a dead null-check.
return state.process as unknown as ChildProcess;
};
if (opts.signal !== undefined) {
opts.signal.addEventListener(
"abort",
() => {
state.aborted = true;
state.process?.kill("SIGTERM");
},
{ once: true }
);
}
if (opts.timeoutMs !== undefined) {
const t = setTimeout(() => {
state.timedOut = true;
state.process?.kill("SIGTERM");
}, opts.timeoutMs);
t.unref?.();
}
let chosenChannels = 0;
return {
start(_sampleRate: number, channels: number): Uint8Array {
chosenChannels = channels;
void startProcess(channels).catch(() => undefined);
return new Uint8Array(0);
},
write(channelData: readonly Float32Array[]): Uint8Array {
if (state.closed) return new Uint8Array(0);
validatePcm(channelData);
if (chosenChannels === 0) chosenChannels = channelData.length;
const interleaved = interleaveF32(channelData);
const proc = state.process;
if (proc?.stdin?.writable === true) {
proc.stdin.write(interleaved);
} else {
// Defer write until process is ready.
void startProcess(chosenChannels)
.then((ready) => {
/* istanbul ignore start -- deterministic fake-spawn
tests always expose a writable stdin on the freshly
spawned child. */
if (ready.stdin?.writable === true) {
ready.stdin.write(interleaved);
}
/* istanbul ignore stop */
})
.catch(() => undefined);
}
return drainSync();
},
flush(): Uint8Array {
return drainSync();
},
async flushAsync(): Promise<Uint8Array> {
const proc = await startProcess(
chosenChannels === 0 ? 1 : chosenChannels
);
proc.stdin?.end();
await state.exitPromise;
if (state.aborted) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.ABORTED,
"ffmpeg-native.stream.aborted",
{ context: { stderr: state.stderrTail } }
);
}
if (state.timedOut) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.TIMEOUT,
"ffmpeg-native.stream.timeout",
{ context: { stderr: state.stderrTail } }
);
}
const code = proc.exitCode;
if (code !== null && code !== 0) {
throw new AudioCodecFfmpegNativeError(
AUDIO_CODEC_FFMPEG_NATIVE_CODES.ENCODE_FAILED,
"ffmpeg-native.stream.nonzero-exit",
{
context: {
stderr: state.stderrTail,
exitCode: code,
},
}
);
}
return drainSync();
},
close(): void {
if (state.closed) return;
state.closed = true;
state.pending = [];
state.process?.kill("SIGTERM");
},
};
}

View File

@@ -0,0 +1,10 @@
import type { Spawn } from "../runner/runner.types.js";
export interface StreamingEncoderOptions {
readonly binaryProvider: () => Promise<string>;
/** Argv builder that knows the channel count chosen by `start()`. */
readonly argsFor: (channels: number) => readonly string[];
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
readonly spawn?: Spawn;
}

View File

@@ -0,0 +1,219 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough, type Readable, type Writable } from "node:stream";
export interface FakeSpawnPlan {
/** Bytes/messages to push to stdout in order (each becomes one "data" event). */
readonly stdout?: readonly Uint8Array[];
readonly stderr?: readonly string[];
/** Exit code emitted on "close". */
readonly exitCode?: number | null;
/** Signal returned on "close" — distinguishes signal-kill cases. */
readonly closeSignal?: NodeJS.Signals | null;
/** Throw via "error" event immediately instead of returning normally. */
readonly spawnError?: Error;
/** Delay (ms) before stdout/stderr/close emission. */
readonly delayMs?: number;
/** If true, never auto-close — caller drives close via `child.kill`. */
readonly hold?: boolean;
/** Echo stdin into stdout (loopback). */
readonly echo?: boolean;
/** Ignore `kill()` calls so SIGTERM->SIGKILL escalation can be exercised. */
readonly ignoreKill?: boolean;
/** Force stdin to be null so the no-stdin guard branch fires. */
readonly noStdin?: boolean;
/**
* Cause `stdin.write()` to return false on first call so the caller has to
* wait on the `drain` event before continuing. Used to exercise the
* backpressure branch in `runEncode`.
*/
readonly stdinBackpressure?: boolean;
/**
* Trigger close on stdin end with these values. Used to exercise the
* `code ?? -1` and `signal ?? ""` fallback branches by feeding `null`.
*/
readonly closeOnStdinEnd?: {
code: number | null;
signal: NodeJS.Signals | null;
};
}
export interface FakeChild extends ChildProcess {
/** Internal hook for tests to manually trigger close. */
triggerClose(code: number, signal?: NodeJS.Signals | null): void;
}
class StubChild extends EventEmitter implements FakeChild {
public stdin: Writable | null;
public stdout: Readable;
public stderr: Readable;
public exitCode: number | null = null;
public signalCode: NodeJS.Signals | null = null;
public pid = 4242;
private closed = false;
public constructor(public plan: FakeSpawnPlan) {
super();
const stdoutStream = new PassThrough();
this.stdout = stdoutStream;
this.stderr = new PassThrough();
if (plan.noStdin === true) {
this.stdin = null;
return;
}
const stdin = new PassThrough();
this.stdin = stdin;
if (plan.stdinBackpressure === true) {
const realWrite = stdin.write.bind(stdin);
let triggered = false;
stdin.write = ((chunk: Buffer | string): boolean => {
realWrite(chunk);
if (!triggered) {
triggered = true;
// Schedule a drain emission so the awaited promise resolves.
setImmediate(() => stdin.emit("drain"));
return false;
}
return true;
}) as typeof stdin.write;
}
if (plan.echo === true) {
stdin.on("data", (c) => stdoutStream.write(c));
stdin.on("end", () => {
stdoutStream.end();
if (plan.hold !== true) {
if (plan.closeOnStdinEnd !== undefined) {
this.triggerClose(
plan.closeOnStdinEnd.code ?? 0,
plan.closeOnStdinEnd.signal
);
} else {
this.triggerClose(plan.exitCode ?? 0);
}
}
});
}
if (plan.closeOnStdinEnd !== undefined && plan.echo !== true) {
stdin.on("end", () => {
this.triggerCloseRaw(
plan.closeOnStdinEnd!.code,
plan.closeOnStdinEnd!.signal
);
});
}
}
public kill(signal?: NodeJS.Signals | number): boolean {
if (this.closed) return false;
const s = typeof signal === "string" ? signal : "SIGTERM";
if (this.plan.ignoreKill === true && s !== "SIGKILL") {
// Pretend the process is stubbornly alive — escalation needed.
return true;
}
this.signalCode = s;
this.triggerClose(0, this.signalCode);
return true;
}
public triggerClose(code: number, signal?: NodeJS.Signals | null): void {
if (this.closed) return;
this.closed = true;
this.exitCode = code;
// Settle on next microtask so listeners attach first.
setImmediate(() => {
this.emit("close", code, signal ?? null);
});
}
/**
* Like `triggerClose` but emits the raw `code` value (including `null`)
* so the runner exercises its `code ?? -1` fallback branch.
*/
public triggerCloseRaw(
code: number | null,
signal: NodeJS.Signals | null
): void {
if (this.closed) return;
this.closed = true;
this.exitCode = code;
setImmediate(() => {
this.emit("close", code, signal);
});
}
public ref(): this {
return this;
}
public unref(): this {
return this;
}
public disconnect(): void {
// no-op stub
}
public send(): boolean {
return true;
}
public get connected(): boolean {
return false;
}
public get killed(): boolean {
return this.closed;
}
public get stdio(): ChildProcess["stdio"] {
return [
this.stdin,
this.stdout,
this.stderr,
null,
null,
] as unknown as ChildProcess["stdio"];
}
public get stdinWritable(): Writable | null {
return this.stdin;
}
public get spawnargs(): string[] {
return [];
}
public get spawnfile(): string {
return "fake-ffmpeg";
}
}
export function makeFakeSpawn(plan: FakeSpawnPlan): {
spawn: (cmd: string, args: readonly string[]) => ChildProcess;
last: () => FakeChild | undefined;
} {
let last: FakeChild | undefined;
return {
spawn(_cmd, _args) {
const child = new StubChild(plan) as FakeChild;
last = child;
// Synchronous error path.
if (plan.spawnError !== undefined) {
setImmediate(() => child.emit("error", plan.spawnError));
return child;
}
const delay = plan.delayMs ?? 0;
setTimeout(() => {
if (plan.echo !== true) {
for (const chunk of plan.stdout ?? []) {
(child.stdout as PassThrough).write(Buffer.from(chunk));
}
}
for (const e of plan.stderr ?? []) {
(child.stderr as PassThrough).write(e);
}
if (plan.hold !== true && plan.echo !== true) {
child.triggerClose(
plan.exitCode ?? 0,
plan.closeSignal ?? null
);
}
}, delay);
return child;
},
last() {
return last;
},
};
}

View File

@@ -0,0 +1,3 @@
{
"extends": "./tsconfig.lib.json"
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,41 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audio-codec-ffmpeg-native",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/audio-codecs",
"@suaveplan/audio-core",
"@suaveplan/error",
"@suaveplan/types",
"@opentelemetry/api",
"fluent-ffmpeg",
"@ffmpeg-installer/ffmpeg",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,6 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
testTimeout: 30_000,
hookTimeout: 30_000,
});