Initial Commit
This commit is contained in:
81
packages/media/audio/audio-codec-ffmpeg-native/README.md
Normal file
81
packages/media/audio/audio-codec-ffmpeg-native/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# @suaveplan/audio-codec-ffmpeg-native - Server-tier ffmpeg-native AudioCodec plugin for @suaveplan/audio-codecs: spawns the system ffmpeg binary via fluent-ffmpeg, streams stdin/stdout for true chunked decode/encode of wav/mp3/ogg/opus/flac/aac/webm, bounded concurrency, AbortSignal cleanup with SIGTERM->SIGKILL escalation, and a one-line register(registry) helper.
|
||||
|
||||
Server-tier `AudioCodec` plugin for `@suaveplan/audio-codecs` that shells out
|
||||
to the system `ffmpeg` binary via `fluent-ffmpeg` and streams audio through
|
||||
the subprocess' stdin/stdout. Spec ID: `enterprise-audio-codec-ffmpeg-native`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [binary](./src/binary/binary.md) — This module owns ffmpeg-binary discovery for `@suaveplan/audio-codec-ffmpeg-native`. It implements a fixed four-step pr…
|
||||
- [binary](./src/binary/binary.md) — This module owns ffmpeg-binary discovery for `@suaveplan/audio-codec-ffmpeg-native`. It implements a fixed four-step pr…
|
||||
- [codec](./src/codec/codec.md) — `FfmpegNativeCodec` is the server-tier concrete `AudioCodec` implementation that backs `@suaveplan/audio-codec-ffmpeg-n…
|
||||
- [codec](./src/codec/codec.md) — `FfmpegNativeCodec` is the server-tier concrete `AudioCodec` implementation that backs `@suaveplan/audio-codec-ffmpeg-n…
|
||||
- [pcm](./src/codec/pcm.md) — PCM helper module for `@suaveplan/audio-codec-ffmpeg-native`. The ffmpeg subprocess speaks `f32le` (planar-interleaved…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, namespaced error-code registry for `@suaveplan/audio-codec-ffmpeg-native`. Every operation…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable, namespaced error-code registry for `@suaveplan/audio-codec-ffmpeg-native`. Every operation…
|
||||
- [register](./src/register/register.md) — `register(registry, options?)` is the one-line entry point applications use to wire `@suaveplan/audio-codec-ffmpeg-nati…
|
||||
- [register](./src/register/register.md) — `register(registry, options?)` is the one-line entry point applications use to wire `@suaveplan/audio-codec-ffmpeg-nati…
|
||||
- [runner](./src/runner/runner.md) — This module is the single place in `@suaveplan/audio-codec-ffmpeg-native` that imports `node:child_process`. It owns sp…
|
||||
- [runner](./src/runner/runner.md) — This module is the single place in `@suaveplan/audio-codec-ffmpeg-native` that imports `node:child_process`. It owns sp…
|
||||
- [stream-decoder](./src/stream/stream-decoder.md) — `createStreamingDecoder` returns a `FfmpegStreamingDecoder` — the `@suaveplan/audio-codecs` `StreamingDecoder` plus an…
|
||||
- [stream-encoder](./src/stream/stream-encoder.md) — `createStreamingEncoder` returns a `FfmpegStreamingEncoder` — the `@suaveplan/audio-codecs` `StreamingEncoder` plus an…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/audio-codec-ffmpeg-native @suaveplan/audio-codecs
|
||||
# Optional bundled static binary:
|
||||
bun add @ffmpeg-installer/ffmpeg
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs";
|
||||
import { register } from "@suaveplan/audio-codec-ffmpeg-native";
|
||||
|
||||
const registry = new CodecRegistry();
|
||||
register(registry, { concurrencyLimit: 8, timeoutMs: 30_000 });
|
||||
|
||||
const codec = registry.byName("ffmpeg-native");
|
||||
const decoded = await codec.decode(mp3Bytes);
|
||||
const encoded = await codec.encode(decoded.channelData, {
|
||||
format: "flac",
|
||||
sampleRate: decoded.sampleRate,
|
||||
});
|
||||
```
|
||||
|
||||
## Streaming
|
||||
|
||||
```ts
|
||||
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
|
||||
|
||||
const decoder = new FfmpegNativeCodec().createDecoder({ format: "ogg" });
|
||||
for (const chunk of oggChunks) decoder.write(chunk);
|
||||
const trailing = await decoder.flushAsync(); // additive Promise companion.
|
||||
decoder.close();
|
||||
```
|
||||
|
||||
`flushAsync()` is an additive method on the streaming decoder/encoder that
|
||||
the spec asks for, layered over the synchronous base contract from
|
||||
`@suaveplan/audio-codecs`. See `src/stream/*.md` for details.
|
||||
|
||||
## Engines
|
||||
|
||||
Node.js 22+. Server-tier only — no browser support, no `worker_threads`
|
||||
plumbing.
|
||||
|
||||
## Error codes
|
||||
|
||||
Eight codes namespaced under `AUDIO_CODEC_FFMPEG_NATIVE.*`:
|
||||
`BINARY_MISSING`, `SPAWN_FAILED`, `DECODE_FAILED`, `ENCODE_FAILED`,
|
||||
`TIMEOUT`, `ABORTED`, `UNSUPPORTED_CODEC`, `INVALID_PCM`.
|
||||
|
||||
## Status
|
||||
|
||||
`0.1.0` — first-tier landing. Subpath exports cover every implementation
|
||||
module so consumers can tree-shake. See the package's OpenSpec change for
|
||||
the full specification.
|
||||
|
||||
110
packages/media/audio/audio-codec-ffmpeg-native/package.json
Normal file
110
packages/media/audio/audio-codec-ffmpeg-native/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# `binary`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns ffmpeg-binary discovery for `@suaveplan/audio-codec-ffmpeg-native`.
|
||||
It implements a fixed four-step precedence chain — caller-supplied provider,
|
||||
explicit absolute path, dynamically imported `@ffmpeg-installer/ffmpeg`
|
||||
optional dependency, then PATH-based discovery — and memoises successful
|
||||
resolutions by options-object identity so that high-traffic decode/encode
|
||||
pipelines pay the resolution cost exactly once per shared options bundle.
|
||||
|
||||
The resolver throws `AudioCodecFfmpegNativeError("AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING")`
|
||||
when every step fails, which lets the codec surface an actionable error to the
|
||||
caller instead of waiting for a downstream `ENOENT` from `child_process.spawn`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict precedence: provider → explicit path → installer → PATH lookup.
|
||||
- Promise-returning provider supported (sync providers also accepted).
|
||||
- Dynamic `import("@ffmpeg-installer/ffmpeg")` so the optional dependency is
|
||||
never a hard requirement at module load.
|
||||
- Test-injectable `installerLoader` and `pathLookup` hooks decouple resolution
|
||||
from real filesystem state.
|
||||
- `BINARY_MISSING` error code on full-chain failure with no leaked stack from
|
||||
the underlying optional-dep failure.
|
||||
- WeakMap-keyed memoisation that automatically GCs entries when the caller
|
||||
drops their options reference.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveBinary } from "@suaveplan/audio-codec-ffmpeg-native/binary/binary";
|
||||
|
||||
const { path, source } = await resolveBinary();
|
||||
console.log(`Using ffmpeg at ${path} via ${source}`);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { resolveBinary } from "@suaveplan/audio-codec-ffmpeg-native/binary/binary";
|
||||
|
||||
const opts = {
|
||||
binaryProvider: async () => process.env.FFMPEG_PATH ?? "",
|
||||
binaryPath: "/usr/local/bin/ffmpeg",
|
||||
installerLoader: async () => ({ path: "/opt/ffmpeg" }),
|
||||
pathLookup: () => "/usr/bin/ffmpeg",
|
||||
} as const;
|
||||
|
||||
const first = await resolveBinary(opts);
|
||||
const second = await resolveBinary(opts); // memoised — no re-resolution.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `resolveBinary(opts?): Promise<ResolvedBinary>` — returns `{ path, source }`.
|
||||
- `BinaryResolverOptions` — `binaryProvider`, `binaryPath`, `installerLoader`,
|
||||
`pathLookup` (all optional; first match wins per the precedence chain).
|
||||
- `ResolvedBinary.source` — `"provider" | "explicit" | "installer" | "path"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The four-step chain is hard-coded — there is no plugin slot — because deviation
|
||||
in precedence would silently change which binary actually runs on production
|
||||
servers. Empty strings are treated as "miss" so callers can write `() => ""`
|
||||
in a provider to opt into the next strategy without throwing.
|
||||
|
||||
The installer is imported via dynamic `import()` because static imports would
|
||||
make `@ffmpeg-installer/ffmpeg` a hard dependency rather than the optional one
|
||||
the spec requires. Any failure inside the installer load (missing module,
|
||||
platform mismatch, exec-bit stripped) is swallowed and the chain advances —
|
||||
the only surfaced error is `BINARY_MISSING` after the entire chain misses.
|
||||
|
||||
Memoisation uses a `WeakMap` keyed by the options object reference; callers
|
||||
who want forced re-resolution pass a fresh options bundle.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { resolveBinary } from "./binary.js";
|
||||
export type { BinaryResolverOptions, ResolvedBinary } from "./binary.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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";
|
||||
@@ -0,0 +1,79 @@
|
||||
# `pcm`
|
||||
|
||||
## Purpose
|
||||
|
||||
PCM helper module for `@suaveplan/audio-codec-ffmpeg-native`. The ffmpeg
|
||||
subprocess speaks `f32le` (planar-interleaved 32-bit little-endian float) on
|
||||
its stdin and stdout pipes; this module owns the conversion between that wire
|
||||
shape and the channel-major `Float32Array[]` shape that the
|
||||
`@suaveplan/audio-codecs` `DecodeResult` and encode input expect.
|
||||
|
||||
It also owns the conservative magic-byte sniffer used by `canDecode`, kept
|
||||
here because both the codec and the streaming decoder lean on it for input
|
||||
type-checks.
|
||||
|
||||
## Features
|
||||
|
||||
- `validatePcm(channels)` — enforces non-empty channel list, equal channel
|
||||
lengths, raises `INVALID_PCM` on every divergence.
|
||||
- `interleaveF32(channels)` — packs `Float32Array[]` into a single
|
||||
`Uint8Array` view in f32le interleaved order.
|
||||
- `deinterleaveF32(bytes, channels)` — inverse: splits an interleaved
|
||||
`Uint8Array` into `channels` per-channel `Float32Array`s.
|
||||
- `concat(chunks)` — single-pass `Uint8Array` concatenation used to merge
|
||||
stdout chunks.
|
||||
- `sniff(bytes)` — magic-byte format detection for the major container
|
||||
formats (`wav`, `flac`, `ogg`, `mp3`, `matroska`/webm, `m4a`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
interleaveF32,
|
||||
deinterleaveF32,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-native/codec/pcm";
|
||||
|
||||
const bytes = interleaveF32([leftChannel, rightChannel]);
|
||||
const [left, right] = deinterleaveF32(bytes, 2);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
concat,
|
||||
sniff,
|
||||
validatePcm,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-native/codec/pcm";
|
||||
|
||||
const detected = sniff(headerSlice);
|
||||
if (detected === "flac") console.log("FLAC stream confirmed");
|
||||
|
||||
validatePcm(channelData); // throws INVALID_PCM if uneven channels
|
||||
const merged = concat(stdoutChunks);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `validatePcm(channels: readonly Float32Array[]): void`
|
||||
- `interleaveF32(channels: readonly Float32Array[]): Uint8Array`
|
||||
- `deinterleaveF32(bytes: Uint8Array, channels: number): readonly Float32Array[]`
|
||||
- `concat(chunks: readonly Uint8Array[]): Uint8Array`
|
||||
- `sniff(bytes: Uint8Array): string | null`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Interleave allocates one `Float32Array(channels * length)` then writes a
|
||||
column-major copy; the returned `Uint8Array` is a zero-copy view over the
|
||||
same backing buffer, so handing it directly to `child.stdin.write` avoids a
|
||||
second allocation.
|
||||
|
||||
`deinterleaveF32` uses `bytes.buffer.slice(...)` rather than aliasing the
|
||||
incoming buffer because callers frequently hand in `Uint8Array` views over a
|
||||
shared Node `Buffer` pool — aliasing would otherwise yield channels that
|
||||
silently mutate when the pool recycles its slab.
|
||||
|
||||
`sniff` returns `null` (rather than `"unknown"`) so the codec's `canDecode`
|
||||
can short-circuit on a single nullability check. The mp3 sync pattern
|
||||
`0xFF 0xEx` also matches AAC ADTS, which is acceptable because ffmpeg accepts
|
||||
both with the same `pipe:0` decoder argv.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
}
|
||||
});
|
||||
116
packages/media/audio/audio-codec-ffmpeg-native/src/codec/pcm.ts
Normal file
116
packages/media/audio/audio-codec-ffmpeg-native/src/codec/pcm.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the stable, namespaced error-code registry for
|
||||
`@suaveplan/audio-codec-ffmpeg-native`. Every operational failure raised by the
|
||||
package — binary-resolution mishaps, spawn/IO errors, codec-level decode/encode
|
||||
failures, lifecycle events (timeout, abort) and input-validation rejections —
|
||||
flows through a single `AudioCodecFfmpegNativeError` typed subclass whose code
|
||||
field is narrowed to the union of `AUDIO_CODEC_FFMPEG_NATIVE.*` strings.
|
||||
|
||||
This centralisation lets consumers do exhaustive `switch (err.code)` dispatch
|
||||
with TypeScript checking, and it gives observability the canonical label set to
|
||||
attribute span events and metric counters against without per-call-site
|
||||
stringly-typed labels.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight stable codes covering binary, spawn, decode, encode, timeout, abort,
|
||||
unsupported-codec and invalid-PCM failure modes.
|
||||
- `defineCodes("AUDIO_CODEC_FFMPEG_NATIVE", [...])` produces frozen
|
||||
`<namespace>.<reason>` constants and a precise literal-union type.
|
||||
- `AudioCodecFfmpegNativeError` subclass of `SuaveplanError` preserves
|
||||
`cause`, `context`, `traceId`, and the inherited timestamp.
|
||||
- `AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA` records per-code description +
|
||||
severity for human-readable surfacing (logs, dashboards).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
|
||||
AudioCodecFfmpegNativeError,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-native/error-codes";
|
||||
|
||||
throw new AudioCodecFfmpegNativeError(
|
||||
AUDIO_CODEC_FFMPEG_NATIVE_CODES.BINARY_MISSING,
|
||||
"ffmpeg-native.binary.missing",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
|
||||
AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA,
|
||||
AudioCodecFfmpegNativeError,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-native/error-codes";
|
||||
|
||||
try {
|
||||
await someFfmpegOperation();
|
||||
} catch (cause) {
|
||||
const code = AUDIO_CODEC_FFMPEG_NATIVE_CODES.DECODE_FAILED;
|
||||
throw new AudioCodecFfmpegNativeError(code, "ffmpeg-native.decode.failed", {
|
||||
cause,
|
||||
context: {
|
||||
severity: AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA[code].severity,
|
||||
stderr: "...last 4 KiB of ffmpeg stderr...",
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUDIO_CODEC_FFMPEG_NATIVE_CODES` — frozen record of namespaced codes.
|
||||
- `AudioCodecFfmpegNativeErrorCode` — string-literal union of all codes.
|
||||
- `AUDIO_CODEC_FFMPEG_NATIVE_ERROR_METADATA` — `Record<Code, { description, severity }>`.
|
||||
- `AudioCodecFfmpegNativeError` — concrete `SuaveplanError` subclass; constructor
|
||||
takes `(code, messageKey, options)`; `options.context.stderr` carries the
|
||||
ffmpeg stderr tail when available.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The eight codes are intentionally minimal: spec-level failure scenarios collapse
|
||||
into one of these eight. `BINARY_MISSING` is reserved for the resolution
|
||||
preflight, `SPAWN_FAILED` for `child_process.spawn` errors before any output is
|
||||
read, and `DECODE_FAILED` / `ENCODE_FAILED` are the post-spawn ffmpeg-exit
|
||||
failures. `TIMEOUT` and `ABORTED` share the SIGTERM-then-SIGKILL escalation
|
||||
path; only their code distinguishes the trigger.
|
||||
|
||||
`AudioCodecFfmpegNativeError` deliberately mirrors the `AudioCodecsError`
|
||||
shape (public constructor + `category` literal + `name` override) so that
|
||||
multi-codec error funnels can discriminate by `category` without
|
||||
runtime-importing every package.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
51
packages/media/audio/audio-codec-ffmpeg-native/src/index.ts
Normal file
51
packages/media/audio/audio-codec-ffmpeg-native/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { register } from "./register.js";
|
||||
export type { RegisterOptions, RegistryLike } from "./register.types.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# `register`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`register(registry, options?)` is the one-line entry point applications use
|
||||
to wire `@suaveplan/audio-codec-ffmpeg-native` into a
|
||||
`@suaveplan/audio-codecs` `CodecRegistry`. It encapsulates construction of a
|
||||
`FfmpegNativeCodec` with the given options and the structural `register`
|
||||
call against the registry, returning the constructed codec for callers that
|
||||
want to retain a direct reference (for later inspection, instrumentation, or
|
||||
unregistration).
|
||||
|
||||
This helper exists so that downstream services do not need to import and
|
||||
construct `FfmpegNativeCodec` directly; the import shape mirrors every other
|
||||
codec plugin (`@suaveplan/audio-codecs` siblings, future
|
||||
`audio-codec-ffmpeg-wasm`) for ergonomic consistency.
|
||||
|
||||
## Features
|
||||
|
||||
- Single function call, single import.
|
||||
- Accepts the full `FfmpegNativeCodecOptions` bundle — binary providers,
|
||||
concurrency, timeout, sample rate, etc.
|
||||
- Optional `spawn` injection point for tests that want to register a codec
|
||||
driven by a fake subprocess.
|
||||
- Returns the constructed `FfmpegNativeCodec` for chained registry hooks.
|
||||
- Operates against a structural `RegistryLike` view so registries from
|
||||
different bundle versions remain interoperable.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs";
|
||||
import { register } from "@suaveplan/audio-codec-ffmpeg-native/register/register";
|
||||
|
||||
const registry = new CodecRegistry();
|
||||
register(registry);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs";
|
||||
import { register } from "@suaveplan/audio-codec-ffmpeg-native/register/register";
|
||||
|
||||
const registry = new CodecRegistry();
|
||||
const codec = register(registry, {
|
||||
binaryProvider: () => process.env.FFMPEG_PATH ?? "",
|
||||
concurrencyLimit: 16,
|
||||
timeoutMs: 30_000,
|
||||
defaultEncodeFormat: "opus",
|
||||
});
|
||||
|
||||
// Optionally swap the codec out later (multi-tenant test seam):
|
||||
registry.unregister(codec.name);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `register(registry, options?, spawn?): FfmpegNativeCodec`
|
||||
- `RegisterOptions` — alias for `FfmpegNativeCodecOptions`.
|
||||
- `RegistryLike` — `{ register(codec: AudioCodec): void }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function is intentionally a thin wrapper — it does not memoise, throttle,
|
||||
or de-duplicate calls, because doing so would silently override the
|
||||
registry's own replace-on-name-collision semantics documented in
|
||||
`@suaveplan/audio-codecs`. Each `register` call constructs a fresh codec; if
|
||||
the caller wants a singleton, they keep the returned reference around.
|
||||
|
||||
The `spawn` parameter is positional rather than nested inside `options` so
|
||||
that the public surface in production code remains a clean two-argument
|
||||
shape, and the test-only seam is opt-in.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
export { runDecode, runEncode } from "./runner.js";
|
||||
export type {
|
||||
RunDecodeOptions,
|
||||
RunEncodeOptions,
|
||||
RunnerCommon,
|
||||
RunResult,
|
||||
Spawn,
|
||||
} from "./runner.types.js";
|
||||
@@ -0,0 +1,99 @@
|
||||
# `runner`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the single place in `@suaveplan/audio-codec-ffmpeg-native` that
|
||||
imports `node:child_process`. It owns spawning ffmpeg, piping bytes through
|
||||
stdin, streaming stdout chunks to a callback (never buffering the full
|
||||
output), enforcing optional timeouts with a SIGTERM-then-SIGKILL escalation,
|
||||
honouring caller-supplied AbortSignals with the same escalation path, and
|
||||
mapping all post-spawn failure modes onto the typed
|
||||
`AudioCodecFfmpegNativeError` codes.
|
||||
|
||||
Centralising spawn lifecycle here means decode, encode and the streaming
|
||||
adapters never re-implement timeout/abort/exit handling — they only describe
|
||||
the ffmpeg argv they want to run.
|
||||
|
||||
## Features
|
||||
|
||||
- `runDecode` — feeds a single `Uint8Array` into stdin then ends it.
|
||||
- `runEncode` — drains an `AsyncIterable<Uint8Array>` into stdin with
|
||||
backpressure-aware `drain` waits.
|
||||
- `onStdout(chunk)` callback called per stdout chunk; nothing is buffered
|
||||
inside the runner.
|
||||
- Optional `timeoutMs` triggers SIGTERM with a 250 ms grace window before
|
||||
SIGKILL.
|
||||
- AbortSignal listeners use `{ once: true }` and are removed on settle.
|
||||
- Rolling stderr ring buffer (default 4 KiB) preserved on
|
||||
`error.context.stderr`.
|
||||
- Injectable `spawn` for tests; defaults to `node:child_process.spawn`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runDecode } from "@suaveplan/audio-codec-ffmpeg-native/runner/runner";
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
await runDecode({
|
||||
binaryPath: "/usr/bin/ffmpeg",
|
||||
args: ["-i", "pipe:0", "-f", "f32le", "-ac", "1", "pipe:1"],
|
||||
input: compressedBytes,
|
||||
onStdout: (chunk) => chunks.push(chunk),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { runEncode } from "@suaveplan/audio-codec-ffmpeg-native/runner/runner";
|
||||
|
||||
async function* pcm(): AsyncIterable<Uint8Array> {
|
||||
yield interleaveFloat32(channelDataA);
|
||||
yield interleaveFloat32(channelDataB);
|
||||
}
|
||||
|
||||
const out: Uint8Array[] = [];
|
||||
const controller = new AbortController();
|
||||
const result = await runEncode({
|
||||
binaryPath: "/opt/ffmpeg",
|
||||
args: ["-f", "f32le", "-ar", "48000", "-ac", "2", "-i", "pipe:0", "-f", "flac", "pipe:1"],
|
||||
inputs: pcm(),
|
||||
onStdout: (chunk) => out.push(chunk),
|
||||
signal: controller.signal,
|
||||
timeoutMs: 30_000,
|
||||
stderrCap: 8192,
|
||||
});
|
||||
console.log("stderr tail:", result.stderr);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runDecode(opts: RunDecodeOptions): Promise<RunResult>`
|
||||
- `runEncode(opts: RunEncodeOptions): Promise<RunResult>`
|
||||
- `RunnerCommon` — `binaryPath`, `args`, `signal?`, `timeoutMs?`, `spawn?`, `stderrCap?`.
|
||||
- `RunResult` — `{ stderr, exitCode }` (exitCode is always `0` on resolve).
|
||||
- Error codes raised: `SPAWN_FAILED`, `DECODE_FAILED`, `ENCODE_FAILED`,
|
||||
`TIMEOUT`, `ABORTED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The single shared `settle` guard guarantees the returned promise resolves or
|
||||
rejects exactly once even when stdin pump, child `error` and `close` events
|
||||
all fire in close succession. `child.on("error", ...)` is the spawn-time
|
||||
failure path (ENOENT/EACCES); `child.on("close", ...)` is the post-exit
|
||||
discriminator that funnels exit codes into `DECODE_FAILED` / `ENCODE_FAILED`
|
||||
depending on which entry point caller used.
|
||||
|
||||
Timeout and abort both go through the same `killEscalate` helper. The 250 ms
|
||||
grace window is deliberately short — ffmpeg honours SIGTERM cleanly in the
|
||||
common case, and on misbehaving builds SIGKILL guarantees process death
|
||||
without leaking PIDs. `timeout?.unref?.()` keeps the Node event loop from
|
||||
being kept alive by the timer.
|
||||
|
||||
The stderr ring buffer keeps the **tail** of stderr because ffmpeg emits its
|
||||
informative diagnostics (codec banner, then per-frame progress) at the
|
||||
**start** of a successful run but the **end** of a failing run.
|
||||
@@ -0,0 +1,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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,80 @@
|
||||
# `stream-decoder`
|
||||
|
||||
## Purpose
|
||||
|
||||
`createStreamingDecoder` returns a `FfmpegStreamingDecoder` — the
|
||||
`@suaveplan/audio-codecs` `StreamingDecoder` plus an additive Promise-returning
|
||||
`flushAsync()` companion. It is backed by a single long-running ffmpeg child
|
||||
process per decoder instance, lazily spawned on the first `write` so that a
|
||||
decoder constructed but never fed never spends a PID.
|
||||
|
||||
`write` and `flush` honour the underlying interface's synchronous contract by
|
||||
returning whatever pages have already been buffered out of stdout; `flushAsync`
|
||||
is the safe end-of-stream method that ends stdin, awaits the child to exit,
|
||||
and yields any trailing pages.
|
||||
|
||||
## Features
|
||||
|
||||
- Single persistent ffmpeg subprocess per decoder.
|
||||
- Lazy spawn — the child is only created once the first chunk arrives.
|
||||
- Sync `write(chunk): DecodeResult[]` returning all pages buffered to date.
|
||||
- Sync `flush(): DecodeResult[]` for callers that pump synchronously.
|
||||
- Additive `flushAsync(): Promise<DecodeResult[]>` for safe end-of-stream
|
||||
semantics (mandatory whenever the caller wants the trailing decoded frames).
|
||||
- `AbortSignal` and `timeoutMs` integrated — both raise the canonical
|
||||
`ABORTED` / `TIMEOUT` codes on the next `flushAsync` awaited.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
|
||||
|
||||
const decoder = new FfmpegNativeCodec().createDecoder({ format: "ogg" });
|
||||
for (const chunk of oggChunks) decoder.write(chunk);
|
||||
const pages = await decoder.flushAsync();
|
||||
decoder.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FfmpegNativeCodec } from "@suaveplan/audio-codec-ffmpeg-native";
|
||||
|
||||
const codec = new FfmpegNativeCodec({ channels: 2, sampleRate: 48_000 });
|
||||
const controller = new AbortController();
|
||||
const decoder = codec.createDecoder({ signal: controller.signal });
|
||||
try {
|
||||
for await (const chunk of network()) decoder.write(chunk);
|
||||
const tail = await decoder.flushAsync();
|
||||
process.stdout.write(`decoded ${tail.length} trailing pages\n`);
|
||||
} finally {
|
||||
decoder.close();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createStreamingDecoder(opts: StreamingDecoderOptions): FfmpegStreamingDecoder`
|
||||
- `FfmpegStreamingDecoder` — extends `StreamingDecoder` with
|
||||
`flushAsync(): Promise<readonly DecodeResult[]>`.
|
||||
- `StreamingDecoderOptions` — `binaryProvider`, `args`, `channels`,
|
||||
`sampleRate`, `format`, optional `signal`, `timeoutMs`, `spawn`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `@suaveplan/audio-codecs` `StreamingDecoder` contract is synchronous —
|
||||
`write(chunk): readonly DecodeResult[]`. Since the underlying ffmpeg
|
||||
subprocess is fundamentally asynchronous, the synchronous `write` returns
|
||||
only the pages already buffered from earlier `stdout data` events; the
|
||||
new chunk is piped into stdin in the same call but its decoded output
|
||||
arrives in subsequent ticks.
|
||||
|
||||
This is why `flushAsync` exists and why the package docs guide callers to
|
||||
prefer it. The base `flush` is still useful for callers driving the decoder
|
||||
from an event loop tick that already drained stdout (e.g. a `WritableStream`
|
||||
pipe).
|
||||
|
||||
Frame alignment matters: ffmpeg's `-f f32le` output is a stream of
|
||||
4-byte-per-sample, channels-interleaved frames. The sync drain only
|
||||
de-interleaves a whole number of frames and re-buffers any trailing bytes
|
||||
for the next call, so partial chunks never corrupt the channel split.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./tsconfig.lib.json"
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
testTimeout: 30_000,
|
||||
hookTimeout: 30_000,
|
||||
});
|
||||
Reference in New Issue
Block a user