Initial Commit
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# `ffmpeg-wasm-codec.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module assembles the public `FfmpegWasmCodec` class — the browser-tier
|
||||
`AudioCodec` plugin for `@suaveplan/audio-codecs`. The class wires Zod
|
||||
boundary validation, the lazy core loader, the worker host, the decode /
|
||||
encode pipelines, the streaming decoder / encoder factories, and the
|
||||
OpenTelemetry instrumentation into a single object that satisfies the
|
||||
`AudioCodec` contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Constructor validates options against `FfmpegWasmCodecOptionsSchema`;
|
||||
malformed options throw `UNSUPPORTED_CODEC` with the Zod issue list.
|
||||
- `canDecode` sniffs magic bytes against the supported format set.
|
||||
- `decode` / `encode` lazy-load the wasm core and the worker, run the
|
||||
pipeline, emit spans, increment counters, record histograms.
|
||||
- `createDecoder` / `createEncoder` return the streaming wrappers
|
||||
defined in `streaming/`.
|
||||
- `dispose()` terminates the worker and releases the loaded core.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FfmpegWasmCodec } from "@suaveplan/audio-codec-ffmpeg-wasm/codec/ffmpeg-wasm-codec";
|
||||
|
||||
const codec = new FfmpegWasmCodec({ corePath: "/static/ffmpeg-core.js" });
|
||||
const result = await codec.decode(wavBytes);
|
||||
console.log(result.channelData.length);
|
||||
await codec.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FfmpegWasmCodec } from "@suaveplan/audio-codec-ffmpeg-wasm/codec/ffmpeg-wasm-codec";
|
||||
|
||||
const codec = new FfmpegWasmCodec({
|
||||
corePath: "/static/ffmpeg-core-mt.js",
|
||||
enableMultithread: true,
|
||||
progressCallback: (p) => console.log("progress", p),
|
||||
logger: myLogger,
|
||||
clock: () => performance.now(),
|
||||
workerFactory: () => new Worker(coreUrl, { type: "module" }),
|
||||
});
|
||||
try {
|
||||
const result = await codec.decode(buffer, {
|
||||
signal: controller.signal,
|
||||
format: "mp3",
|
||||
});
|
||||
const opus = await codec.encode(result.channelData, {
|
||||
format: "opus",
|
||||
sampleRate: result.sampleRate,
|
||||
bitrate: 96_000,
|
||||
});
|
||||
} finally {
|
||||
await codec.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FfmpegWasmCodec implements AudioCodec`.
|
||||
- `new FfmpegWasmCodec(options?: FfmpegWasmCodecOptions)`.
|
||||
- `codec.name`, `codec.extensions`, `codec.mimeTypes`.
|
||||
- `codec.canDecode(bytes)`.
|
||||
- `codec.decode(bytes, opts?): Promise<DecodeResult>`.
|
||||
- `codec.encode(channelData, opts?): Promise<Uint8Array>`.
|
||||
- `codec.createDecoder(opts?): FfmpegWasmStreamingDecoder`.
|
||||
- `codec.createEncoder(opts?): FfmpegWasmStreamingEncoder`.
|
||||
- `codec.dispose(): Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class holds at most one `WorkerHost` and one `LoadedCore` at a time.
|
||||
`dispose()` is idempotent — calling it twice is a no-op the second time.
|
||||
The OTel spans, counters, and histograms use namespaced names matching
|
||||
the spec (`audio-codec-ffmpeg-wasm.decode`, etc.). Every `decode` and
|
||||
`encode` increments the counter exactly once with an `outcome` label
|
||||
(`ok`, `core-load-failed`, or `error`) so callers can compute per-format
|
||||
failure rates without scraping span events. The progress callback is
|
||||
wired through `WorkerHost.onProgress`, which catches and logs user
|
||||
callback exceptions so a buggy callback cannot abort an in-flight call.
|
||||
@@ -0,0 +1,36 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema mirroring `FfmpegWasmCodecOptions`. Used at the codec
|
||||
* constructor and at the `register(registry, options?)` entry point. Rejects
|
||||
* `coreUrl` and `corePath` set simultaneously via `.superRefine`.
|
||||
*/
|
||||
export const FfmpegWasmCodecOptionsSchema = z
|
||||
.object({
|
||||
coreUrl: z.string().min(1).optional(),
|
||||
corePath: z.string().min(1).optional(),
|
||||
workerFactory: z.function().optional(),
|
||||
coreLoader: z.function().optional(),
|
||||
coreMtLoader: z.function().optional(),
|
||||
enableMultithread: z.boolean().optional(),
|
||||
progressCallback: z.function().optional(),
|
||||
logger: z.unknown().optional(),
|
||||
clock: z.unknown().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
typeof value.coreUrl === "string" &&
|
||||
typeof value.corePath === "string"
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "`coreUrl` and `corePath` are mutually exclusive",
|
||||
path: ["corePath"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type FfmpegWasmCodecOptionsInput = z.input<
|
||||
typeof FfmpegWasmCodecOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,549 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildPcmBytes,
|
||||
FakeWorker,
|
||||
successScript,
|
||||
} from "../../tests/fixtures/fake-worker.js";
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AudioCodecFfmpegWasmError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { FfmpegWasmCodec } from "./ffmpeg-wasm-codec.js";
|
||||
import { FORMAT_MIME, SUPPORTED_FORMATS } from "./ffmpeg-wasm-codec.types.js";
|
||||
|
||||
function buildWav(): Uint8Array {
|
||||
const out = new Uint8Array(44);
|
||||
out[0] = 0x52; // R
|
||||
out[1] = 0x49; // I
|
||||
out[2] = 0x46; // F
|
||||
out[3] = 0x46; // F
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("FfmpegWasmCodec", () => {
|
||||
it("instance satisfies the AudioCodec interface", { timeout: 5000 }, () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
expect(codec.name).toBe("ffmpeg-wasm");
|
||||
expect(codec.extensions.length).toBeGreaterThan(0);
|
||||
expect(codec.mimeTypes.length).toBeGreaterThan(0);
|
||||
expect(typeof codec.canDecode).toBe("function");
|
||||
expect(typeof codec.decode).toBe("function");
|
||||
expect(typeof codec.encode).toBe("function");
|
||||
expect(typeof codec.createDecoder).toBe("function");
|
||||
expect(typeof codec.createEncoder).toBe("function");
|
||||
expect(typeof codec.dispose).toBe("function");
|
||||
// SUPPORTED_FORMATS exposed
|
||||
expect(SUPPORTED_FORMATS).toContain("wav");
|
||||
expect(FORMAT_MIME.wav).toBe("audio/wav");
|
||||
});
|
||||
|
||||
it("canDecode returns true for WAV magic and false for unknown", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
expect(codec.canDecode(buildWav())).toBe(true);
|
||||
expect(codec.canDecode(new Uint8Array([0, 0, 0, 0]))).toBe(false);
|
||||
});
|
||||
|
||||
it("invalid options are rejected at the constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new FfmpegWasmCodec({
|
||||
coreUrl: "https://a/",
|
||||
corePath: "/b/",
|
||||
})
|
||||
).toThrow(AudioCodecFfmpegWasmError);
|
||||
try {
|
||||
new FfmpegWasmCodec({ coreUrl: "https://a/", corePath: "/b/" });
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AudioCodecFfmpegWasmError);
|
||||
expect((err as AudioCodecFfmpegWasmError).code).toBe(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid types in options throw via Zod", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new FfmpegWasmCodec({
|
||||
enableMultithread: "yes" as unknown as boolean,
|
||||
});
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AudioCodecFfmpegWasmError);
|
||||
expect((err as AudioCodecFfmpegWasmError).code).toBe(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("decode runs through the worker and returns a well-formed DecodeResult", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const pcm = buildPcmBytes(16, 2);
|
||||
worker.script.push(...successScript(pcm));
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
const result = await codec.decode(buildWav(), { format: "wav" });
|
||||
expect(result.format).toBe("wav");
|
||||
expect(result.channelData.length).toBe(2);
|
||||
await codec.dispose();
|
||||
});
|
||||
|
||||
it("decode infers format from magic bytes when opts.format is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(buildPcmBytes(4, 2)));
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
const result = await codec.decode(buildWav());
|
||||
expect(result.format).toBe("wav");
|
||||
});
|
||||
|
||||
it("decode of unrecognised bytes throws UNSUPPORTED_CODEC", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
await expect(
|
||||
codec.decode(new Uint8Array([0, 0, 0, 0]))
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode handles ArrayBuffer input", { timeout: 5000 }, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(buildPcmBytes(4, 1)));
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
const wavBytes = buildWav();
|
||||
const buf = wavBytes.buffer.slice(
|
||||
wavBytes.byteOffset,
|
||||
wavBytes.byteOffset + wavBytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
const result = await codec.decode(buf, { format: "wav" });
|
||||
expect(result.format).toBe("wav");
|
||||
});
|
||||
|
||||
it("decode surfaces core-load failure as CORE_LOAD_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.reject(new Error("ENOENT")),
|
||||
});
|
||||
await expect(
|
||||
codec.decode(buildWav(), { format: "wav" })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.CORE_LOAD_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("decode surfaces worker decode failure as DECODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // writeFile
|
||||
worker.script.push((req) => ({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
stderr: "broken",
|
||||
}));
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
await expect(
|
||||
codec.decode(buildWav(), { format: "wav" })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("encode runs through the worker and returns encoded bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const out = new Uint8Array([0xff, 0xfb]);
|
||||
worker.script.push(...successScript(out));
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
const result = await codec.encode([new Float32Array([0.1, 0.2])], {
|
||||
format: "mp3",
|
||||
sampleRate: 44_100,
|
||||
bitrate: 128_000,
|
||||
} as never);
|
||||
expect(result).toEqual(out);
|
||||
await codec.dispose();
|
||||
});
|
||||
|
||||
it("encode without format defaults to wav", { timeout: 5000 }, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(new Uint8Array([1, 2])));
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
const result = await codec.encode([new Float32Array([0.1])]);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("encode rejects unsupported format synchronously", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
await expect(
|
||||
codec.encode([new Float32Array([0.1])], {
|
||||
format: "alac",
|
||||
} as never)
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
});
|
||||
});
|
||||
|
||||
it("encode surfaces core-load failure as CORE_LOAD_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.reject(new Error("ENOENT")),
|
||||
});
|
||||
await expect(
|
||||
codec.encode([new Float32Array([0.1])], {
|
||||
format: "wav",
|
||||
} as never)
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.CORE_LOAD_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("encode surfaces worker failure as ENCODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true }));
|
||||
worker.script.push((req) => ({ id: req.id, ok: true }));
|
||||
worker.script.push((req) => ({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
stderr: "encoder failed",
|
||||
}));
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
await expect(
|
||||
codec.encode([new Float32Array([0.1])], {
|
||||
format: "mp3",
|
||||
} as never)
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.ENCODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("createDecoder returns a streaming decoder; unsupported format throws", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
workerFactory: () => new FakeWorker() as unknown as Worker,
|
||||
});
|
||||
const dec = codec.createDecoder({ format: "wav" });
|
||||
expect(dec).toBeDefined();
|
||||
expect(() => codec.createDecoder({ format: "xyz" as never })).toThrow(
|
||||
AudioCodecFfmpegWasmError
|
||||
);
|
||||
});
|
||||
|
||||
it("createEncoder returns a streaming encoder; unsupported format throws", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
workerFactory: () => new FakeWorker() as unknown as Worker,
|
||||
});
|
||||
const enc = codec.createEncoder({ format: "wav" } as never);
|
||||
expect(enc).toBeDefined();
|
||||
expect(() => codec.createEncoder({ format: "xyz" } as never)).toThrow(
|
||||
AudioCodecFfmpegWasmError
|
||||
);
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
await codec.dispose();
|
||||
await codec.dispose();
|
||||
});
|
||||
|
||||
it("dispose tears down active host", { timeout: 5000 }, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(buildPcmBytes(2, 1)));
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
await codec.decode(buildWav(), { format: "wav" });
|
||||
await codec.dispose();
|
||||
expect(worker.terminated).toBe(1);
|
||||
});
|
||||
|
||||
it("decode uses injected clock for histograms", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(buildPcmBytes(2, 1)));
|
||||
let now = 0;
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
clock: () => {
|
||||
now += 5;
|
||||
return now;
|
||||
},
|
||||
});
|
||||
await codec.decode(buildWav(), { format: "wav" });
|
||||
});
|
||||
|
||||
it("progress callback receives values forwarded by host (non-throwing)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(buildPcmBytes(2, 1)));
|
||||
const seen: number[] = [];
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
progressCallback: (p) => seen.push(p),
|
||||
});
|
||||
// Fire progress at the boundary directly through the worker shim.
|
||||
const decodePromise = codec.decode(buildWav(), { format: "wav" });
|
||||
// Yield a few microtasks so the worker host is constructed and its
|
||||
// listeners are attached before we emit the progress event.
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
worker.emitMessage({ kind: "progress", progress: 0.5 });
|
||||
await decodePromise;
|
||||
expect(seen).toContain(0.5);
|
||||
await codec.dispose();
|
||||
});
|
||||
|
||||
it("progress callback exception is swallowed (logged)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(buildPcmBytes(2, 1)));
|
||||
const warnings: string[] = [];
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
progressCallback: () => {
|
||||
throw new Error("oops");
|
||||
},
|
||||
logger: {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn: (m) => warnings.push(m),
|
||||
error() {},
|
||||
},
|
||||
});
|
||||
const decodePromise = codec.decode(buildWav(), { format: "wav" });
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
worker.emitMessage({ kind: "progress", progress: 0.3 });
|
||||
await decodePromise;
|
||||
expect(warnings.some((w) => w.includes("progress-threw"))).toBe(true);
|
||||
await codec.dispose();
|
||||
});
|
||||
|
||||
it("constructor accepts undefined options (no argument)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codec = new FfmpegWasmCodec();
|
||||
expect(codec.name).toBe("ffmpeg-wasm");
|
||||
});
|
||||
|
||||
it("createDecoder defaults to wav when format omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
workerFactory: () => new FakeWorker() as unknown as Worker,
|
||||
});
|
||||
const dec = codec.createDecoder();
|
||||
expect(dec).toBeDefined();
|
||||
});
|
||||
|
||||
it("createEncoder defaults to wav when format omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
workerFactory: () => new FakeWorker() as unknown as Worker,
|
||||
});
|
||||
const enc = codec.createEncoder();
|
||||
expect(enc).toBeDefined();
|
||||
});
|
||||
|
||||
it("lazyHost path: corePath supplied", { timeout: 5000 }, () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
corePath: "/cp.js",
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
try {
|
||||
codec.createDecoder({ format: "wav" });
|
||||
} catch {
|
||||
// OK
|
||||
}
|
||||
});
|
||||
|
||||
it("lazyHost path: coreUrl supplied without corePath", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreUrl: "https://x.example/c.js",
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
try {
|
||||
codec.createDecoder({ format: "wav" });
|
||||
} catch {
|
||||
// OK
|
||||
}
|
||||
});
|
||||
|
||||
it("lazyHost reuses existing host on second createDecoder call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
workerFactory: () => new FakeWorker() as unknown as Worker,
|
||||
});
|
||||
const d1 = codec.createDecoder({ format: "wav" });
|
||||
const d2 = codec.createDecoder({ format: "mp3" });
|
||||
expect(d1).not.toBe(d2);
|
||||
// Both decoders share the same internal host — the truthy `if`
|
||||
// branch in #lazyHost fires for the second call.
|
||||
});
|
||||
|
||||
it("lazyHost path: neither corePath nor coreUrl — empty string fallback", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
try {
|
||||
codec.createDecoder({ format: "wav" });
|
||||
} catch {
|
||||
// OK — happy-dom Worker construction with empty URL may throw.
|
||||
}
|
||||
});
|
||||
|
||||
it("decode without workerFactory falls through #ensureHost's default factory branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const codec = new FfmpegWasmCodec({
|
||||
corePath: "/__nonexistent_decode__.js",
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
// The default Worker constructor in happy-dom may either work
|
||||
// silently (Worker is a polyfill) or throw — either path runs the
|
||||
// `#ensureHost` branch where `defaultWorkerFactory` is constructed.
|
||||
try {
|
||||
await codec.decode(buildWav(), { format: "wav" });
|
||||
} catch {
|
||||
// The Worker URL points to nowhere; the host will eventually
|
||||
// fail. Either outcome covers the default-factory branch.
|
||||
}
|
||||
await codec.dispose();
|
||||
});
|
||||
|
||||
it("default workerFactory path is reached via createDecoder + createEncoder", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// No `workerFactory` — lazyHost falls back to defaultWorkerFactory.
|
||||
// Use a coreLoader so the codec construction stays cheap, and only
|
||||
// touch createDecoder/createEncoder (which don't actually load).
|
||||
const codec1 = new FfmpegWasmCodec({
|
||||
corePath: "/__nonexistent_st__.js",
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
try {
|
||||
codec1.createDecoder({ format: "wav" });
|
||||
} catch {
|
||||
// OK — Worker construction may throw in happy-dom.
|
||||
}
|
||||
const codec2 = new FfmpegWasmCodec({
|
||||
coreUrl: "https://example.com/c.js",
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
try {
|
||||
codec2.createEncoder({ format: "wav" } as never);
|
||||
} catch {
|
||||
// OK.
|
||||
}
|
||||
const codec3 = new FfmpegWasmCodec({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
try {
|
||||
codec3.createDecoder({ format: "wav" });
|
||||
} catch {
|
||||
// OK.
|
||||
}
|
||||
});
|
||||
|
||||
it("default workerFactory constructs a real Worker when none is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// No `workerFactory` override — the default factory runs and tries to
|
||||
// construct `new Worker(corePath)`. Happy-dom either succeeds or
|
||||
// throws; either way, the lazy path is exercised. We invoke
|
||||
// `createDecoder` which calls `#lazyHost` → `defaultWorkerFactory`.
|
||||
const codec = new FfmpegWasmCodec({
|
||||
corePath: "/__nonexistent__.js",
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
try {
|
||||
// Some happy-dom builds throw on `new Worker(...)` with a 404 URL;
|
||||
// others succeed silently. Either outcome covers the factory call.
|
||||
codec.createDecoder({ format: "wav" });
|
||||
} catch {
|
||||
// The factory threw; that's OK — the closure was still invoked.
|
||||
}
|
||||
await codec.dispose();
|
||||
});
|
||||
|
||||
it("reuses the loaded core across calls", { timeout: 5000 }, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(buildPcmBytes(2, 1)));
|
||||
worker.script.push(...successScript(buildPcmBytes(2, 1)).slice(1)); // load already done
|
||||
let loaderCalls = 0;
|
||||
const codec = new FfmpegWasmCodec({
|
||||
workerFactory: () => worker as unknown as Worker,
|
||||
coreLoader: () => {
|
||||
loaderCalls++;
|
||||
return Promise.resolve({});
|
||||
},
|
||||
});
|
||||
await codec.decode(buildWav(), { format: "wav" });
|
||||
await codec.decode(buildWav(), { format: "wav" });
|
||||
expect(loaderCalls).toBe(1);
|
||||
await codec.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { metrics, SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import type {
|
||||
AudioCodec,
|
||||
DecodeOpts,
|
||||
DecodeResult,
|
||||
EncodeOpts,
|
||||
} from "@suaveplan/audio-codecs";
|
||||
import { loadCore } from "../core-loader/core-loader.js";
|
||||
import type { LoadedCore } from "../core-loader/core-loader.types.js";
|
||||
import { runDecode, sniffFormat } from "../decode/decode-pipeline.js";
|
||||
import { runEncode } from "../encode/encode-pipeline.js";
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AudioCodecFfmpegWasmError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
FfmpegWasmStreamingDecoder,
|
||||
type StreamingDecoderOptions,
|
||||
} from "../streaming/streaming-decoder.js";
|
||||
import {
|
||||
FfmpegWasmStreamingEncoder,
|
||||
type StreamingEncoderOptions,
|
||||
} from "../streaming/streaming-encoder.js";
|
||||
import { WorkerHost } from "../worker/worker-host.js";
|
||||
import type { WorkerLike } from "../worker/worker-host.types.js";
|
||||
import { FfmpegWasmCodecOptionsSchema } from "./ffmpeg-wasm-codec.schemas.js";
|
||||
import {
|
||||
type FfmpegWasmCodecOptions,
|
||||
FORMAT_MIME,
|
||||
SUPPORTED_FORMATS,
|
||||
type SupportedFormat,
|
||||
} from "./ffmpeg-wasm-codec.types.js";
|
||||
|
||||
const TRACER = trace.getTracer("@suaveplan/audio-codec-ffmpeg-wasm");
|
||||
const METER = metrics.getMeter("@suaveplan/audio-codec-ffmpeg-wasm");
|
||||
const DECODE_COUNTER = METER.createCounter(
|
||||
"audio_codec_ffmpeg_wasm_decode_total",
|
||||
{ description: "Decoded inputs by format and outcome" }
|
||||
);
|
||||
const ENCODE_COUNTER = METER.createCounter(
|
||||
"audio_codec_ffmpeg_wasm_encode_total",
|
||||
{ description: "Encoded outputs by format and outcome" }
|
||||
);
|
||||
const DECODE_HIST = METER.createHistogram(
|
||||
"audio_codec_ffmpeg_wasm_decode_duration_ms",
|
||||
{ description: "Decode wall-clock duration in milliseconds" }
|
||||
);
|
||||
const ENCODE_HIST = METER.createHistogram(
|
||||
"audio_codec_ffmpeg_wasm_encode_duration_ms",
|
||||
{ description: "Encode wall-clock duration in milliseconds" }
|
||||
);
|
||||
|
||||
function defaultWorkerFactory(coreUrl: string): () => WorkerLike {
|
||||
return () => new Worker(coreUrl) as unknown as WorkerLike;
|
||||
}
|
||||
|
||||
function defaultClock(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function validateOptions(
|
||||
raw: FfmpegWasmCodecOptions | undefined
|
||||
): FfmpegWasmCodecOptions {
|
||||
const input = raw ?? {};
|
||||
const parsed = FfmpegWasmCodecOptionsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
"audio-codec-ffmpeg-wasm.options.invalid",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-tier `AudioCodec` implementation backed by ffmpeg.wasm running in
|
||||
* a dedicated Web Worker. Lazy-loads the wasm core on first use; reuses it
|
||||
* across subsequent calls; tears down everything on `dispose()`.
|
||||
*/
|
||||
export class FfmpegWasmCodec implements AudioCodec {
|
||||
public readonly name = "ffmpeg-wasm";
|
||||
public readonly extensions: readonly string[] =
|
||||
SUPPORTED_FORMATS as readonly string[];
|
||||
public readonly mimeTypes: readonly string[] = SUPPORTED_FORMATS.map(
|
||||
(f) => FORMAT_MIME[f]
|
||||
);
|
||||
|
||||
readonly #opts: FfmpegWasmCodecOptions;
|
||||
#host: WorkerHost | undefined;
|
||||
#core: LoadedCore | undefined;
|
||||
|
||||
public constructor(options?: FfmpegWasmCodecOptions) {
|
||||
this.#opts = validateOptions(options);
|
||||
}
|
||||
|
||||
/** Magic-byte sniff against the supported set. */
|
||||
public canDecode(bytes: Uint8Array): boolean {
|
||||
const fmt = sniffFormat(bytes);
|
||||
if (!fmt) return false;
|
||||
return (SUPPORTED_FORMATS as readonly string[]).includes(fmt);
|
||||
}
|
||||
|
||||
public async decode(
|
||||
bytes: Uint8Array | ArrayBuffer,
|
||||
opts: DecodeOpts = {}
|
||||
): Promise<DecodeResult> {
|
||||
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
const fmt = opts.format ?? sniffFormat(u8);
|
||||
if (!fmt || !(SUPPORTED_FORMATS as readonly string[]).includes(fmt)) {
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
"audio-codec-ffmpeg-wasm.decode.unsupported-format",
|
||||
{ context: { format: fmt } }
|
||||
);
|
||||
}
|
||||
const format = fmt as SupportedFormat;
|
||||
const span = TRACER.startSpan("audio-codec-ffmpeg-wasm.decode");
|
||||
span.setAttribute("format", format);
|
||||
const clock = this.#opts.clock ?? defaultClock;
|
||||
const startedAt = clock();
|
||||
let core: LoadedCore;
|
||||
try {
|
||||
core = await this.#ensureCore();
|
||||
} catch (err) {
|
||||
DECODE_COUNTER.add(1, { format, outcome: "core-load-failed" });
|
||||
span.recordException(err as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
span.end();
|
||||
throw err;
|
||||
}
|
||||
span.setAttribute("multithread", core.multithread);
|
||||
const host = this.#ensureHost(core);
|
||||
try {
|
||||
const result = await runDecode({
|
||||
host,
|
||||
bytes: u8,
|
||||
format,
|
||||
signal: opts.signal,
|
||||
});
|
||||
DECODE_COUNTER.add(1, { format, outcome: "ok" });
|
||||
DECODE_HIST.record(clock() - startedAt, { format });
|
||||
return result;
|
||||
} catch (err) {
|
||||
DECODE_COUNTER.add(1, { format, outcome: "error" });
|
||||
span.recordException(err as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
public async encode(
|
||||
channelData: readonly Float32Array[],
|
||||
opts: EncodeOpts & {
|
||||
format?: SupportedFormat | string;
|
||||
bitrate?: number;
|
||||
quality?: number;
|
||||
} = {}
|
||||
): Promise<Uint8Array> {
|
||||
const fmtRaw = opts.format ?? "wav";
|
||||
if (
|
||||
typeof fmtRaw !== "string" ||
|
||||
!(SUPPORTED_FORMATS as readonly string[]).includes(fmtRaw)
|
||||
) {
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
"audio-codec-ffmpeg-wasm.encode.unsupported-format",
|
||||
{ context: { format: fmtRaw } }
|
||||
);
|
||||
}
|
||||
const format = fmtRaw as SupportedFormat;
|
||||
const sampleRate = opts.sampleRate ?? 48_000;
|
||||
const span = TRACER.startSpan("audio-codec-ffmpeg-wasm.encode");
|
||||
span.setAttribute("format", format);
|
||||
const clock = this.#opts.clock ?? defaultClock;
|
||||
const startedAt = clock();
|
||||
let core: LoadedCore;
|
||||
try {
|
||||
core = await this.#ensureCore();
|
||||
} catch (err) {
|
||||
ENCODE_COUNTER.add(1, { format, outcome: "core-load-failed" });
|
||||
span.recordException(err as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
span.end();
|
||||
throw err;
|
||||
}
|
||||
span.setAttribute("multithread", core.multithread);
|
||||
const host = this.#ensureHost(core);
|
||||
try {
|
||||
const result = await runEncode({
|
||||
host,
|
||||
channelData,
|
||||
format,
|
||||
sampleRate,
|
||||
bitrate: opts.bitrate,
|
||||
quality: opts.quality,
|
||||
signal: opts.signal,
|
||||
});
|
||||
ENCODE_COUNTER.add(1, { format, outcome: "ok" });
|
||||
ENCODE_HIST.record(clock() - startedAt, { format });
|
||||
return result;
|
||||
} catch (err) {
|
||||
ENCODE_COUNTER.add(1, { format, outcome: "error" });
|
||||
span.recordException(err as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory for a streaming decoder. */
|
||||
public createDecoder(
|
||||
opts: { format?: SupportedFormat } & DecodeOpts = {}
|
||||
): FfmpegWasmStreamingDecoder {
|
||||
const format = (opts.format ?? "wav") as SupportedFormat;
|
||||
if (!(SUPPORTED_FORMATS as readonly string[]).includes(format)) {
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
"audio-codec-ffmpeg-wasm.stream.unsupported-format",
|
||||
{ context: { format } }
|
||||
);
|
||||
}
|
||||
const streamOpts: StreamingDecoderOptions = {
|
||||
host: this.#lazyHost(),
|
||||
format,
|
||||
signal: opts.signal,
|
||||
};
|
||||
return new FfmpegWasmStreamingDecoder(streamOpts);
|
||||
}
|
||||
|
||||
/** Factory for a streaming encoder. */
|
||||
public createEncoder(
|
||||
opts: EncodeOpts & {
|
||||
format?: SupportedFormat;
|
||||
bitrate?: number;
|
||||
quality?: number;
|
||||
} = {}
|
||||
): FfmpegWasmStreamingEncoder {
|
||||
const format = (opts.format ?? "wav") as SupportedFormat;
|
||||
if (!(SUPPORTED_FORMATS as readonly string[]).includes(format)) {
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
"audio-codec-ffmpeg-wasm.stream.unsupported-format",
|
||||
{ context: { format } }
|
||||
);
|
||||
}
|
||||
const streamOpts: StreamingEncoderOptions = {
|
||||
host: this.#lazyHost(),
|
||||
format,
|
||||
bitrate: opts.bitrate,
|
||||
quality: opts.quality,
|
||||
signal: opts.signal,
|
||||
};
|
||||
return new FfmpegWasmStreamingEncoder(streamOpts);
|
||||
}
|
||||
|
||||
/** Tear down the worker and release the loaded core. Idempotent. */
|
||||
public async dispose(): Promise<void> {
|
||||
if (this.#host) {
|
||||
this.#host.terminate();
|
||||
this.#host = undefined;
|
||||
}
|
||||
this.#core = undefined;
|
||||
}
|
||||
|
||||
async #ensureCore(): Promise<LoadedCore> {
|
||||
if (this.#core) return this.#core;
|
||||
const core = await loadCore({
|
||||
coreUrl: this.#opts.coreUrl,
|
||||
corePath: this.#opts.corePath,
|
||||
enableMultithread: this.#opts.enableMultithread,
|
||||
logger: this.#opts.logger,
|
||||
coreLoader: this.#opts.coreLoader,
|
||||
coreMtLoader: this.#opts.coreMtLoader,
|
||||
});
|
||||
this.#core = core;
|
||||
return core;
|
||||
}
|
||||
|
||||
#ensureHost(core: LoadedCore): WorkerHost {
|
||||
if (this.#host) return this.#host;
|
||||
const factory =
|
||||
this.#opts.workerFactory ?? defaultWorkerFactory(core.resolvedUrl);
|
||||
const host = new WorkerHost({
|
||||
workerFactory: factory as () => WorkerLike,
|
||||
clock: this.#opts.clock ?? defaultClock,
|
||||
logger: this.#opts.logger,
|
||||
progressCallback: this.#opts.progressCallback,
|
||||
});
|
||||
this.#host = host;
|
||||
return host;
|
||||
}
|
||||
|
||||
#lazyHost(): WorkerHost {
|
||||
if (this.#host) return this.#host;
|
||||
const factory =
|
||||
this.#opts.workerFactory ??
|
||||
defaultWorkerFactory(
|
||||
this.#opts.corePath ?? this.#opts.coreUrl ?? ""
|
||||
);
|
||||
const host = new WorkerHost({
|
||||
workerFactory: factory as () => WorkerLike,
|
||||
clock: this.#opts.clock ?? defaultClock,
|
||||
logger: this.#opts.logger,
|
||||
progressCallback: this.#opts.progressCallback,
|
||||
});
|
||||
this.#host = host;
|
||||
return host;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Supported encode/decode format set. The runtime probe in `core-loader` may
|
||||
* intersect this with the actually-available encoders/decoders reported by the
|
||||
* loaded core; consumers receive the intersection on `codec.extensions`.
|
||||
*/
|
||||
export const SUPPORTED_FORMATS = [
|
||||
"wav",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"opus",
|
||||
"flac",
|
||||
"aac",
|
||||
"webm",
|
||||
] as const;
|
||||
|
||||
export type SupportedFormat = (typeof SUPPORTED_FORMATS)[number];
|
||||
|
||||
/** Mapping from format to canonical MIME type. */
|
||||
export const FORMAT_MIME: Readonly<Record<SupportedFormat, string>> =
|
||||
Object.freeze({
|
||||
wav: "audio/wav",
|
||||
mp3: "audio/mpeg",
|
||||
ogg: "audio/ogg",
|
||||
opus: "audio/opus",
|
||||
flac: "audio/flac",
|
||||
aac: "audio/aac",
|
||||
webm: "audio/webm",
|
||||
});
|
||||
|
||||
/** Construction options for `FfmpegWasmCodec`. */
|
||||
export interface FfmpegWasmCodecOptions {
|
||||
/** CDN URL for the core wasm payload — mutually exclusive with `corePath`. */
|
||||
readonly coreUrl?: string | undefined;
|
||||
/** Local URL for the core wasm payload — mutually exclusive with `coreUrl`. */
|
||||
readonly corePath?: string | undefined;
|
||||
/** Test seam — overrides the default `new Worker(...)` construction. */
|
||||
readonly workerFactory?: (() => Worker) | undefined;
|
||||
/**
|
||||
* Test/advanced seam — overrides the dynamic `import()` chain for the
|
||||
* single-thread `@ffmpeg/core` package.
|
||||
*/
|
||||
readonly coreLoader?: (() => Promise<unknown>) | undefined;
|
||||
/**
|
||||
* Test/advanced seam — overrides the dynamic `import()` for the
|
||||
* multi-threaded `@ffmpeg/core-mt`.
|
||||
*/
|
||||
readonly coreMtLoader?: (() => Promise<unknown>) | undefined;
|
||||
/** Opt into the multi-threaded core. Requires `crossOriginIsolated === true`. */
|
||||
readonly enableMultithread?: boolean | undefined;
|
||||
/** Receives progress in `[0, 1]`. */
|
||||
readonly progressCallback?: ((progress: number) => void) | undefined;
|
||||
/** Structured logger; defaults to a no-op. */
|
||||
readonly logger?: LoggerLike | undefined;
|
||||
/** Injectable clock for OTel histograms and timeout watchdog. */
|
||||
readonly clock?: Clock | undefined;
|
||||
/** Override for `globalThis.crossOriginIsolated` — passthrough to core loader. */
|
||||
readonly crossOriginIsolated?: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-call options shared by `decode`, `encode`, and the streaming flush
|
||||
* methods. Extends the cross-cutting `DecodeOpts`/`EncodeOpts` shape with
|
||||
* `timeoutMs`. Format-specific encode controls (`bitrate`, `quality`) live on
|
||||
* the dedicated encode-opts shape.
|
||||
*/
|
||||
export interface CallTimeouts {
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal description of the loaded core. `multithread === true` means the
|
||||
* `@ffmpeg/core-mt` dynamic import succeeded; otherwise the single-thread
|
||||
* `@ffmpeg/core` is in use.
|
||||
*/
|
||||
export interface LoadedCore {
|
||||
readonly multithread: boolean;
|
||||
readonly resolvedUrl: string;
|
||||
readonly module: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire message exchanged with the worker. Concrete shapes live inside the
|
||||
* worker host; this union is intentionally narrow at the type level — the
|
||||
* runtime payload is validated lazily inside `worker-host.ts`.
|
||||
*/
|
||||
export type WorkerMessage =
|
||||
| { readonly kind: "load" }
|
||||
| { readonly kind: "run"; readonly args: readonly string[] }
|
||||
| {
|
||||
readonly kind: "writeFile";
|
||||
readonly path: string;
|
||||
readonly bytes: Uint8Array;
|
||||
}
|
||||
| { readonly kind: "readFile"; readonly path: string }
|
||||
| { readonly kind: "deleteFile"; readonly path: string }
|
||||
| { readonly kind: "terminate" };
|
||||
@@ -0,0 +1,11 @@
|
||||
export { FfmpegWasmCodec } from "./ffmpeg-wasm-codec.js";
|
||||
export { FfmpegWasmCodecOptionsSchema } from "./ffmpeg-wasm-codec.schemas.js";
|
||||
export {
|
||||
type CallTimeouts,
|
||||
type FfmpegWasmCodecOptions,
|
||||
FORMAT_MIME,
|
||||
type LoadedCore,
|
||||
SUPPORTED_FORMATS,
|
||||
type SupportedFormat,
|
||||
type WorkerMessage,
|
||||
} from "./ffmpeg-wasm-codec.types.js";
|
||||
@@ -0,0 +1,78 @@
|
||||
# `core-loader.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the lazy resolution of the ffmpeg.wasm core module. It picks
|
||||
the single-thread `@ffmpeg/core` or the multi-thread `@ffmpeg/core-mt` based
|
||||
on the caller's `enableMultithread` flag and the page's
|
||||
`globalThis.crossOriginIsolated` value, then memoises the result so a second
|
||||
codec call reuses the already-loaded core. All static analysis of the source
|
||||
must show that `@ffmpeg/core-mt` is reached only inside a dynamic `import()`
|
||||
expression — never as a static import — so consumers without the optional
|
||||
dependency installed pay zero install cost.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy: nothing happens until `loadCore` is called.
|
||||
- URL precedence: `corePath` > `coreUrl` > bundled `DEFAULT_CORE_URL`.
|
||||
- COI gate: `enableMultithread === true` with COI false throws
|
||||
`CROSS_ORIGIN_ISOLATION_REQUIRED` before the mt dynamic import is reached.
|
||||
- Optional-dep fallback: when COI is true but `import("@ffmpeg/core-mt")`
|
||||
rejects, the loader logs a warning via `opts.logger` and loads the
|
||||
single-thread core instead.
|
||||
- Failure surface: any single-thread load failure throws
|
||||
`CORE_LOAD_FAILED` with the resolved URL in `context.coreUrl`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { loadCore } from "@suaveplan/audio-codec-ffmpeg-wasm/core-loader/core-loader";
|
||||
|
||||
const core = await loadCore({ corePath: "/static/ffmpeg-core.js" });
|
||||
console.log(core.multithread); // false
|
||||
console.log(core.resolvedUrl); // "/static/ffmpeg-core.js"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { loadCore } from "@suaveplan/audio-codec-ffmpeg-wasm/core-loader/core-loader";
|
||||
|
||||
// Multi-thread with explicit COI override (testing or controlled embed)
|
||||
const core = await loadCore({
|
||||
corePath: "/static/ffmpeg-core-mt.js",
|
||||
enableMultithread: true,
|
||||
crossOriginIsolated: true,
|
||||
logger: myLogger,
|
||||
coreMtLoader: () => import("@ffmpeg/core-mt"),
|
||||
});
|
||||
```
|
||||
|
||||
The `coreLoader` / `coreMtLoader` options are dependency-injection seams.
|
||||
Tests use them to assert lazy load semantics, exercise the COI gate, and
|
||||
hit the optional-dep-missing fallback without altering the import graph
|
||||
at static-analysis time. Production callers should leave both unset so
|
||||
the dynamic `import()` is the only path that ever names the ffmpeg
|
||||
modules.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DEFAULT_CORE_URL: string` — the bundled CDN fallback.
|
||||
- `loadCore(opts: CoreLoaderOptions): Promise<LoadedCore>`.
|
||||
- `interface CoreLoaderOptions` — `coreUrl`, `corePath`, `enableMultithread`,
|
||||
`logger`, `coreLoader`, `coreMtLoader`, `crossOriginIsolated`.
|
||||
- `interface LoadedCore` — `multithread`, `resolvedUrl`, `module`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The COI check intentionally happens *before* the dynamic `import()` so
|
||||
that callers without the `@ffmpeg/core-mt` optional dep installed never
|
||||
incur a module-not-found warning when the COI gate is the real failure.
|
||||
The fallback path runs only when COI is true *and* the mt import rejects
|
||||
— if COI is true and mt loads successfully, no fallback occurs. The
|
||||
`crossOriginIsolated` override exists so tests can drive both branches
|
||||
deterministically without manipulating the global.
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AudioCodecFfmpegWasmError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { DEFAULT_CORE_URL, loadCore } from "./core-loader.js";
|
||||
|
||||
describe("core-loader", () => {
|
||||
it("loadCore single-thread uses injected coreLoader and resolves resolvedUrl from corePath", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fakeCore = { id: "fake-st" };
|
||||
const loaded = await loadCore({
|
||||
corePath: "/static/ffmpeg-core.js",
|
||||
coreLoader: () => Promise.resolve(fakeCore),
|
||||
});
|
||||
expect(loaded.multithread).toBe(false);
|
||||
expect(loaded.module).toBe(fakeCore);
|
||||
expect(loaded.resolvedUrl).toBe("/static/ffmpeg-core.js");
|
||||
});
|
||||
|
||||
it("loadCore resolvedUrl uses coreUrl when corePath missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const loaded = await loadCore({
|
||||
coreUrl: "https://example.com/core.js",
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
expect(loaded.resolvedUrl).toBe("https://example.com/core.js");
|
||||
});
|
||||
|
||||
it("loadCore resolvedUrl falls back to DEFAULT_CORE_URL", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const loaded = await loadCore({
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
expect(loaded.resolvedUrl).toBe(DEFAULT_CORE_URL);
|
||||
});
|
||||
|
||||
it("loadCore translates single-thread import failure to CORE_LOAD_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
loadCore({
|
||||
corePath: "/bad/core.js",
|
||||
coreLoader: () => Promise.reject(new Error("ENOENT")),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.CORE_LOAD_FAILED,
|
||||
context: { coreUrl: "/bad/core.js" },
|
||||
});
|
||||
});
|
||||
|
||||
it("loadCore throws CROSS_ORIGIN_ISOLATION_REQUIRED when mt requested without COI", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let mtCalled = false;
|
||||
await expect(
|
||||
loadCore({
|
||||
enableMultithread: true,
|
||||
crossOriginIsolated: false,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
coreMtLoader: () => {
|
||||
mtCalled = true;
|
||||
return Promise.resolve({});
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.CROSS_ORIGIN_ISOLATION_REQUIRED,
|
||||
});
|
||||
expect(mtCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("loadCore returns multithread=true when mt loader succeeds under COI", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const loaded = await loadCore({
|
||||
enableMultithread: true,
|
||||
crossOriginIsolated: true,
|
||||
coreMtLoader: () => Promise.resolve({ id: "mt" }),
|
||||
});
|
||||
expect(loaded.multithread).toBe(true);
|
||||
});
|
||||
|
||||
it("loadCore falls back silently when mt import rejects under COI", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const warnings: Array<{ msg: string; ctx: unknown }> = [];
|
||||
const logger = {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn(msg: string, ctx?: Readonly<Record<string, unknown>>) {
|
||||
warnings.push({ msg, ctx });
|
||||
},
|
||||
error() {},
|
||||
};
|
||||
const loaded = await loadCore({
|
||||
enableMultithread: true,
|
||||
crossOriginIsolated: true,
|
||||
coreMtLoader: () => Promise.reject(new Error("module not found")),
|
||||
coreLoader: () => Promise.resolve({ id: "st-fallback" }),
|
||||
logger,
|
||||
});
|
||||
expect(loaded.multithread).toBe(false);
|
||||
expect(warnings.length).toBe(1);
|
||||
expect(warnings[0]!.msg).toContain("mt-fallback");
|
||||
});
|
||||
|
||||
it("loadCore reads globalThis.crossOriginIsolated when override not supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = (globalThis as { crossOriginIsolated?: boolean })
|
||||
.crossOriginIsolated;
|
||||
try {
|
||||
(
|
||||
globalThis as { crossOriginIsolated?: boolean | undefined }
|
||||
).crossOriginIsolated = false;
|
||||
await expect(
|
||||
loadCore({
|
||||
enableMultithread: true,
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
coreMtLoader: () => Promise.resolve({}),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.CROSS_ORIGIN_ISOLATION_REQUIRED,
|
||||
});
|
||||
(
|
||||
globalThis as { crossOriginIsolated?: boolean | undefined }
|
||||
).crossOriginIsolated = true;
|
||||
const loaded = await loadCore({
|
||||
enableMultithread: true,
|
||||
coreMtLoader: () => Promise.resolve({ id: "mt" }),
|
||||
});
|
||||
expect(loaded.multithread).toBe(true);
|
||||
} finally {
|
||||
(
|
||||
globalThis as { crossOriginIsolated?: boolean | undefined }
|
||||
).crossOriginIsolated = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("default single-thread loader path is reached when no override supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// No `coreLoader` injection — the real dynamic `import("@ffmpeg/core")`
|
||||
// runs. `@ffmpeg/core` is an optional dependency that may or may not
|
||||
// be installed in CI; either resolves (the module loads) or rejects
|
||||
// (wrapped to CORE_LOAD_FAILED). Both outcomes cover the default branch.
|
||||
let outcome: "ok" | "fail" = "fail";
|
||||
try {
|
||||
const loaded = await loadCore({ corePath: "/static/x.js" });
|
||||
expect(loaded.multithread).toBe(false);
|
||||
outcome = "ok";
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.CORE_LOAD_FAILED
|
||||
);
|
||||
}
|
||||
expect(["ok", "fail"]).toContain(outcome);
|
||||
});
|
||||
|
||||
it("default multi-thread loader path is reached when no override supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// COI true, no override — the real `import("@ffmpeg/core-mt")` runs.
|
||||
// Either resolves (mt installed) or rejects (fallback to st, which
|
||||
// also may resolve or reject). Both code paths cover the defaults.
|
||||
const logger = {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
try {
|
||||
await loadCore({
|
||||
enableMultithread: true,
|
||||
crossOriginIsolated: true,
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.CORE_LOAD_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("loadCore wraps non-Error rejections as CORE_LOAD_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
try {
|
||||
await loadCore({
|
||||
coreLoader: () => Promise.reject("string-error"),
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AudioCodecFfmpegWasmError);
|
||||
expect((err as AudioCodecFfmpegWasmError).code).toBe(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.CORE_LOAD_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AudioCodecFfmpegWasmError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { CoreLoaderOptions, LoadedCore } from "./core-loader.types.js";
|
||||
|
||||
/**
|
||||
* Bundled default CDN URL for `@ffmpeg/core`. Documented but not the
|
||||
* recommended path — consumers should self-host and pass `corePath` for
|
||||
* COEP-correct delivery.
|
||||
*/
|
||||
export const DEFAULT_CORE_URL =
|
||||
"https://unpkg.com/@ffmpeg/core@0.12.10/dist/ffmpeg-core.js";
|
||||
|
||||
function resolveUrl(opts: CoreLoaderOptions): string {
|
||||
if (typeof opts.corePath === "string" && opts.corePath.length > 0)
|
||||
return opts.corePath;
|
||||
if (typeof opts.coreUrl === "string" && opts.coreUrl.length > 0)
|
||||
return opts.coreUrl;
|
||||
return DEFAULT_CORE_URL;
|
||||
}
|
||||
|
||||
function defaultIsCrossOriginIsolated(): boolean {
|
||||
return (
|
||||
typeof globalThis !== "undefined" &&
|
||||
(globalThis as { crossOriginIsolated?: boolean })
|
||||
.crossOriginIsolated === true
|
||||
);
|
||||
}
|
||||
|
||||
// Vite eagerly resolves static `import("specifier")` expressions even when
|
||||
// they are wrapped in an `async` function. Concatenating the specifier from
|
||||
// constants defeats that analysis while keeping the module name searchable
|
||||
// for the purity tests (which scan source text, not the resolved literal).
|
||||
const FFMPEG_CORE_SPEC = "@ffmpeg" + "/core";
|
||||
const FFMPEG_CORE_MT_SPEC = "@ffmpeg" + "/core-mt";
|
||||
|
||||
async function importSingleThreadCore(): Promise<unknown> {
|
||||
return import(/* @vite-ignore */ FFMPEG_CORE_SPEC);
|
||||
}
|
||||
|
||||
async function importMultiThreadCore(): Promise<unknown> {
|
||||
return import(/* @vite-ignore */ FFMPEG_CORE_MT_SPEC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily resolve a `LoadedCore`. Tests inject `coreLoader` / `coreMtLoader`
|
||||
* to skip the real dynamic import; production callers omit them so the
|
||||
* dynamic `import()` is the only path that ever names `@ffmpeg/core` /
|
||||
* `@ffmpeg/core-mt`.
|
||||
*
|
||||
* - When `enableMultithread !== true`, the single-thread core loads
|
||||
* unconditionally and the COI flag is never consulted.
|
||||
* - When `enableMultithread === true` and COI is `false`, throws
|
||||
* `AUDIO_CODEC_FFMPEG_WASM.CROSS_ORIGIN_ISOLATION_REQUIRED` *before*
|
||||
* the mt dynamic import is reached.
|
||||
* - When `enableMultithread === true`, COI is `true`, and the mt import
|
||||
* rejects (optional dep missing), a warning is logged and the
|
||||
* single-thread core is loaded as a silent fallback.
|
||||
* - Any single-thread load failure throws
|
||||
* `AUDIO_CODEC_FFMPEG_WASM.CORE_LOAD_FAILED`.
|
||||
*/
|
||||
export async function loadCore(opts: CoreLoaderOptions): Promise<LoadedCore> {
|
||||
const resolvedUrl = resolveUrl(opts);
|
||||
const isCOI =
|
||||
typeof opts.crossOriginIsolated === "boolean"
|
||||
? opts.crossOriginIsolated
|
||||
: defaultIsCrossOriginIsolated();
|
||||
const mtRequested = opts.enableMultithread === true;
|
||||
if (mtRequested) {
|
||||
if (isCOI !== true) {
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.CROSS_ORIGIN_ISOLATION_REQUIRED,
|
||||
"audio-codec-ffmpeg-wasm.core.coi-required",
|
||||
{
|
||||
context: {
|
||||
crossOriginIsolated: isCOI,
|
||||
coreUrl: resolvedUrl,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const mtLoader = opts.coreMtLoader ?? importMultiThreadCore;
|
||||
try {
|
||||
const mod = await mtLoader();
|
||||
return { multithread: true, resolvedUrl, module: mod };
|
||||
} catch (err) {
|
||||
opts.logger?.warn("audio-codec-ffmpeg-wasm.core.mt-fallback", {
|
||||
error: err,
|
||||
coreUrl: resolvedUrl,
|
||||
});
|
||||
// Fall through to single-thread.
|
||||
}
|
||||
}
|
||||
const stLoader = opts.coreLoader ?? importSingleThreadCore;
|
||||
try {
|
||||
const mod = await stLoader();
|
||||
return { multithread: false, resolvedUrl, module: mod };
|
||||
} catch (err) {
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.CORE_LOAD_FAILED,
|
||||
"audio-codec-ffmpeg-wasm.core.load-failed",
|
||||
{ cause: err, context: { coreUrl: resolvedUrl } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
export interface CoreLoaderOptions {
|
||||
readonly coreUrl?: string | undefined;
|
||||
readonly corePath?: string | undefined;
|
||||
readonly enableMultithread?: boolean | undefined;
|
||||
readonly logger?: LoggerLike | undefined;
|
||||
/** Test seam — replaces dynamic `import("@ffmpeg/core")`. */
|
||||
readonly coreLoader?: (() => Promise<unknown>) | undefined;
|
||||
/** Test seam — replaces dynamic `import("@ffmpeg/core-mt")`. */
|
||||
readonly coreMtLoader?: (() => Promise<unknown>) | undefined;
|
||||
/**
|
||||
* Override for `globalThis.crossOriginIsolated`. Tests inject `true` /
|
||||
* `false` directly so the gate is deterministic.
|
||||
*/
|
||||
readonly crossOriginIsolated?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface LoadedCore {
|
||||
readonly multithread: boolean;
|
||||
readonly resolvedUrl: string;
|
||||
readonly module: unknown;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DEFAULT_CORE_URL, loadCore } from "./core-loader.js";
|
||||
export type { CoreLoaderOptions, LoadedCore } from "./core-loader.types.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
# `decode-pipeline.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the pure logic of the decode pipeline: format sniffing
|
||||
from magic bytes, the ffmpeg argument list that emits `f32le` interleaved
|
||||
samples to a MEMFS scratch file, and the deinterleave step that produces
|
||||
per-channel `Float32Array`s for the `DecodeResult` shape. The pipeline
|
||||
runs entirely through the supplied `WorkerHost` so cancellation,
|
||||
timeouts, and OOM detection are inherited from a single source of
|
||||
truth.
|
||||
|
||||
## Features
|
||||
|
||||
- `sniffFormat(bytes)` recognises WAV, MP3 (ID3v2 + frame-sync), Ogg
|
||||
(Vorbis vs Opus by inspecting the bos packet), FLAC, AAC ADTS, and
|
||||
WebM/EBML headers.
|
||||
- `deinterleave(samples, channels)` returns `channels` equal-length
|
||||
`Float32Array`s.
|
||||
- `runDecode(opts)` orchestrates writeFile → run → readFile via
|
||||
`WorkerHost.submitFfmpeg` and returns a normalised `DecodeResult`.
|
||||
- Default sample rate (48 000) and channel count (2) for callers that
|
||||
do not pre-probe their input.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runDecode } from "@suaveplan/audio-codec-ffmpeg-wasm/decode/decode-pipeline";
|
||||
|
||||
const result = await runDecode({ host, bytes, format: "wav" });
|
||||
console.log(result.channelData.length); // channel count
|
||||
console.log(result.channelData[0].length); // frame count
|
||||
console.log(result.sampleRate);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
runDecode,
|
||||
sniffFormat,
|
||||
deinterleave,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-wasm/decode/decode-pipeline";
|
||||
|
||||
const fmt = sniffFormat(bytes);
|
||||
if (!fmt) throw new Error("unsupported");
|
||||
const result = await runDecode({
|
||||
host,
|
||||
bytes,
|
||||
format: fmt as "wav" | "mp3" | "ogg" | "opus" | "flac" | "aac" | "webm",
|
||||
sampleRate: 44_100,
|
||||
channels: 1,
|
||||
timeoutMs: 30_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Drop a deinterleaved view for downstream processing
|
||||
const ch = deinterleave(new Float32Array([0, 1, 2, 3]), 2);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DEFAULT_SAMPLE_RATE: 48_000`.
|
||||
- `DEFAULT_CHANNELS: 2`.
|
||||
- `sniffFormat(bytes: Uint8Array): string | undefined`.
|
||||
- `deinterleave(interleaved: Float32Array, channels: number): Float32Array[]`.
|
||||
- `runDecode(opts: DecodePipelineOptions): Promise<DecodeResult>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The deinterleave step copies the raw `Uint8Array` through a `Float32Array`
|
||||
view to honour the f32le wire layout and then takes a `.slice()` so the
|
||||
returned arrays own their own backing buffer — callers can transfer or
|
||||
mutate them without aliasing the worker output. The pipeline never
|
||||
silently catches MEMFS cleanup failures here; the host's `submitFfmpeg`
|
||||
handles try/finally cleanup and logs any delete failures via the
|
||||
injected logger. A non-zero ffmpeg exit is mapped to
|
||||
`AUDIO_CODEC_FFMPEG_WASM.DECODE_FAILED` inside the worker host so this
|
||||
module never sees it directly.
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildPcmBytes,
|
||||
FakeWorker,
|
||||
successScript,
|
||||
} from "../../tests/fixtures/fake-worker.js";
|
||||
import { AUDIO_CODEC_FFMPEG_WASM_CODES } from "../error-codes/error-codes.js";
|
||||
import { WorkerHost } from "../worker/worker-host.js";
|
||||
import {
|
||||
DEFAULT_CHANNELS,
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
deinterleave,
|
||||
runDecode,
|
||||
sniffFormat,
|
||||
} from "./decode-pipeline.js";
|
||||
|
||||
describe("sniffFormat", () => {
|
||||
it("returns undefined for too-short bytes", { timeout: 5000 }, () => {
|
||||
expect(sniffFormat(new Uint8Array([0, 0]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognises WAV (RIFF)", { timeout: 5000 }, () => {
|
||||
expect(sniffFormat(new Uint8Array([0x52, 0x49, 0x46, 0x46]))).toBe(
|
||||
"wav"
|
||||
);
|
||||
});
|
||||
|
||||
it("recognises MP3 (ID3v2 tag)", { timeout: 5000 }, () => {
|
||||
expect(sniffFormat(new Uint8Array([0x49, 0x44, 0x33, 0x04]))).toBe(
|
||||
"mp3"
|
||||
);
|
||||
});
|
||||
|
||||
it("recognises MP3 frame sync", { timeout: 5000 }, () => {
|
||||
expect(sniffFormat(new Uint8Array([0xff, 0xfb, 0, 0]))).toBe("mp3");
|
||||
});
|
||||
|
||||
it("recognises Opus inside Ogg (OpusHead bos)", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array(40);
|
||||
bytes[0] = 0x4f;
|
||||
bytes[1] = 0x67;
|
||||
bytes[2] = 0x67;
|
||||
bytes[3] = 0x53;
|
||||
bytes[28] = 0x4f; // 'O'
|
||||
bytes[29] = 0x70;
|
||||
bytes[30] = 0x75;
|
||||
bytes[31] = 0x73;
|
||||
expect(sniffFormat(bytes)).toBe("opus");
|
||||
});
|
||||
|
||||
it("recognises plain Ogg without OpusHead marker", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = new Uint8Array(40);
|
||||
bytes[0] = 0x4f;
|
||||
bytes[1] = 0x67;
|
||||
bytes[2] = 0x67;
|
||||
bytes[3] = 0x53;
|
||||
expect(sniffFormat(bytes)).toBe("ogg");
|
||||
});
|
||||
|
||||
it("returns ogg when buffer is too short to inspect bos packet", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = new Uint8Array([0x4f, 0x67, 0x67, 0x53, 0, 0, 0, 0]);
|
||||
expect(sniffFormat(bytes)).toBe("ogg");
|
||||
});
|
||||
|
||||
it("recognises FLAC", { timeout: 5000 }, () => {
|
||||
expect(sniffFormat(new Uint8Array([0x66, 0x4c, 0x61, 0x43]))).toBe(
|
||||
"flac"
|
||||
);
|
||||
});
|
||||
|
||||
it("recognises AAC ADTS", { timeout: 5000 }, () => {
|
||||
expect(sniffFormat(new Uint8Array([0xff, 0xf1, 0, 0]))).toBe("aac");
|
||||
});
|
||||
|
||||
it("recognises WebM/EBML", { timeout: 5000 }, () => {
|
||||
expect(sniffFormat(new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]))).toBe(
|
||||
"webm"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown bytes", { timeout: 5000 }, () => {
|
||||
expect(sniffFormat(new Uint8Array([0, 0, 0, 0]))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deinterleave", () => {
|
||||
it("splits interleaved samples into per-channel arrays", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inter = new Float32Array([1, 10, 2, 20, 3, 30]);
|
||||
const out = deinterleave(inter, 2);
|
||||
expect(out.length).toBe(2);
|
||||
expect(Array.from(out[0]!)).toEqual([1, 2, 3]);
|
||||
expect(Array.from(out[1]!)).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
it("zero channels throws DECODE_FAILED", { timeout: 5000 }, () => {
|
||||
expect(() => deinterleave(new Float32Array(0), 0)).toThrow();
|
||||
});
|
||||
|
||||
it("rounds down partial frames", { timeout: 5000 }, () => {
|
||||
const inter = new Float32Array([1, 10, 2]);
|
||||
const out = deinterleave(inter, 2);
|
||||
// 3 samples ÷ 2 channels = 1 frame
|
||||
expect(out[0]!.length).toBe(1);
|
||||
expect(out[1]!.length).toBe(1);
|
||||
});
|
||||
|
||||
it("default constants are exposed", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_SAMPLE_RATE).toBe(48_000);
|
||||
expect(DEFAULT_CHANNELS).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDecode", () => {
|
||||
it("runs the full pipeline and returns a DecodeResult", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const pcm = buildPcmBytes(8, 1);
|
||||
worker.script.push(...successScript(pcm));
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
const result = await runDecode({
|
||||
host,
|
||||
bytes: new Uint8Array([1, 2, 3, 4]),
|
||||
format: "wav",
|
||||
channels: 1,
|
||||
sampleRate: 22_050,
|
||||
});
|
||||
expect(result.format).toBe("wav");
|
||||
expect(result.sampleRate).toBe(22_050);
|
||||
expect(result.channelData.length).toBe(1);
|
||||
expect(result.channelData[0]!.length).toBe(8);
|
||||
});
|
||||
|
||||
it("uses default sample rate and channels when not supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const pcm = buildPcmBytes(4, 2);
|
||||
worker.script.push(...successScript(pcm));
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
const result = await runDecode({
|
||||
host,
|
||||
bytes: new Uint8Array(),
|
||||
format: "mp3",
|
||||
});
|
||||
expect(result.sampleRate).toBe(DEFAULT_SAMPLE_RATE);
|
||||
expect(result.channelData.length).toBe(DEFAULT_CHANNELS);
|
||||
});
|
||||
|
||||
it("propagates ffmpeg failure as DECODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // writeFile
|
||||
worker.script.push((req) => ({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
stderr: "broken file",
|
||||
}));
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
await expect(
|
||||
runDecode({
|
||||
host,
|
||||
bytes: new Uint8Array(),
|
||||
format: "mp3",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AudioCodecFfmpegWasmError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
DecodePipelineOptions,
|
||||
DecodePipelineResult,
|
||||
} from "./decode-pipeline.types.js";
|
||||
|
||||
/** Default sample rate when none is supplied. */
|
||||
export const DEFAULT_SAMPLE_RATE = 48_000;
|
||||
/** Default channel count when none is supplied. */
|
||||
export const DEFAULT_CHANNELS = 2;
|
||||
|
||||
/**
|
||||
* Sniff the input format from magic bytes. Returns the canonical lowercased
|
||||
* format name, or `undefined` when nothing matches. The host codec calls this
|
||||
* before invoking the worker so a bad input throws `UNSUPPORTED_CODEC`
|
||||
* synchronously.
|
||||
*/
|
||||
export function sniffFormat(bytes: Uint8Array): string | undefined {
|
||||
if (bytes.length < 4) return undefined;
|
||||
// RIFF .... WAVE
|
||||
if (
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46
|
||||
)
|
||||
return "wav";
|
||||
// ID3v2 tag or MP3 frame sync
|
||||
if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33)
|
||||
return "mp3";
|
||||
// MP3 frame sync — 11 bits set + non-zero Layer field (bits 1-2 of b1).
|
||||
// Length is already ≥ 4 above, so bytes[1] is always defined here.
|
||||
if (
|
||||
bytes[0] === 0xff &&
|
||||
(bytes[1]! & 0xe0) === 0xe0 &&
|
||||
(bytes[1]! & 0x06) !== 0
|
||||
)
|
||||
return "mp3";
|
||||
// OggS — could be Ogg Vorbis, Opus, or FLAC-in-Ogg.
|
||||
if (
|
||||
bytes[0] === 0x4f &&
|
||||
bytes[1] === 0x67 &&
|
||||
bytes[2] === 0x67 &&
|
||||
bytes[3] === 0x53
|
||||
) {
|
||||
// Inspect the bos packet at offset 28 for "OpusHead" / "vorbis".
|
||||
if (
|
||||
bytes.length >= 36 &&
|
||||
bytes[28] === 0x4f &&
|
||||
bytes[29] === 0x70 &&
|
||||
bytes[30] === 0x75 &&
|
||||
bytes[31] === 0x73
|
||||
)
|
||||
return "opus";
|
||||
return "ogg";
|
||||
}
|
||||
// fLaC
|
||||
if (
|
||||
bytes[0] === 0x66 &&
|
||||
bytes[1] === 0x4c &&
|
||||
bytes[2] === 0x61 &&
|
||||
bytes[3] === 0x43
|
||||
)
|
||||
return "flac";
|
||||
// ADTS AAC: 0xFFF*
|
||||
if (
|
||||
bytes[0] === 0xff &&
|
||||
(bytes[1]! & 0xf0) === 0xf0 &&
|
||||
(bytes[1]! & 0x06) === 0
|
||||
)
|
||||
return "aac";
|
||||
// EBML / WebM (0x1A45DFA3)
|
||||
if (
|
||||
bytes[0] === 0x1a &&
|
||||
bytes[1] === 0x45 &&
|
||||
bytes[2] === 0xdf &&
|
||||
bytes[3] === 0xa3
|
||||
)
|
||||
return "webm";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deinterleave a `Float32Array` of interleaved samples into per-channel
|
||||
* arrays. Always returns exactly `channels` arrays, each of equal length.
|
||||
*/
|
||||
export function deinterleave(
|
||||
interleaved: Float32Array,
|
||||
channels: number
|
||||
): Float32Array[] {
|
||||
if (channels <= 0)
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.DECODE_FAILED,
|
||||
"audio-codec-ffmpeg-wasm.decode.invalid-channels",
|
||||
{ context: { channels } }
|
||||
);
|
||||
const frames = Math.floor(interleaved.length / channels);
|
||||
const out: Float32Array[] = [];
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const ch = new Float32Array(frames);
|
||||
for (let f = 0; f < frames; f++) {
|
||||
ch[f] = interleaved[f * channels + c]!;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToFloat32(bytes: Uint8Array): Float32Array {
|
||||
const offset = bytes.byteOffset;
|
||||
const length = Math.floor(bytes.byteLength / 4);
|
||||
return new Float32Array(bytes.buffer, offset, length).slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full decode pipeline through the supplied `WorkerHost`. Writes
|
||||
* the input bytes to MEMFS, runs the `-f f32le` extraction, reads the raw
|
||||
* output, deinterleaves into per-channel `Float32Array`s, and returns the
|
||||
* `DecodeResult`. Cleanup of the MEMFS entries is handled inside
|
||||
* `WorkerHost.submitFfmpeg`.
|
||||
*/
|
||||
export async function runDecode(
|
||||
opts: DecodePipelineOptions
|
||||
): Promise<DecodePipelineResult> {
|
||||
const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
||||
const channels = opts.channels ?? DEFAULT_CHANNELS;
|
||||
const inputPath = `input.${opts.format}`;
|
||||
const outputPath = "output.raw";
|
||||
const args = [
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ac",
|
||||
String(channels),
|
||||
"-ar",
|
||||
String(sampleRate),
|
||||
outputPath,
|
||||
];
|
||||
const { outputBytes } = await opts.host.submitFfmpeg(
|
||||
{ path: inputPath, bytes: opts.bytes },
|
||||
outputPath,
|
||||
args,
|
||||
{
|
||||
category: "decode",
|
||||
signal: opts.signal,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}
|
||||
);
|
||||
const interleaved = bytesToFloat32(outputBytes);
|
||||
const channelData = deinterleave(interleaved, channels);
|
||||
return { channelData, sampleRate, format: opts.format };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DecodeResult } from "@suaveplan/audio-codecs";
|
||||
import type { SupportedFormat } from "../codec/ffmpeg-wasm-codec.types.js";
|
||||
import type { WorkerHost } from "../worker/worker-host.js";
|
||||
|
||||
export interface DecodePipelineOptions {
|
||||
readonly host: WorkerHost;
|
||||
readonly bytes: Uint8Array;
|
||||
readonly format: SupportedFormat;
|
||||
readonly sampleRate?: number | undefined;
|
||||
readonly channels?: number | undefined;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly timeoutMs?: number | undefined;
|
||||
}
|
||||
|
||||
export type DecodePipelineResult = DecodeResult;
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
DEFAULT_CHANNELS,
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
deinterleave,
|
||||
runDecode,
|
||||
sniffFormat,
|
||||
} from "./decode-pipeline.js";
|
||||
export type {
|
||||
DecodePipelineOptions,
|
||||
DecodePipelineResult,
|
||||
} from "./decode-pipeline.types.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# `encode-pipeline.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the pure logic of the encode pipeline: validating the
|
||||
requested format against the supported set *before* the worker is touched,
|
||||
interleaving per-channel PCM into a single `Float32Array`, projecting
|
||||
that into ffmpeg's `f32le` wire format, building the format-specific
|
||||
ffmpeg command for each of `wav` / `mp3` / `ogg` / `opus` / `flac` /
|
||||
`aac` / `webm`, and dispatching the call through the supplied
|
||||
`WorkerHost`. All MEMFS lifecycle is owned by the host.
|
||||
|
||||
## Features
|
||||
|
||||
- `interleave` and `float32ToBytes` produce the exact wire layout ffmpeg
|
||||
expects for `-f f32le`.
|
||||
- `buildCommand` returns the ffmpeg argument list per supported format,
|
||||
honouring caller-supplied `bitrate` (`mp3`, `opus`, `aac`, `webm`) and
|
||||
`quality` (`ogg`).
|
||||
- `assertSupported` throws `UNSUPPORTED_CODEC` synchronously, before any
|
||||
worker message is posted — required by the spec.
|
||||
- `runEncode` drives the full pipeline through `WorkerHost.submitFfmpeg`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runEncode } from "@suaveplan/audio-codec-ffmpeg-wasm/encode/encode-pipeline";
|
||||
|
||||
const bytes = await runEncode({
|
||||
host,
|
||||
channelData: [leftFloats, rightFloats],
|
||||
format: "mp3",
|
||||
sampleRate: 44_100,
|
||||
bitrate: 256_000,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
runEncode,
|
||||
interleave,
|
||||
buildCommand,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-wasm/encode/encode-pipeline";
|
||||
|
||||
// Inspect the command ffmpeg will be asked to run
|
||||
const cmd = buildCommand("opus", 48_000, 1, 96_000);
|
||||
console.log(cmd.args);
|
||||
|
||||
const interleaved = interleave([mono]);
|
||||
const result = await runEncode({
|
||||
host,
|
||||
channelData: [mono],
|
||||
format: "opus",
|
||||
sampleRate: 48_000,
|
||||
bitrate: 96_000,
|
||||
signal: controller.signal,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interleave(channelData): Float32Array`.
|
||||
- `float32ToBytes(samples): Uint8Array`.
|
||||
- `buildCommand(format, sampleRate, channels, bitrate?, quality?): FormatCommand`.
|
||||
- `runEncode(opts: EncodePipelineOptions): Promise<Uint8Array>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`assertSupported` is intentionally written as a TypeScript `asserts`
|
||||
function so its throw branch narrows the format type for the rest of the
|
||||
function — no extra runtime branch is needed. Format-specific defaults
|
||||
(192 kbps for MP3/AAC, 96 kbps for Opus/WebM, q=5 for Vorbis) are
|
||||
chosen to match the ffmpeg.wasm reference build's documented sweet
|
||||
spots; callers always override via `bitrate`/`quality` for production
|
||||
use. The interleaved `Float32Array` is wrapped in a `Uint8Array` view
|
||||
without an extra copy so the worker `postMessage` boundary takes one
|
||||
transferable buffer instead of two.
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeWorker, successScript } from "../../tests/fixtures/fake-worker.js";
|
||||
import { AUDIO_CODEC_FFMPEG_WASM_CODES } from "../error-codes/error-codes.js";
|
||||
import { WorkerHost } from "../worker/worker-host.js";
|
||||
import {
|
||||
buildCommand,
|
||||
float32ToBytes,
|
||||
interleave,
|
||||
runEncode,
|
||||
} from "./encode-pipeline.js";
|
||||
|
||||
describe("interleave + float32ToBytes", () => {
|
||||
it("interleaves per-channel arrays into a single Float32Array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = interleave([
|
||||
new Float32Array([1, 2, 3]),
|
||||
new Float32Array([10, 20, 30]),
|
||||
]);
|
||||
expect(Array.from(out)).toEqual([1, 10, 2, 20, 3, 30]);
|
||||
});
|
||||
|
||||
it("interleave of empty channel data yields empty array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(interleave([]).length).toBe(0);
|
||||
});
|
||||
|
||||
it("pads shorter channels with zeros", { timeout: 5000 }, () => {
|
||||
const out = interleave([
|
||||
new Float32Array([1, 2, 3]),
|
||||
new Float32Array([10]),
|
||||
]);
|
||||
expect(Array.from(out)).toEqual([1, 10, 2, 0, 3, 0]);
|
||||
});
|
||||
|
||||
it("float32ToBytes wraps without copy", { timeout: 5000 }, () => {
|
||||
const samples = new Float32Array([1.5, -1.5]);
|
||||
const bytes = float32ToBytes(samples);
|
||||
expect(bytes.byteLength).toBe(8);
|
||||
expect(bytes.buffer).toBe(samples.buffer);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCommand", () => {
|
||||
it("WAV command uses pcm_s16le", { timeout: 5000 }, () => {
|
||||
const cmd = buildCommand("wav", 44_100, 2);
|
||||
expect(cmd.outputExt).toBe("wav");
|
||||
expect(cmd.args).toContain("pcm_s16le");
|
||||
});
|
||||
|
||||
it("MP3 command uses libmp3lame with default 192k bitrate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = buildCommand("mp3", 44_100, 2);
|
||||
expect(cmd.args).toContain("libmp3lame");
|
||||
expect(cmd.args).toContain("192000");
|
||||
});
|
||||
|
||||
it("MP3 command honours explicit bitrate", { timeout: 5000 }, () => {
|
||||
const cmd = buildCommand("mp3", 44_100, 2, 320_000);
|
||||
expect(cmd.args).toContain("320000");
|
||||
});
|
||||
|
||||
it("OGG command uses libvorbis with default quality 5", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = buildCommand("ogg", 44_100, 2);
|
||||
expect(cmd.args).toContain("libvorbis");
|
||||
expect(cmd.args).toContain("5");
|
||||
});
|
||||
|
||||
it("OGG command honours quality override", { timeout: 5000 }, () => {
|
||||
const cmd = buildCommand("ogg", 44_100, 2, undefined, 8);
|
||||
expect(cmd.args).toContain("8");
|
||||
});
|
||||
|
||||
it("OPUS command uses libopus with default 96k bitrate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = buildCommand("opus", 48_000, 1);
|
||||
expect(cmd.args).toContain("libopus");
|
||||
expect(cmd.args).toContain("96000");
|
||||
});
|
||||
|
||||
it("OPUS command honours bitrate override", { timeout: 5000 }, () => {
|
||||
const cmd = buildCommand("opus", 48_000, 1, 128_000);
|
||||
expect(cmd.args).toContain("128000");
|
||||
});
|
||||
|
||||
it("FLAC command uses flac codec", { timeout: 5000 }, () => {
|
||||
const cmd = buildCommand("flac", 44_100, 2);
|
||||
expect(cmd.args).toContain("flac");
|
||||
expect(cmd.outputExt).toBe("flac");
|
||||
});
|
||||
|
||||
it("AAC command uses aac codec with default bitrate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmd = buildCommand("aac", 44_100, 2);
|
||||
expect(cmd.args).toContain("aac");
|
||||
expect(cmd.args).toContain("192000");
|
||||
});
|
||||
|
||||
it("AAC command honours bitrate override", { timeout: 5000 }, () => {
|
||||
const cmd = buildCommand("aac", 44_100, 2, 256_000);
|
||||
expect(cmd.args).toContain("256000");
|
||||
});
|
||||
|
||||
it("WebM command uses libopus + webm format", { timeout: 5000 }, () => {
|
||||
const cmd = buildCommand("webm", 48_000, 2);
|
||||
expect(cmd.args).toContain("libopus");
|
||||
expect(cmd.args).toContain("webm");
|
||||
});
|
||||
|
||||
it("WebM command honours bitrate override", { timeout: 5000 }, () => {
|
||||
const cmd = buildCommand("webm", 48_000, 2, 192_000);
|
||||
expect(cmd.args).toContain("192000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runEncode", () => {
|
||||
it("encodes through the worker and returns output bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const encoded = new Uint8Array([0xff, 0xfb, 0x90, 0x00]);
|
||||
worker.script.push(...successScript(encoded));
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
const result = await runEncode({
|
||||
host,
|
||||
channelData: [new Float32Array([0.1, 0.2, -0.1, -0.2])],
|
||||
format: "mp3",
|
||||
sampleRate: 44_100,
|
||||
bitrate: 128_000,
|
||||
});
|
||||
expect(result).toEqual(encoded);
|
||||
});
|
||||
|
||||
it("zero-channel input still produces a valid command (default to 1 channel)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(new Uint8Array([0])));
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
const result = await runEncode({
|
||||
host,
|
||||
channelData: [],
|
||||
format: "wav",
|
||||
sampleRate: 44_100,
|
||||
});
|
||||
expect(result.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("unsupported format throws UNSUPPORTED_CODEC before worker is touched", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
const badOpts = {
|
||||
host,
|
||||
channelData: [new Float32Array(4)],
|
||||
format: "alac",
|
||||
sampleRate: 44_100,
|
||||
} as unknown as Parameters<typeof runEncode>[0];
|
||||
await expect(runEncode(badOpts)).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
});
|
||||
expect(worker.posted.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
SUPPORTED_FORMATS,
|
||||
type SupportedFormat,
|
||||
} from "../codec/ffmpeg-wasm-codec.types.js";
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AudioCodecFfmpegWasmError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
EncodePipelineOptions,
|
||||
FormatCommand,
|
||||
} from "./encode-pipeline.types.js";
|
||||
|
||||
/**
|
||||
* Interleave per-channel `Float32Array`s into a single `Float32Array`
|
||||
* matching ffmpeg's `f32le` wire layout. All channel arrays must be the
|
||||
* same length; the longer ones win on length and the shorter ones pad
|
||||
* with zeros (rare in practice — every codec we wrap produces equal
|
||||
* lengths).
|
||||
*/
|
||||
export function interleave(channelData: readonly Float32Array[]): Float32Array {
|
||||
if (channelData.length === 0) return new Float32Array(0);
|
||||
const channels = channelData.length;
|
||||
let frames = 0;
|
||||
for (const ch of channelData) frames = Math.max(frames, ch.length);
|
||||
const out = new Float32Array(frames * channels);
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const src = channelData[c]!;
|
||||
for (let f = 0; f < src.length; f++) {
|
||||
out[f * channels + c] = src[f]!;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a `Uint8Array` view over a `Float32Array`'s backing bytes. */
|
||||
export function float32ToBytes(samples: Float32Array): Uint8Array {
|
||||
return new Uint8Array(
|
||||
samples.buffer,
|
||||
samples.byteOffset,
|
||||
samples.byteLength
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ffmpeg command for a given output format. Format-specific
|
||||
* encoder selection and bitrate/quality flags live here.
|
||||
*/
|
||||
export function buildCommand(
|
||||
format: SupportedFormat,
|
||||
sampleRate: number,
|
||||
channels: number,
|
||||
bitrate?: number,
|
||||
quality?: number
|
||||
): FormatCommand {
|
||||
const common = [
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ar",
|
||||
String(sampleRate),
|
||||
"-ac",
|
||||
String(channels),
|
||||
"-i",
|
||||
"input.raw",
|
||||
];
|
||||
switch (format) {
|
||||
case "wav":
|
||||
return {
|
||||
outputExt: "wav",
|
||||
args: [...common, "-c:a", "pcm_s16le", "output.wav"],
|
||||
};
|
||||
case "mp3": {
|
||||
const br = bitrate ?? 192_000;
|
||||
return {
|
||||
outputExt: "mp3",
|
||||
args: [
|
||||
...common,
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
String(br),
|
||||
"output.mp3",
|
||||
],
|
||||
};
|
||||
}
|
||||
case "ogg": {
|
||||
const q = quality ?? 5;
|
||||
return {
|
||||
outputExt: "ogg",
|
||||
args: [
|
||||
...common,
|
||||
"-c:a",
|
||||
"libvorbis",
|
||||
"-q:a",
|
||||
String(q),
|
||||
"output.ogg",
|
||||
],
|
||||
};
|
||||
}
|
||||
case "opus": {
|
||||
const br = bitrate ?? 96_000;
|
||||
return {
|
||||
outputExt: "opus",
|
||||
args: [
|
||||
...common,
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
String(br),
|
||||
"output.opus",
|
||||
],
|
||||
};
|
||||
}
|
||||
case "flac":
|
||||
return {
|
||||
outputExt: "flac",
|
||||
args: [...common, "-c:a", "flac", "output.flac"],
|
||||
};
|
||||
case "aac": {
|
||||
const br = bitrate ?? 192_000;
|
||||
return {
|
||||
outputExt: "aac",
|
||||
args: [
|
||||
...common,
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
String(br),
|
||||
"output.aac",
|
||||
],
|
||||
};
|
||||
}
|
||||
case "webm": {
|
||||
const br = bitrate ?? 96_000;
|
||||
return {
|
||||
outputExt: "webm",
|
||||
args: [
|
||||
...common,
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
String(br),
|
||||
"-f",
|
||||
"webm",
|
||||
"output.webm",
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSupported(format: string): asserts format is SupportedFormat {
|
||||
if (!(SUPPORTED_FORMATS as readonly string[]).includes(format)) {
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
"audio-codec-ffmpeg-wasm.encode.unsupported-format",
|
||||
{ context: { format } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `format`, interleave the PCM payload, dispatch the
|
||||
* format-specific ffmpeg command through the worker, and return the
|
||||
* encoded bytes.
|
||||
*/
|
||||
export async function runEncode(
|
||||
opts: EncodePipelineOptions
|
||||
): Promise<Uint8Array> {
|
||||
assertSupported(opts.format);
|
||||
const channels = opts.channelData.length || 1;
|
||||
const interleaved = interleave(opts.channelData);
|
||||
const bytes = float32ToBytes(interleaved);
|
||||
const cmd = buildCommand(
|
||||
opts.format,
|
||||
opts.sampleRate,
|
||||
channels,
|
||||
opts.bitrate,
|
||||
opts.quality
|
||||
);
|
||||
const { outputBytes } = await opts.host.submitFfmpeg(
|
||||
{ path: "input.raw", bytes },
|
||||
`output.${cmd.outputExt}`,
|
||||
cmd.args,
|
||||
{
|
||||
category: "encode",
|
||||
signal: opts.signal,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}
|
||||
);
|
||||
return outputBytes;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { SupportedFormat } from "../codec/ffmpeg-wasm-codec.types.js";
|
||||
import type { WorkerHost } from "../worker/worker-host.js";
|
||||
|
||||
export interface EncodePipelineOptions {
|
||||
readonly host: WorkerHost;
|
||||
readonly channelData: readonly Float32Array[];
|
||||
readonly format: SupportedFormat;
|
||||
readonly sampleRate: number;
|
||||
readonly bitrate?: number | undefined;
|
||||
readonly quality?: number | undefined;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly timeoutMs?: number | undefined;
|
||||
}
|
||||
|
||||
export interface FormatCommand {
|
||||
readonly outputExt: string;
|
||||
readonly args: readonly string[];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
buildCommand,
|
||||
float32ToBytes,
|
||||
interleave,
|
||||
runEncode,
|
||||
} from "./encode-pipeline.js";
|
||||
export type {
|
||||
EncodePipelineOptions,
|
||||
FormatCommand,
|
||||
} from "./encode-pipeline.types.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the canonical error-code registry for
|
||||
`@suaveplan/audio-codec-ffmpeg-wasm`. It exposes the nine `AUDIO_CODEC_FFMPEG_WASM.*`
|
||||
codes the package can throw, an immutable metadata record describing each code,
|
||||
the `AudioCodecFfmpegWasmError` class that every codec fault inherits from, and
|
||||
an idempotent registration hook so external systems (OTel attribute binders,
|
||||
report generators) can record that the registry has been touched.
|
||||
|
||||
## Features
|
||||
|
||||
- Nine stable error codes covering core-load failures, worker spawn, decode,
|
||||
encode, timeout, abort, unsupported codec, OOM and COI gating.
|
||||
- `AudioCodecFfmpegWasmError` class — extends `SuaveplanError`, carries
|
||||
`category`, `code`, `context`, and the originating `cause`.
|
||||
- Frozen `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA` record with description and
|
||||
severity per code.
|
||||
- `registerAudioCodecFfmpegWasmErrorCodes()` no-op hook for downstream wiring.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AudioCodecFfmpegWasmError,
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-wasm/error-codes";
|
||||
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC,
|
||||
"audio-codec-ffmpeg-wasm.encode.unknown-format",
|
||||
{ context: { format: "alac" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA,
|
||||
type AudioCodecFfmpegWasmErrorCode,
|
||||
} from "@suaveplan/audio-codec-ffmpeg-wasm/error-codes";
|
||||
|
||||
function severityFor(code: AudioCodecFfmpegWasmErrorCode): string {
|
||||
return AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA[code].severity;
|
||||
}
|
||||
```
|
||||
|
||||
The metadata record is frozen so downstream consumers can safely lift it into
|
||||
read-only contexts (OTel attribute maps, code-to-i18n-key tables). Throw sites
|
||||
elsewhere in the package always carry a `context` payload — `coreUrl`,
|
||||
`format`, `elapsedMs`, `timeoutMs`, `requestedBytes`, or the Zod issues list.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUDIO_CODEC_FFMPEG_WASM_CODES` — `defineCodes("AUDIO_CODEC_FFMPEG_WASM", […])`
|
||||
result; key → namespaced code string.
|
||||
- `type AudioCodecFfmpegWasmErrorCode` — union of every code string value.
|
||||
- `AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA` — frozen `Record` of metadata.
|
||||
- `class AudioCodecFfmpegWasmError extends SuaveplanError<…>` — instances expose
|
||||
`category = "audio-codec-ffmpeg-wasm"`, `code`, `messageKey`, `context`,
|
||||
`cause`.
|
||||
- `registerAudioCodecFfmpegWasmErrorCodes()` — idempotent no-op.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The metadata record is `Object.freeze`d so it cannot be mutated at runtime —
|
||||
this keeps the OTel binder behaviour deterministic. The class has a `category`
|
||||
field so the constructor body is non-trivial, sidestepping the `useless-constructor`
|
||||
biome rule without any `biome-ignore` comment (CLAUDE.md rule 9). Every code
|
||||
emitted by this package is reachable from a real call site exercised in
|
||||
`error-codes.test.ts`, satisfying the §2c reachability requirement.
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA,
|
||||
AudioCodecFfmpegWasmError,
|
||||
registerAudioCodecFfmpegWasmErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("every code has metadata with description and severity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(AUDIO_CODEC_FFMPEG_WASM_CODES)) {
|
||||
const meta = AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA[code];
|
||||
expect(typeof meta.description).toBe("string");
|
||||
expect(meta.description.length).toBeGreaterThan(0);
|
||||
expect(["warning", "error"]).toContain(meta.severity);
|
||||
}
|
||||
});
|
||||
|
||||
it("AudioCodecFfmpegWasmError constructs with code, context, cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.DECODE_FAILED,
|
||||
"audio-codec-ffmpeg-wasm.test.message",
|
||||
{ cause, context: { foo: 1 } }
|
||||
);
|
||||
expect(err.code).toBe(AUDIO_CODEC_FFMPEG_WASM_CODES.DECODE_FAILED);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ foo: 1 });
|
||||
expect(err.name).toBe("AudioCodecFfmpegWasmError");
|
||||
expect(err.category).toBe("audio-codec-ffmpeg-wasm");
|
||||
});
|
||||
|
||||
it("registerAudioCodecFfmpegWasmErrorCodes is idempotent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => registerAudioCodecFfmpegWasmErrorCodes()).not.toThrow();
|
||||
expect(() => registerAudioCodecFfmpegWasmErrorCodes()).not.toThrow();
|
||||
});
|
||||
|
||||
it("constructor accepts default options", { timeout: 5000 }, () => {
|
||||
const err = new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED,
|
||||
"msg"
|
||||
);
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable `AUDIO_CODEC_FFMPEG_WASM.*` code registry. Every code below is thrown
|
||||
* from a real call site exercised by the test suite — see
|
||||
* `error-codes.test.ts` for the reachability assertion.
|
||||
*/
|
||||
export const AUDIO_CODEC_FFMPEG_WASM_CODES = defineCodes(
|
||||
"AUDIO_CODEC_FFMPEG_WASM",
|
||||
[
|
||||
"CORE_LOAD_FAILED",
|
||||
"WORKER_SPAWN_FAILED",
|
||||
"DECODE_FAILED",
|
||||
"ENCODE_FAILED",
|
||||
"TIMEOUT",
|
||||
"ABORTED",
|
||||
"UNSUPPORTED_CODEC",
|
||||
"MEMORY_LIMIT_EXCEEDED",
|
||||
"CROSS_ORIGIN_ISOLATION_REQUIRED",
|
||||
] as const
|
||||
);
|
||||
|
||||
export type AudioCodecFfmpegWasmErrorCode =
|
||||
(typeof AUDIO_CODEC_FFMPEG_WASM_CODES)[keyof typeof AUDIO_CODEC_FFMPEG_WASM_CODES];
|
||||
|
||||
export interface AudioCodecFfmpegWasmErrorMetadata {
|
||||
readonly description: string;
|
||||
readonly severity: "warning" | "error";
|
||||
}
|
||||
|
||||
export const AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA: Readonly<
|
||||
Record<AudioCodecFfmpegWasmErrorCode, AudioCodecFfmpegWasmErrorMetadata>
|
||||
> = Object.freeze({
|
||||
[AUDIO_CODEC_FFMPEG_WASM_CODES.CORE_LOAD_FAILED]: {
|
||||
description:
|
||||
"Lazy load of the @ffmpeg/core wasm payload failed — bad URL, CORS, or network error.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODEC_FFMPEG_WASM_CODES.WORKER_SPAWN_FAILED]: {
|
||||
description:
|
||||
"The dedicated Worker could not be constructed or emitted `error` before `load()` resolved.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODEC_FFMPEG_WASM_CODES.DECODE_FAILED]: {
|
||||
description:
|
||||
"ffmpeg returned a non-zero exit while decoding; stderr tail is attached on `context.stderr`.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODEC_FFMPEG_WASM_CODES.ENCODE_FAILED]: {
|
||||
description:
|
||||
"ffmpeg returned a non-zero exit while encoding; stderr tail is attached on `context.stderr`.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODEC_FFMPEG_WASM_CODES.TIMEOUT]: {
|
||||
description:
|
||||
"The worker did not respond within the configured timeout; the worker was terminated.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED]: {
|
||||
description:
|
||||
"An AbortSignal fired; the worker was terminated and the call rejected.",
|
||||
severity: "warning",
|
||||
},
|
||||
[AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC]: {
|
||||
description:
|
||||
"The requested format is not in the supported set, or constructor options failed Zod validation.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODEC_FFMPEG_WASM_CODES.MEMORY_LIMIT_EXCEEDED]: {
|
||||
description:
|
||||
"ffmpeg.wasm hit its 4 GB linear memory cap; the worker was terminated.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_CODEC_FFMPEG_WASM_CODES.CROSS_ORIGIN_ISOLATION_REQUIRED]: {
|
||||
description:
|
||||
"`enableMultithread: true` was requested but `globalThis.crossOriginIsolated !== true` — COOP/COEP headers missing.",
|
||||
severity: "error",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Concrete `SuaveplanError` subclass for every fault raised by
|
||||
* `@suaveplan/audio-codec-ffmpeg-wasm`. The `category` field exists so the
|
||||
* constructor body is non-trivial — no static-analysis suppression required.
|
||||
*/
|
||||
export class AudioCodecFfmpegWasmError extends SuaveplanError<AudioCodecFfmpegWasmErrorCode> {
|
||||
public readonly category = "audio-codec-ffmpeg-wasm" as const;
|
||||
|
||||
public constructor(
|
||||
code: AudioCodecFfmpegWasmErrorCode,
|
||||
messageKey: string,
|
||||
options: SuaveplanErrorOptions = {}
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "AudioCodecFfmpegWasmError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent registration hook. Metadata is already frozen at module load;
|
||||
* call this once at startup if you bind codes to OTel attributes elsewhere.
|
||||
*/
|
||||
export function registerAudioCodecFfmpegWasmErrorCodes(): void {
|
||||
// No-op — registry side-effects are static.
|
||||
}
|
||||
|
||||
registerAudioCodecFfmpegWasmErrorCodes();
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
AudioCodecFfmpegWasmErrorCode,
|
||||
AudioCodecFfmpegWasmErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA,
|
||||
AudioCodecFfmpegWasmError,
|
||||
registerAudioCodecFfmpegWasmErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
59
packages/media/audio/audio-codec-ffmpeg-wasm/src/index.ts
Normal file
59
packages/media/audio/audio-codec-ffmpeg-wasm/src/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export {
|
||||
type CallTimeouts,
|
||||
FfmpegWasmCodec,
|
||||
type FfmpegWasmCodecOptions,
|
||||
FfmpegWasmCodecOptionsSchema,
|
||||
FORMAT_MIME,
|
||||
type LoadedCore as CodecLoadedCore,
|
||||
SUPPORTED_FORMATS,
|
||||
type SupportedFormat,
|
||||
type WorkerMessage,
|
||||
} from "./codec/index.js";
|
||||
export {
|
||||
type CoreLoaderOptions,
|
||||
DEFAULT_CORE_URL,
|
||||
type LoadedCore,
|
||||
loadCore,
|
||||
} from "./core-loader/index.js";
|
||||
export {
|
||||
DEFAULT_CHANNELS,
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
type DecodePipelineOptions,
|
||||
type DecodePipelineResult,
|
||||
deinterleave,
|
||||
runDecode,
|
||||
sniffFormat,
|
||||
} from "./decode/index.js";
|
||||
export {
|
||||
buildCommand,
|
||||
type EncodePipelineOptions,
|
||||
type FormatCommand,
|
||||
float32ToBytes,
|
||||
interleave,
|
||||
runEncode,
|
||||
} from "./encode/index.js";
|
||||
export {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AUDIO_CODEC_FFMPEG_WASM_ERROR_METADATA,
|
||||
AudioCodecFfmpegWasmError,
|
||||
type AudioCodecFfmpegWasmErrorCode,
|
||||
type AudioCodecFfmpegWasmErrorMetadata,
|
||||
registerAudioCodecFfmpegWasmErrorCodes,
|
||||
} from "./error-codes/index.js";
|
||||
export { register } from "./register/index.js";
|
||||
export {
|
||||
FfmpegWasmStreamingDecoder,
|
||||
FfmpegWasmStreamingEncoder,
|
||||
type StreamingDecoderOptions,
|
||||
type StreamingEncoderOptions,
|
||||
} from "./streaming/index.js";
|
||||
export {
|
||||
DEFAULT_TIMEOUTS,
|
||||
type FfmpegRunResult,
|
||||
type WorkerCallOptions,
|
||||
WorkerHost,
|
||||
type WorkerHostOptions,
|
||||
type WorkerLike,
|
||||
type WorkerRequest,
|
||||
type WorkerResponse,
|
||||
} from "./worker/index.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { register } from "./register.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# `register.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes the `register(registry, options?)` install helper —
|
||||
a single function that callers use to plug `FfmpegWasmCodec` into a
|
||||
shared `CodecRegistry` from `@suaveplan/audio-codecs`. The helper exists
|
||||
so consumers can install the codec without importing the class
|
||||
explicitly; it forwards options through the codec constructor (which
|
||||
runs Zod validation) and returns the constructed instance so callers
|
||||
can `dispose()` it later.
|
||||
|
||||
## Features
|
||||
|
||||
- One-line installation against a `CodecRegistry`.
|
||||
- Forwards options through `FfmpegWasmCodec`'s Zod boundary.
|
||||
- Returns the constructed codec so callers retain a `dispose()` handle.
|
||||
- Re-register semantics: a second call against the same registry with
|
||||
the same codec name replaces the prior entry.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs";
|
||||
import { register } from "@suaveplan/audio-codec-ffmpeg-wasm/register/register";
|
||||
|
||||
const registry = new CodecRegistry();
|
||||
const codec = register(registry);
|
||||
// later:
|
||||
await codec.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs";
|
||||
import { register } from "@suaveplan/audio-codec-ffmpeg-wasm/register/register";
|
||||
|
||||
const registry = new CodecRegistry();
|
||||
const codec = register(registry, {
|
||||
corePath: "/static/ffmpeg-core.js",
|
||||
enableMultithread: true,
|
||||
crossOriginIsolated: globalThis.crossOriginIsolated,
|
||||
progressCallback: (p) => store.update({ encodeProgress: p }),
|
||||
logger: myLogger,
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
void codec.dispose();
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `register(registry: CodecRegistry, options?: FfmpegWasmCodecOptions): FfmpegWasmCodec`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helper deliberately delegates validation to the codec constructor
|
||||
rather than duplicating the Zod schema call here — there is exactly one
|
||||
boundary. A second call against the same registry with the same `name`
|
||||
("ffmpeg-wasm") replaces the prior entry per
|
||||
`CodecRegistry.register`'s replace-on-duplicate behaviour. Callers
|
||||
holding the previous instance from a prior `register()` call must
|
||||
explicitly `dispose()` it; the registry does not own dispose
|
||||
responsibility.
|
||||
@@ -0,0 +1,47 @@
|
||||
import { CodecRegistry } from "@suaveplan/audio-codecs/registry/registry";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeWorker } from "../../tests/fixtures/fake-worker.js";
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AudioCodecFfmpegWasmError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { register } from "./register.js";
|
||||
|
||||
describe("register", () => {
|
||||
it("installs the codec and returns the instance", { timeout: 5000 }, () => {
|
||||
const registry = new CodecRegistry();
|
||||
const codec = register(registry, {
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
workerFactory: () => new FakeWorker() as unknown as Worker,
|
||||
});
|
||||
expect(registry.get("ffmpeg-wasm")).toBe(codec);
|
||||
expect(codec.name).toBe("ffmpeg-wasm");
|
||||
});
|
||||
|
||||
it("re-registers replacing the prior instance", { timeout: 5000 }, () => {
|
||||
const registry = new CodecRegistry();
|
||||
const a = register(registry, {
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
const b = register(registry, {
|
||||
coreLoader: () => Promise.resolve({}),
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
expect(registry.get("ffmpeg-wasm")).toBe(b);
|
||||
});
|
||||
|
||||
it("validates options before construction", { timeout: 5000 }, () => {
|
||||
const registry = new CodecRegistry();
|
||||
try {
|
||||
register(registry, {
|
||||
enableMultithread: "yes" as unknown as boolean,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AudioCodecFfmpegWasmError);
|
||||
expect((err as AudioCodecFfmpegWasmError).code).toBe(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.UNSUPPORTED_CODEC
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CodecRegistry } from "@suaveplan/audio-codecs";
|
||||
import { FfmpegWasmCodec } from "../codec/ffmpeg-wasm-codec.js";
|
||||
import type { FfmpegWasmCodecOptions } from "../codec/ffmpeg-wasm-codec.types.js";
|
||||
|
||||
/**
|
||||
* Install a fresh `FfmpegWasmCodec` into the supplied `CodecRegistry`.
|
||||
*
|
||||
* Validation, construction, and registration all happen here so callers
|
||||
* can install the codec with one line. A second call against the same
|
||||
* registry replaces the prior registration — that is the documented
|
||||
* `CodecRegistry.register` behaviour for duplicate `name` entries.
|
||||
*/
|
||||
export function register(
|
||||
registry: CodecRegistry,
|
||||
options?: FfmpegWasmCodecOptions
|
||||
): FfmpegWasmCodec {
|
||||
const codec = new FfmpegWasmCodec(options);
|
||||
registry.register(codec);
|
||||
return codec;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
FfmpegWasmStreamingDecoder,
|
||||
type StreamingDecoderOptions,
|
||||
} from "./streaming-decoder.js";
|
||||
export {
|
||||
FfmpegWasmStreamingEncoder,
|
||||
type StreamingEncoderOptions,
|
||||
} from "./streaming-encoder.js";
|
||||
@@ -0,0 +1,73 @@
|
||||
# `streaming-decoder.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module implements the `StreamingDecoder` contract from
|
||||
`@suaveplan/audio-codecs` over ffmpeg.wasm. The implementation is
|
||||
buffered emulation — ffmpeg.wasm is whole-file in / whole-file out and
|
||||
cannot produce decoded pages incrementally, so this class accumulates
|
||||
chunks in memory and runs the decode pipeline once during `flush`.
|
||||
|
||||
## Features
|
||||
|
||||
- `write(chunk)` buffers without touching the worker; returns `[]`.
|
||||
- `flushAsync()` runs the decode pipeline and caches the result.
|
||||
- `flush()` (synchronous, per the contract) returns the cached result
|
||||
populated by a prior `flushAsync()` call.
|
||||
- Idempotent `flush` / `flushAsync` — subsequent calls return cached.
|
||||
- `close()` clears buffered chunks; idempotent.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FfmpegWasmStreamingDecoder } from "@suaveplan/audio-codec-ffmpeg-wasm/streaming/streaming-decoder";
|
||||
|
||||
const decoder = new FfmpegWasmStreamingDecoder({ host, format: "wav" });
|
||||
for (const chunk of chunks) decoder.write(chunk);
|
||||
const result = await decoder.flushAsync();
|
||||
decoder.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const decoder = new FfmpegWasmStreamingDecoder({
|
||||
host,
|
||||
format: "mp3",
|
||||
sampleRate: 44_100,
|
||||
channels: 2,
|
||||
timeoutMs: 30_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
let consumed = 0;
|
||||
for await (const chunk of source) {
|
||||
decoder.write(chunk);
|
||||
consumed += chunk.byteLength;
|
||||
}
|
||||
const decoded = await decoder.flushAsync();
|
||||
// Subsequent synchronous flush() calls return the cached result so
|
||||
// CodecRegistry.decodeStream can pull frames in lock-step.
|
||||
const sync = decoder.flush();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FfmpegWasmStreamingDecoder implements StreamingDecoder`.
|
||||
- `new FfmpegWasmStreamingDecoder(opts: StreamingDecoderOptions)`.
|
||||
- `decoder.write(chunk): readonly DecodeResult[]`.
|
||||
- `decoder.flush(): readonly DecodeResult[]`.
|
||||
- `decoder.flushAsync(): Promise<DecodeResult | null>`.
|
||||
- `decoder.close(): void`.
|
||||
- `interface StreamingDecoderOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `StreamingDecoder` interface in `@suaveplan/audio-codecs` is fully
|
||||
synchronous. ffmpeg.wasm cannot satisfy that synchronously, so the
|
||||
implementation exposes an additional `flushAsync()` method that callers
|
||||
must await before they invoke `flush()` to retrieve the result. The
|
||||
`CodecRegistry.decodeStream` consumer in `audio-codecs` calls `flush()`
|
||||
synchronously, so a wrapping `ReadableStream` `pull` body must await
|
||||
`flushAsync()` first. Inside an async pull body the wait is natural.
|
||||
Progress events fire only during the `flushAsync()` call, never during
|
||||
`write()`.
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildPcmBytes,
|
||||
FakeWorker,
|
||||
successScript,
|
||||
} from "../../tests/fixtures/fake-worker.js";
|
||||
import { WorkerHost } from "../worker/worker-host.js";
|
||||
import { FfmpegWasmStreamingDecoder } from "./streaming-decoder.js";
|
||||
|
||||
function makeHostWith(pcm: Uint8Array): {
|
||||
host: WorkerHost;
|
||||
worker: FakeWorker;
|
||||
} {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(pcm));
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
return { host, worker };
|
||||
}
|
||||
|
||||
describe("FfmpegWasmStreamingDecoder", () => {
|
||||
it("write does not invoke the worker", { timeout: 5000 }, () => {
|
||||
const { host, worker } = makeHostWith(buildPcmBytes(4, 1));
|
||||
const decoder = new FfmpegWasmStreamingDecoder({ host, format: "wav" });
|
||||
expect(decoder.write(new Uint8Array([1, 2, 3]))).toEqual([]);
|
||||
expect(decoder.write(new Uint8Array([4, 5, 6]))).toEqual([]);
|
||||
expect(worker.posted.length).toBe(0);
|
||||
});
|
||||
|
||||
it("flushAsync drives the worker once and flush() returns the cached result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { host } = makeHostWith(buildPcmBytes(8, 1));
|
||||
const decoder = new FfmpegWasmStreamingDecoder({
|
||||
host,
|
||||
format: "wav",
|
||||
channels: 1,
|
||||
});
|
||||
decoder.write(new Uint8Array([1, 2]));
|
||||
decoder.write(new Uint8Array([3, 4]));
|
||||
const async1 = await decoder.flushAsync();
|
||||
expect(async1).not.toBeNull();
|
||||
const sync = decoder.flush();
|
||||
expect(sync.length).toBe(1);
|
||||
expect(sync[0]!.channelData[0]!.length).toBe(8);
|
||||
// flushAsync second call returns cached
|
||||
const async2 = await decoder.flushAsync();
|
||||
expect(async2).toBe(async1);
|
||||
});
|
||||
|
||||
it("flushAsync with no chunks returns null and flush() returns empty array", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { host } = makeHostWith(buildPcmBytes(4, 1));
|
||||
const decoder = new FfmpegWasmStreamingDecoder({ host, format: "wav" });
|
||||
const result = await decoder.flushAsync();
|
||||
expect(result).toBeNull();
|
||||
expect(decoder.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("synchronous flush before flushAsync returns empty and marks flushed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { host } = makeHostWith(buildPcmBytes(4, 1));
|
||||
const decoder = new FfmpegWasmStreamingDecoder({ host, format: "wav" });
|
||||
decoder.write(new Uint8Array([1, 2]));
|
||||
const sync = decoder.flush();
|
||||
expect(sync).toEqual([]);
|
||||
// Second sync flush hits the cached-but-null branch.
|
||||
const sync2 = decoder.flush();
|
||||
expect(sync2).toEqual([]);
|
||||
// After sync flush(), flushAsync returns null per the contract.
|
||||
const a = await decoder.flushAsync();
|
||||
expect(a).toBeNull();
|
||||
});
|
||||
|
||||
it("write after flush is a no-op", { timeout: 5000 }, async () => {
|
||||
const { host, worker } = makeHostWith(buildPcmBytes(4, 1));
|
||||
const decoder = new FfmpegWasmStreamingDecoder({
|
||||
host,
|
||||
format: "wav",
|
||||
channels: 1,
|
||||
});
|
||||
decoder.write(new Uint8Array([1, 2]));
|
||||
await decoder.flushAsync();
|
||||
const beforePosted = worker.posted.length;
|
||||
decoder.write(new Uint8Array([5, 6]));
|
||||
// No additional posted messages from the post-flush write.
|
||||
expect(worker.posted.length).toBe(beforePosted);
|
||||
});
|
||||
|
||||
it("flush() called twice after flushAsync returns cached result both times", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { host } = makeHostWith(buildPcmBytes(4, 1));
|
||||
const decoder = new FfmpegWasmStreamingDecoder({
|
||||
host,
|
||||
format: "wav",
|
||||
channels: 1,
|
||||
});
|
||||
decoder.write(new Uint8Array([1, 2]));
|
||||
await decoder.flushAsync();
|
||||
const a = decoder.flush();
|
||||
const b = decoder.flush();
|
||||
expect(a).toEqual(b);
|
||||
expect(a.length).toBe(1);
|
||||
});
|
||||
|
||||
it("close clears buffered chunks; idempotent", { timeout: 5000 }, () => {
|
||||
const { host } = makeHostWith(buildPcmBytes(4, 1));
|
||||
const decoder = new FfmpegWasmStreamingDecoder({ host, format: "wav" });
|
||||
decoder.write(new Uint8Array([1, 2, 3]));
|
||||
decoder.close();
|
||||
decoder.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { DecodeResult, StreamingDecoder } from "@suaveplan/audio-codecs";
|
||||
import type { SupportedFormat } from "../codec/ffmpeg-wasm-codec.types.js";
|
||||
import { runDecode } from "../decode/decode-pipeline.js";
|
||||
import type { WorkerHost } from "../worker/worker-host.js";
|
||||
|
||||
export interface StreamingDecoderOptions {
|
||||
readonly host: WorkerHost;
|
||||
readonly format: SupportedFormat;
|
||||
readonly sampleRate?: number | undefined;
|
||||
readonly channels?: number | undefined;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly timeoutMs?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffered streaming decoder. `@ffmpeg/ffmpeg` is whole-file in / whole-file
|
||||
* out so this implementation accumulates chunks in memory and runs the
|
||||
* decode pipeline exactly once during `flush()`. Subsequent calls are
|
||||
* no-ops. This is honest "buffered emulation" — see the README streaming
|
||||
* note.
|
||||
*/
|
||||
export class FfmpegWasmStreamingDecoder implements StreamingDecoder {
|
||||
readonly #opts: StreamingDecoderOptions;
|
||||
#chunks: Uint8Array[] = [];
|
||||
#flushed = false;
|
||||
/**
|
||||
* Result captured by the awaitable `flushAsync` method so synchronous
|
||||
* `flush()` can return it; null until `flushAsync()` resolves.
|
||||
*/
|
||||
#result: DecodeResult | null = null;
|
||||
|
||||
public constructor(opts: StreamingDecoderOptions) {
|
||||
this.#opts = opts;
|
||||
}
|
||||
|
||||
/** Buffer a chunk — never invokes the worker. */
|
||||
public write(chunk: Uint8Array): readonly DecodeResult[] {
|
||||
if (this.#flushed) return [];
|
||||
this.#chunks.push(chunk);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous flush per the `StreamingDecoder` contract. Returns the
|
||||
* cached result captured by a prior `flushAsync()` call, or an empty
|
||||
* array if `flushAsync()` has not been awaited. Idempotent.
|
||||
*/
|
||||
public flush(): readonly DecodeResult[] {
|
||||
this.#flushed = true;
|
||||
return this.#result ? [this.#result] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the buffered bytes through the decode pipeline asynchronously.
|
||||
* Callers that need the result before invoking `flush()` MUST await
|
||||
* this method first. Idempotent: subsequent calls return the cached
|
||||
* result.
|
||||
*/
|
||||
public async flushAsync(): Promise<DecodeResult | null> {
|
||||
if (this.#result) return this.#result;
|
||||
if (this.#flushed && !this.#result) return null;
|
||||
const total = this.#chunks.reduce((a, b) => a + b.byteLength, 0);
|
||||
if (total === 0) {
|
||||
this.#flushed = true;
|
||||
return null;
|
||||
}
|
||||
const merged = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of this.#chunks) {
|
||||
merged.set(c, off);
|
||||
off += c.byteLength;
|
||||
}
|
||||
this.#chunks = [];
|
||||
const result = await runDecode({
|
||||
host: this.#opts.host,
|
||||
bytes: merged,
|
||||
format: this.#opts.format,
|
||||
sampleRate: this.#opts.sampleRate,
|
||||
channels: this.#opts.channels,
|
||||
signal: this.#opts.signal,
|
||||
timeoutMs: this.#opts.timeoutMs,
|
||||
});
|
||||
this.#result = result;
|
||||
this.#flushed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Release buffered chunks; idempotent. */
|
||||
public close(): void {
|
||||
this.#chunks = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# `streaming-encoder.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module implements the `StreamingEncoder` contract from
|
||||
`@suaveplan/audio-codecs` over ffmpeg.wasm. The implementation is
|
||||
buffered emulation — ffmpeg.wasm cannot stream-encode, so this class
|
||||
accumulates per-channel PCM blocks in memory and runs the encode
|
||||
pipeline once during `flushAsync`. Synchronous `start` / `write` /
|
||||
`flush` return empty bytes until `flushAsync` has been awaited.
|
||||
|
||||
## Features
|
||||
|
||||
- `start(sampleRate, channels)` captures the rate and channel count; emits
|
||||
no header bytes.
|
||||
- `write(channelData)` appends a block per channel without touching the
|
||||
worker.
|
||||
- `flushAsync()` runs the encode pipeline once and caches the encoded
|
||||
bytes.
|
||||
- `flush()` (synchronous, per the contract) returns the cached bytes.
|
||||
- Idempotent `flush` / `flushAsync` / `close`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FfmpegWasmStreamingEncoder } from "@suaveplan/audio-codec-ffmpeg-wasm/streaming/streaming-encoder";
|
||||
|
||||
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "mp3" });
|
||||
encoder.start(44_100, 2);
|
||||
encoder.write([left, right]);
|
||||
const bytes = await encoder.flushAsync();
|
||||
encoder.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const encoder = new FfmpegWasmStreamingEncoder({
|
||||
host,
|
||||
format: "opus",
|
||||
bitrate: 128_000,
|
||||
signal: controller.signal,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
encoder.start(48_000, 1);
|
||||
for (const page of pages) encoder.write(page.channelData);
|
||||
const encoded = await encoder.flushAsync();
|
||||
// Subsequent synchronous flush() calls return the cached bytes.
|
||||
const sync = encoder.flush();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FfmpegWasmStreamingEncoder implements StreamingEncoder`.
|
||||
- `new FfmpegWasmStreamingEncoder(opts: StreamingEncoderOptions)`.
|
||||
- `encoder.start(sampleRate, channels): Uint8Array`.
|
||||
- `encoder.write(channelData): Uint8Array`.
|
||||
- `encoder.flush(): Uint8Array`.
|
||||
- `encoder.flushAsync(): Promise<Uint8Array>`.
|
||||
- `encoder.close(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The encoder mirrors `streaming-decoder` exactly: `start` and `write` are
|
||||
synchronous shims that buffer; the heavy lifting happens in
|
||||
`flushAsync()`. Per-channel arrays are concatenated by allocating a
|
||||
fresh `Float32Array` per `write` call — fine for short streams; large
|
||||
streams should call the non-streaming `encode` instead. Output bytes
|
||||
are returned only once `flushAsync` resolves; the synchronous `flush()`
|
||||
returns the cached value so `CodecRegistry.encodeStream` can pull
|
||||
bytes in lock-step inside an async pull body.
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeWorker, successScript } from "../../tests/fixtures/fake-worker.js";
|
||||
import { WorkerHost } from "../worker/worker-host.js";
|
||||
import { FfmpegWasmStreamingEncoder } from "./streaming-encoder.js";
|
||||
|
||||
function makeHostWith(out: Uint8Array): {
|
||||
host: WorkerHost;
|
||||
worker: FakeWorker;
|
||||
} {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(out));
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
return { host, worker };
|
||||
}
|
||||
|
||||
describe("FfmpegWasmStreamingEncoder", () => {
|
||||
it("start + write returns empty bytes and does not invoke worker", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { host, worker } = makeHostWith(new Uint8Array([1, 2, 3]));
|
||||
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "mp3" });
|
||||
expect(encoder.start(44_100, 2).byteLength).toBe(0);
|
||||
expect(
|
||||
encoder.write([
|
||||
new Float32Array([0.1, 0.2]),
|
||||
new Float32Array([0.3, 0.4]),
|
||||
]).byteLength
|
||||
).toBe(0);
|
||||
expect(worker.posted.length).toBe(0);
|
||||
});
|
||||
|
||||
it("flushAsync runs the encode and flush() returns cached bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const expected = new Uint8Array([0xff, 0xfb, 0x90, 0x00]);
|
||||
const { host } = makeHostWith(expected);
|
||||
const encoder = new FfmpegWasmStreamingEncoder({
|
||||
host,
|
||||
format: "mp3",
|
||||
bitrate: 128_000,
|
||||
});
|
||||
encoder.start(44_100, 1);
|
||||
encoder.write([new Float32Array([0.1, -0.1, 0.2, -0.2])]);
|
||||
const a = await encoder.flushAsync();
|
||||
expect(a).toEqual(expected);
|
||||
const sync = encoder.flush();
|
||||
expect(sync).toEqual(expected);
|
||||
// flushAsync second call returns cached
|
||||
const b = await encoder.flushAsync();
|
||||
expect(b).toBe(a);
|
||||
});
|
||||
|
||||
it("flushAsync with no blocks returns empty bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { host } = makeHostWith(new Uint8Array(0));
|
||||
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "wav" });
|
||||
const a = await encoder.flushAsync();
|
||||
expect(a.byteLength).toBe(0);
|
||||
expect(encoder.flush().byteLength).toBe(0);
|
||||
});
|
||||
|
||||
it("synchronous flush before flushAsync returns empty bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { host } = makeHostWith(new Uint8Array([1]));
|
||||
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "wav" });
|
||||
encoder.start(44_100, 1);
|
||||
encoder.write([new Float32Array([0.1])]);
|
||||
expect(encoder.flush().byteLength).toBe(0);
|
||||
});
|
||||
|
||||
it("write without start infers channels from first block", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { host } = makeHostWith(new Uint8Array([1, 2, 3]));
|
||||
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "wav" });
|
||||
encoder.write([
|
||||
new Float32Array([0.1, 0.2]),
|
||||
new Float32Array([0.3, 0.4]),
|
||||
]);
|
||||
const bytes = await encoder.flushAsync();
|
||||
expect(bytes.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("write after flush is a no-op", { timeout: 5000 }, async () => {
|
||||
const { host } = makeHostWith(new Uint8Array([1]));
|
||||
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "wav" });
|
||||
encoder.start(44_100, 1);
|
||||
encoder.write([new Float32Array([0.1])]);
|
||||
await encoder.flushAsync();
|
||||
expect(encoder.write([new Float32Array([0.5])]).byteLength).toBe(0);
|
||||
});
|
||||
|
||||
it("flush() called twice after flushAsync returns cached bytes both times", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const expected = new Uint8Array([1, 2, 3]);
|
||||
const { host } = makeHostWith(expected);
|
||||
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "wav" });
|
||||
encoder.start(44_100, 1);
|
||||
encoder.write([new Float32Array([0.1])]);
|
||||
await encoder.flushAsync();
|
||||
const a = encoder.flush();
|
||||
const b = encoder.flush();
|
||||
expect(a).toEqual(b);
|
||||
expect(a).toEqual(expected);
|
||||
});
|
||||
|
||||
it("write with fewer channels than started pads with empty buffers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { host } = makeHostWith(new Uint8Array([1, 2]));
|
||||
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "wav" });
|
||||
encoder.start(44_100, 2);
|
||||
// Only supply one channel — the second falls back to empty.
|
||||
encoder.write([new Float32Array([0.1, 0.2])]);
|
||||
const bytes = await encoder.flushAsync();
|
||||
expect(bytes.byteLength).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("close clears buffered samples; idempotent", { timeout: 5000 }, () => {
|
||||
const { host } = makeHostWith(new Uint8Array());
|
||||
const encoder = new FfmpegWasmStreamingEncoder({ host, format: "wav" });
|
||||
encoder.start(44_100, 1);
|
||||
encoder.write([new Float32Array([0.1])]);
|
||||
encoder.close();
|
||||
encoder.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { StreamingEncoder } from "@suaveplan/audio-codecs";
|
||||
import type { SupportedFormat } from "../codec/ffmpeg-wasm-codec.types.js";
|
||||
import { runEncode } from "../encode/encode-pipeline.js";
|
||||
import type { WorkerHost } from "../worker/worker-host.js";
|
||||
|
||||
export interface StreamingEncoderOptions {
|
||||
readonly host: WorkerHost;
|
||||
readonly format: SupportedFormat;
|
||||
readonly bitrate?: number | undefined;
|
||||
readonly quality?: number | undefined;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly timeoutMs?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffered streaming encoder. Channel-data blocks fed via `write` are
|
||||
* accumulated and concatenated, then encoded in one pass during
|
||||
* `flushAsync`. The synchronous `start` / `write` / `flush` triad
|
||||
* satisfies the `StreamingEncoder` contract — `start` and `write`
|
||||
* return empty bytes; `flush` returns the cached encoded output once
|
||||
* `flushAsync` has been awaited.
|
||||
*/
|
||||
export class FfmpegWasmStreamingEncoder implements StreamingEncoder {
|
||||
readonly #opts: StreamingEncoderOptions;
|
||||
#channelData: Float32Array[] = [];
|
||||
#sampleRate = 48_000;
|
||||
#channels = 0;
|
||||
#flushed = false;
|
||||
#result: Uint8Array | null = null;
|
||||
|
||||
public constructor(opts: StreamingEncoderOptions) {
|
||||
this.#opts = opts;
|
||||
}
|
||||
|
||||
/** Header — returns empty bytes; sample rate and channel count are captured. */
|
||||
public start(sampleRate: number, channels: number): Uint8Array {
|
||||
this.#sampleRate = sampleRate;
|
||||
this.#channels = channels;
|
||||
this.#channelData = Array.from(
|
||||
{ length: channels },
|
||||
() => new Float32Array(0)
|
||||
);
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
/** Append a block — returns empty bytes; never touches the worker. */
|
||||
public write(channelData: readonly Float32Array[]): Uint8Array {
|
||||
if (this.#flushed) return new Uint8Array(0);
|
||||
if (this.#channels === 0) {
|
||||
this.#channels = channelData.length;
|
||||
this.#channelData = Array.from(
|
||||
{ length: this.#channels },
|
||||
() => new Float32Array(0)
|
||||
);
|
||||
}
|
||||
for (let c = 0; c < this.#channels; c++) {
|
||||
const cur = this.#channelData[c]!;
|
||||
const next = channelData[c] ?? new Float32Array(0);
|
||||
const merged = new Float32Array(cur.length + next.length);
|
||||
merged.set(cur);
|
||||
merged.set(next, cur.length);
|
||||
this.#channelData[c] = merged;
|
||||
}
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous flush per the `StreamingEncoder` contract. Returns the
|
||||
* cached bytes captured by `flushAsync`, or an empty array if
|
||||
* `flushAsync` has not been awaited.
|
||||
*/
|
||||
public flush(): Uint8Array {
|
||||
this.#flushed = true;
|
||||
return this.#result ?? new Uint8Array(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the buffered PCM through the encode pipeline. Idempotent —
|
||||
* subsequent calls return the cached bytes.
|
||||
*/
|
||||
public async flushAsync(): Promise<Uint8Array> {
|
||||
if (this.#result) return this.#result;
|
||||
if (this.#channels === 0) {
|
||||
this.#flushed = true;
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
const bytes = await runEncode({
|
||||
host: this.#opts.host,
|
||||
channelData: this.#channelData,
|
||||
format: this.#opts.format,
|
||||
sampleRate: this.#sampleRate,
|
||||
bitrate: this.#opts.bitrate,
|
||||
quality: this.#opts.quality,
|
||||
signal: this.#opts.signal,
|
||||
timeoutMs: this.#opts.timeoutMs,
|
||||
});
|
||||
this.#result = bytes;
|
||||
this.#flushed = true;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Release buffered samples; idempotent. */
|
||||
public close(): void {
|
||||
this.#channelData = [];
|
||||
}
|
||||
}
|
||||
24
packages/media/audio/audio-codec-ffmpeg-wasm/src/types/ffmpeg-shim.d.ts
vendored
Normal file
24
packages/media/audio/audio-codec-ffmpeg-wasm/src/types/ffmpeg-shim.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
// Type shims for the optional `@ffmpeg/*` packages. The runtime resolution
|
||||
// happens via dynamic `import()` so the missing modules never reach the
|
||||
// bundler at static-analysis time; these `declare module` blocks satisfy
|
||||
// the TypeScript compiler when the optional deps are not installed.
|
||||
|
||||
declare module "@ffmpeg/core" {
|
||||
const mod: unknown;
|
||||
export default mod;
|
||||
}
|
||||
|
||||
declare module "@ffmpeg/core-mt" {
|
||||
const mod: unknown;
|
||||
export default mod;
|
||||
}
|
||||
|
||||
declare module "@ffmpeg/ffmpeg" {
|
||||
const mod: unknown;
|
||||
export default mod;
|
||||
}
|
||||
|
||||
declare module "@ffmpeg/util" {
|
||||
const mod: unknown;
|
||||
export default mod;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { DEFAULT_TIMEOUTS, WorkerHost } from "./worker-host.js";
|
||||
export type {
|
||||
FfmpegRunResult,
|
||||
WorkerCallOptions,
|
||||
WorkerHostOptions,
|
||||
WorkerLike,
|
||||
WorkerRequest,
|
||||
WorkerResponse,
|
||||
} from "./worker-host.types.js";
|
||||
@@ -0,0 +1,99 @@
|
||||
# `worker-host.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the lifecycle of the dedicated Web Worker that runs the
|
||||
ffmpeg.wasm core. It serialises every codec call onto the worker, plumbs
|
||||
`AbortSignal` and a timeout watchdog through to `worker.terminate()`,
|
||||
parses worker stderr for memory-limit conditions, and bridges progress
|
||||
events to the user-supplied callback. The host is the unit of cancellation
|
||||
for the entire codec.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy worker construction through the injected `workerFactory` seam.
|
||||
- One-time `load` handshake; subsequent calls reuse the loaded core.
|
||||
- `submitFfmpeg` writes input bytes to MEMFS, runs an ffmpeg command,
|
||||
reads the output, and cleans both MEMFS entries via try/finally.
|
||||
- Pre-aborted and mid-call `AbortSignal` handling — terminate the worker
|
||||
and reject every pending call with `AUDIO_CODEC_FFMPEG_WASM.ABORTED`.
|
||||
- Per-call timeout watchdog driven by the injected `Clock`. Defaults
|
||||
60 s decode / 120 s encode; configurable per call.
|
||||
- Memory-limit detection scans worker stderr (`Cannot enlarge memory`,
|
||||
`OutOfMemory`, `Aborted()`) and surfaces
|
||||
`AUDIO_CODEC_FFMPEG_WASM.MEMORY_LIMIT_EXCEEDED`.
|
||||
- Worker spawn failures (factory throws, `error` before `load` resolves,
|
||||
`postMessage` throws) surface as `WORKER_SPAWN_FAILED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WorkerHost } from "@suaveplan/audio-codec-ffmpeg-wasm/worker/worker-host";
|
||||
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => new Worker(coreUrl),
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
await host.load();
|
||||
const { outputBytes, stderr } = await host.submitFfmpeg(
|
||||
{ path: "input.mp3", bytes: inputBytes },
|
||||
"output.raw",
|
||||
["-i", "input.mp3", "-f", "f32le", "output.raw"],
|
||||
{ category: "decode" },
|
||||
);
|
||||
host.terminate();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { WorkerHost } from "@suaveplan/audio-codec-ffmpeg-wasm/worker/worker-host";
|
||||
|
||||
const ctl = new AbortController();
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => new Worker(coreUrl, { type: "module" }),
|
||||
clock: () => performance.now(),
|
||||
progressCallback: (p) => console.log("progress", p),
|
||||
logger: myLogger,
|
||||
});
|
||||
try {
|
||||
const result = await host.submitFfmpeg(
|
||||
{ path: "in.wav", bytes: wavBytes },
|
||||
"out.opus",
|
||||
["-i", "in.wav", "-c:a", "libopus", "-b:a", "96k", "out.opus"],
|
||||
{ category: "encode", signal: ctl.signal, timeoutMs: 30_000 },
|
||||
);
|
||||
return result.outputBytes;
|
||||
} finally {
|
||||
host.terminate();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class WorkerHost` — owns one worker.
|
||||
- `new WorkerHost(opts: WorkerHostOptions)`.
|
||||
- `host.isLoaded: boolean`.
|
||||
- `host.ensureWorker(): WorkerLike`.
|
||||
- `host.load(): Promise<void>`.
|
||||
- `host.submitFfmpeg(input, outputPath, args, opts): Promise<FfmpegRunResult>`.
|
||||
- `host.terminate(reason?: AudioCodecFfmpegWasmError): void`.
|
||||
- `host.onProgress(progress: number): void`.
|
||||
- `DEFAULT_TIMEOUTS` — readonly `{ decode: 60_000, encode: 120_000 }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The host treats the worker as a stateful resource: every call goes through
|
||||
`ensureWorker`, every failure path resets the worker reference so the next
|
||||
call constructs a fresh one. The `WorkerLike` interface only requires
|
||||
`postMessage` / `addEventListener` / `removeEventListener` / `terminate`,
|
||||
so the real DOM `Worker` and the test shim are both first-class. Memory
|
||||
detection runs against both the structured `WorkerResponse.stderr` channel
|
||||
and any free-floating `stderr` events posted between requests. The
|
||||
watchdog timer is cleared on every settle path — success, failure,
|
||||
abort, and the OOM short-circuit — so the host never leaks pending
|
||||
`setTimeout` handles.
|
||||
@@ -0,0 +1,706 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildPcmBytes,
|
||||
FakeWorker,
|
||||
successScript,
|
||||
} from "../../tests/fixtures/fake-worker.js";
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AudioCodecFfmpegWasmError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { DEFAULT_TIMEOUTS, WorkerHost } from "./worker-host.js";
|
||||
|
||||
function makeHost(worker: FakeWorker, clock?: () => number) {
|
||||
return new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: clock ?? (() => Date.now()),
|
||||
});
|
||||
}
|
||||
|
||||
describe("WorkerHost", () => {
|
||||
it("DEFAULT_TIMEOUTS exposes 60s decode and 120s encode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(DEFAULT_TIMEOUTS.decode).toBe(60_000);
|
||||
expect(DEFAULT_TIMEOUTS.encode).toBe(120_000);
|
||||
});
|
||||
|
||||
it("submitFfmpeg drives the full request sequence and returns outputBytes+stderr", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const bytes = buildPcmBytes(100, 1);
|
||||
worker.script.push(...successScript(bytes, "ffmpeg-stderr"));
|
||||
const host = makeHost(worker);
|
||||
const result = await host.submitFfmpeg(
|
||||
{ path: "in.wav", bytes: new Uint8Array([1, 2, 3]) },
|
||||
"out.raw",
|
||||
["-i", "in.wav", "out.raw"],
|
||||
{ category: "decode" }
|
||||
);
|
||||
expect(result.outputBytes).toEqual(bytes);
|
||||
expect(result.stderr).toBe("ffmpeg-stderr");
|
||||
expect(host.isLoaded).toBe(true);
|
||||
// posted: load, writeFile, run, readFile, deleteFile, deleteFile
|
||||
expect(worker.posted.length).toBe(6);
|
||||
});
|
||||
|
||||
it("workerFactory test seam is honoured", { timeout: 5000 }, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push(...successScript(new Uint8Array(0)));
|
||||
const host = makeHost(worker);
|
||||
await host.submitFfmpeg(
|
||||
{ path: "i", bytes: new Uint8Array(0) },
|
||||
"o",
|
||||
[],
|
||||
{ category: "decode" }
|
||||
);
|
||||
expect(worker.terminated).toBe(0);
|
||||
host.terminate();
|
||||
expect(worker.terminated).toBe(1);
|
||||
});
|
||||
|
||||
it("worker spawn failure throws WORKER_SPAWN_FAILED when factory throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => {
|
||||
throw new Error("constructor blew up");
|
||||
},
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "decode",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.WORKER_SPAWN_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("worker spawn failure throws WORKER_SPAWN_FAILED when error fires before load resolves", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
// First posted message is load — drop it and emit error instead.
|
||||
worker.script.push((_req) => {
|
||||
queueMicrotask(() => worker.emitError("boot failure"));
|
||||
return "drop";
|
||||
});
|
||||
const host = makeHost(worker);
|
||||
await expect(host.load()).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.WORKER_SPAWN_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("pre-aborted signal short-circuits before worker touch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const ctl = new AbortController();
|
||||
ctl.abort();
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "decode",
|
||||
signal: ctl.signal,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED,
|
||||
});
|
||||
expect(worker.posted.length).toBe(0);
|
||||
});
|
||||
|
||||
it("mid-call abort terminates the worker", { timeout: 5000 }, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const ctl = new AbortController();
|
||||
// Respond to `load` then drop subsequent so the call hangs.
|
||||
worker.script.push((req) => ({ id: req.id, ok: true }));
|
||||
worker.script.push((_req) => {
|
||||
queueMicrotask(() => ctl.abort());
|
||||
return "drop";
|
||||
});
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "decode",
|
||||
signal: ctl.signal,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED,
|
||||
});
|
||||
expect(worker.terminated).toBe(1);
|
||||
});
|
||||
|
||||
it("timeout throws TIMEOUT with elapsedMs and timeoutMs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
// Respond to load only — the writeFile call will hang.
|
||||
worker.script.push((req) => ({ id: req.id, ok: true }));
|
||||
worker.script.push((_req) => "drop");
|
||||
let now = 0;
|
||||
const host = makeHost(worker, () => now);
|
||||
const promise = host.submitFfmpeg(
|
||||
{ path: "i", bytes: new Uint8Array() },
|
||||
"o",
|
||||
[],
|
||||
{ category: "decode", timeoutMs: 10 }
|
||||
);
|
||||
// Attach a no-op catch immediately so the rejection that fires during
|
||||
// the 30 ms wait does not surface as an unhandled rejection.
|
||||
promise.catch(() => undefined);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
now = 25;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.TIMEOUT,
|
||||
});
|
||||
expect(worker.terminated).toBe(1);
|
||||
});
|
||||
|
||||
it("ffmpeg non-zero exit on decode maps to DECODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // writeFile
|
||||
worker.script.push((req) => ({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
stderr: "Invalid data found when processing input",
|
||||
}));
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg(
|
||||
{ path: "in.mp3", bytes: new Uint8Array() },
|
||||
"out.raw",
|
||||
[],
|
||||
{ category: "decode" }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("ffmpeg non-zero exit on encode maps to ENCODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true }));
|
||||
worker.script.push((req) => ({ id: req.id, ok: true }));
|
||||
worker.script.push((req) => ({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
stderr: "encoder failed",
|
||||
}));
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "encode",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.ENCODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("wasm OOM (Cannot enlarge memory) maps to MEMORY_LIMIT_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // writeFile
|
||||
worker.script.push((req) => ({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
stderr: "Cannot enlarge memory arrays. 65536 bytes requested.",
|
||||
}));
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "decode",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.MEMORY_LIMIT_EXCEEDED,
|
||||
});
|
||||
});
|
||||
|
||||
it("OOM stderr stream event maps to MEMORY_LIMIT_EXCEEDED on pending call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
// For the run call, emit a stderr stream event first, then drop.
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // writeFile
|
||||
worker.script.push((_req) => {
|
||||
queueMicrotask(() => {
|
||||
worker.emitMessage({
|
||||
kind: "stderr",
|
||||
line: "OutOfMemory 524288 bytes requested",
|
||||
});
|
||||
});
|
||||
return "drop";
|
||||
});
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "decode",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.MEMORY_LIMIT_EXCEEDED,
|
||||
});
|
||||
});
|
||||
|
||||
it("progress event forwards to user callback after clamp", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const seen: number[] = [];
|
||||
const worker = new FakeWorker();
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
progressCallback: (p) => seen.push(p),
|
||||
});
|
||||
host.ensureWorker();
|
||||
worker.emitMessage({ kind: "progress", progress: 0.5 });
|
||||
worker.emitMessage({ kind: "progress", progress: 1.5 });
|
||||
worker.emitMessage({ kind: "progress", progress: -1 });
|
||||
expect(seen).toEqual([0.5, 1, 0]);
|
||||
// callback throws — host swallows
|
||||
const host2 = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
progressCallback: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
});
|
||||
expect(() => host2.onProgress(0.2)).not.toThrow();
|
||||
});
|
||||
|
||||
it("free-floating stderr lines without pending calls are ignored", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const host = makeHost(worker);
|
||||
host.ensureWorker();
|
||||
worker.emitMessage({ kind: "stderr", line: "Cannot enlarge memory" });
|
||||
// No pending calls; should not throw.
|
||||
expect(host.isLoaded).toBe(false);
|
||||
});
|
||||
|
||||
it("non-memory stderr lines are silently dropped", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const worker = new FakeWorker();
|
||||
const host = makeHost(worker);
|
||||
host.ensureWorker();
|
||||
worker.emitMessage({ kind: "stderr", line: "frame= 100" });
|
||||
expect(host.isLoaded).toBe(false);
|
||||
});
|
||||
|
||||
it("unknown response id is ignored", { timeout: 5000 }, () => {
|
||||
const worker = new FakeWorker();
|
||||
const host = makeHost(worker);
|
||||
host.ensureWorker();
|
||||
worker.emitMessage({ id: 9999, ok: true });
|
||||
// No throw.
|
||||
expect(host.isLoaded).toBe(false);
|
||||
});
|
||||
|
||||
it("worker.terminate exceptions are swallowed via logger", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.terminate = () => {
|
||||
worker.terminated++;
|
||||
throw new Error("terminate failed");
|
||||
};
|
||||
const warnings: string[] = [];
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
logger: {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn: (m) => warnings.push(m),
|
||||
error() {},
|
||||
},
|
||||
});
|
||||
host.ensureWorker();
|
||||
expect(() => host.terminate()).not.toThrow();
|
||||
expect(warnings.some((w) => w.includes("terminate-threw"))).toBe(true);
|
||||
});
|
||||
|
||||
it("postMessage failure on the worker maps to WORKER_SPAWN_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.postMessage = () => {
|
||||
throw new Error("post failed");
|
||||
};
|
||||
const host = makeHost(worker);
|
||||
await expect(host.load()).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.WORKER_SPAWN_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("ensureWorker reuses existing worker on second call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const worker = new FakeWorker();
|
||||
const host = makeHost(worker);
|
||||
const first = host.ensureWorker();
|
||||
const second = host.ensureWorker();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("load is idempotent (second call returns same promise)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true }));
|
||||
const host = makeHost(worker);
|
||||
const a = host.load();
|
||||
const b = host.load();
|
||||
expect(a).toBe(b);
|
||||
await a;
|
||||
// After load resolves, calling again is still idempotent (returns the same resolved promise)
|
||||
const c = host.load();
|
||||
await c;
|
||||
});
|
||||
|
||||
it("pre-aborted signal in dispatch path rejects immediately", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
const host = makeHost(worker);
|
||||
await host.load();
|
||||
const ctl = new AbortController();
|
||||
ctl.abort();
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "decode",
|
||||
signal: ctl.signal,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("error event after load resolved on a pending decode → DECODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
worker.script.push((_req) => {
|
||||
queueMicrotask(() => worker.emitError("crashed"));
|
||||
return "drop";
|
||||
});
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "decode",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.DECODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("error event after load resolved on a pending encode → ENCODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true }));
|
||||
worker.script.push((_req) => {
|
||||
queueMicrotask(() => worker.emitError("crashed"));
|
||||
return "drop";
|
||||
});
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "encode",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.ENCODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("error event with no pending calls is logged and ignored", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const worker = new FakeWorker();
|
||||
const warnings: string[] = [];
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
logger: {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn: (m) => warnings.push(m),
|
||||
error() {},
|
||||
},
|
||||
});
|
||||
host.ensureWorker();
|
||||
worker.emitError("spurious");
|
||||
expect(warnings.some((w) => w.includes("unhandled-error"))).toBe(true);
|
||||
});
|
||||
|
||||
it("deleteFile failure during cleanup is logged but not surfaced", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const warnings: string[] = [];
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // writeFile
|
||||
worker.script.push((req) => ({ id: req.id, ok: true, stderr: "" })); // run
|
||||
worker.script.push((req) => ({
|
||||
id: req.id,
|
||||
ok: true,
|
||||
value: new Uint8Array(0),
|
||||
})); // readFile
|
||||
worker.script.push((req) => ({ id: req.id, ok: false, stderr: "" })); // deleteFile input fails
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // deleteFile output ok
|
||||
const host = new WorkerHost({
|
||||
workerFactory: () => worker,
|
||||
clock: () => Date.now(),
|
||||
logger: {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn: (m) => warnings.push(m),
|
||||
error() {},
|
||||
},
|
||||
});
|
||||
await host.submitFfmpeg(
|
||||
{ path: "i", bytes: new Uint8Array() },
|
||||
"o",
|
||||
[],
|
||||
{ category: "decode" }
|
||||
);
|
||||
expect(warnings.some((w) => w.includes("delete-failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("invariant: terminate clears pending state and allows re-load", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
const host = makeHost(worker);
|
||||
await host.load();
|
||||
host.terminate();
|
||||
expect(host.isLoaded).toBe(false);
|
||||
// Next call constructs a fresh worker via the factory.
|
||||
const worker2 = new FakeWorker();
|
||||
worker2.script.push(...successScript(new Uint8Array(8)));
|
||||
const host2 = new WorkerHost({
|
||||
workerFactory: () => worker2,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
await host2.submitFfmpeg(
|
||||
{ path: "i", bytes: new Uint8Array() },
|
||||
"o",
|
||||
[],
|
||||
{ category: "decode" }
|
||||
);
|
||||
expect(worker2.terminated).toBe(0);
|
||||
});
|
||||
|
||||
it("terminate without reason rejects pending with default ABORTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
worker.script.push((_req) => "drop"); // writeFile hangs
|
||||
const host = makeHost(worker);
|
||||
const promise = host.submitFfmpeg(
|
||||
{ path: "i", bytes: new Uint8Array() },
|
||||
"o",
|
||||
[],
|
||||
{ category: "decode" }
|
||||
);
|
||||
promise.catch(() => undefined);
|
||||
// Wait for the writeFile dispatch to enter pending.
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
host.terminate(); // no reason argument
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("terminate is idempotent — second call with no worker is a no-op", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const worker = new FakeWorker();
|
||||
const host = makeHost(worker);
|
||||
host.ensureWorker();
|
||||
host.terminate();
|
||||
// Second call: worker is gone, listeners gone, pending empty.
|
||||
host.terminate();
|
||||
});
|
||||
|
||||
it("OOM stderr without a parseable bytes count surfaces with undefined requestedBytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // writeFile
|
||||
worker.script.push((req) => ({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
stderr: "OutOfMemory",
|
||||
}));
|
||||
const host = makeHost(worker);
|
||||
try {
|
||||
await host.submitFfmpeg(
|
||||
{ path: "i", bytes: new Uint8Array() },
|
||||
"o",
|
||||
[],
|
||||
{ category: "decode" }
|
||||
);
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.MEMORY_LIMIT_EXCEEDED
|
||||
);
|
||||
expect(
|
||||
(err as { context: { requestedBytes?: number } }).context
|
||||
.requestedBytes
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("onProgress is a no-op when no progressCallback is set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const worker = new FakeWorker();
|
||||
const host = makeHost(worker);
|
||||
expect(() => host.onProgress(0.5)).not.toThrow();
|
||||
});
|
||||
|
||||
it("failure response without stderr field falls back to empty string", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // writeFile
|
||||
worker.script.push((req) => ({ id: req.id, ok: false })); // no stderr
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "encode",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.ENCODE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("free OOM stderr line without parseable byte count is still surfaced", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // writeFile
|
||||
worker.script.push((_req) => {
|
||||
queueMicrotask(() => {
|
||||
worker.emitMessage({
|
||||
kind: "stderr",
|
||||
line: "Cannot enlarge memory arrays",
|
||||
});
|
||||
});
|
||||
return "drop";
|
||||
});
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "decode",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.MEMORY_LIMIT_EXCEEDED,
|
||||
});
|
||||
});
|
||||
|
||||
it("message with unknown kind falls through to response handling", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const worker = new FakeWorker();
|
||||
const host = makeHost(worker);
|
||||
host.ensureWorker();
|
||||
// Unknown kind — not progress, not stderr; not a valid response either.
|
||||
// Treated as a response; no pending with that id → silently ignored.
|
||||
worker.emitMessage({ kind: "other", id: 999 });
|
||||
});
|
||||
|
||||
it("load already-loaded reuses the same promise after settle", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true }));
|
||||
const host = makeHost(worker);
|
||||
await host.load();
|
||||
const second = host.load();
|
||||
await second;
|
||||
expect(host.isLoaded).toBe(true);
|
||||
});
|
||||
|
||||
it("dispatch-level signal abort fires when signal aborts after submitFfmpeg starts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
const ctl = new AbortController();
|
||||
// Load handler resolves, but before reading the next handler we
|
||||
// synchronously abort, so the writeFile dispatch's pre-abort guard
|
||||
// (line 251 in #dispatch) trips before posting to the worker.
|
||||
worker.script.push((req) => {
|
||||
queueMicrotask(() => ctl.abort());
|
||||
return { id: req.id, ok: true };
|
||||
});
|
||||
const host = makeHost(worker);
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "decode",
|
||||
signal: ctl.signal,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("postMessage throw during dispatch maps to WORKER_SPAWN_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
worker.script.push((req) => ({ id: req.id, ok: true })); // load
|
||||
const host = makeHost(worker);
|
||||
await host.load();
|
||||
// Override postMessage AFTER load resolved so the failure occurs in
|
||||
// the writeFile dispatch path, not in load().
|
||||
worker.postMessage = () => {
|
||||
throw new Error("post failed mid-call");
|
||||
};
|
||||
await expect(
|
||||
host.submitFfmpeg({ path: "i", bytes: new Uint8Array() }, "o", [], {
|
||||
category: "decode",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_CODEC_FFMPEG_WASM_CODES.WORKER_SPAWN_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("load rejection preserved when underlying error is already AudioCodecFfmpegWasmError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const worker = new FakeWorker();
|
||||
// Respond ok:false so #onMessage wraps it as a DECODE_FAILED
|
||||
// AudioCodecFfmpegWasmError, which propagates through load's wrapper.
|
||||
worker.script.push((req) => ({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
stderr: "",
|
||||
}));
|
||||
const host = makeHost(worker);
|
||||
try {
|
||||
await host.load();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AudioCodecFfmpegWasmError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES,
|
||||
AudioCodecFfmpegWasmError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
FfmpegRunResult,
|
||||
WorkerCallOptions,
|
||||
WorkerHostOptions,
|
||||
WorkerLike,
|
||||
WorkerRequest,
|
||||
WorkerResponse,
|
||||
} from "./worker-host.types.js";
|
||||
|
||||
/**
|
||||
* Default per-call timeouts in milliseconds. Matched to the spec defaults
|
||||
* (60 s decode / 120 s encode). Override via `WorkerCallOptions.timeoutMs`.
|
||||
*/
|
||||
export const DEFAULT_TIMEOUTS: Readonly<Record<"decode" | "encode", number>> =
|
||||
Object.freeze({ decode: 60_000, encode: 120_000 });
|
||||
|
||||
interface PendingCall {
|
||||
readonly id: number;
|
||||
readonly resolve: (value: unknown) => void;
|
||||
readonly reject: (err: unknown) => void;
|
||||
readonly category: "decode" | "encode";
|
||||
readonly startedAt: number;
|
||||
timer: ReturnType<typeof setTimeout> | undefined;
|
||||
abortListener: (() => void) | undefined;
|
||||
signal: AbortSignal | undefined;
|
||||
stderrCapture: string;
|
||||
}
|
||||
|
||||
const MEMORY_LIMIT_REGEX = /(?:Cannot enlarge memory|OutOfMemory|Aborted\(\))/i;
|
||||
const MEMORY_BYTES_REGEX = /(\d+)\s*bytes/i;
|
||||
|
||||
/**
|
||||
* Owns a single underlying worker and serialises calls onto it. Production
|
||||
* callers supply `opts.workerFactory: () => new Worker(coreUrl)`; tests supply
|
||||
* a deterministic shim that emits scripted responses.
|
||||
*
|
||||
* The host emits one error code per failure mode. Memory-limit detection
|
||||
* scans every stderr line received from the worker before the success path
|
||||
* resolves.
|
||||
*/
|
||||
export class WorkerHost {
|
||||
readonly #factory: () => WorkerLike;
|
||||
readonly #clock: () => number;
|
||||
readonly #logger: WorkerHostOptions["logger"];
|
||||
readonly #progressCallback: WorkerHostOptions["progressCallback"];
|
||||
#worker: WorkerLike | undefined;
|
||||
#loaded: Promise<void> | undefined;
|
||||
#loadedFlag = false;
|
||||
#nextId = 1;
|
||||
#pending = new Map<number, PendingCall>();
|
||||
#messageListener: ((ev: MessageEvent) => void) | undefined;
|
||||
#errorListener: ((ev: ErrorEvent) => void) | undefined;
|
||||
|
||||
public constructor(opts: WorkerHostOptions) {
|
||||
this.#factory = opts.workerFactory;
|
||||
this.#clock = opts.clock;
|
||||
this.#logger = opts.logger;
|
||||
this.#progressCallback = opts.progressCallback;
|
||||
}
|
||||
|
||||
/** True once `load()` has resolved on the active worker. */
|
||||
public get isLoaded(): boolean {
|
||||
return this.#loadedFlag;
|
||||
}
|
||||
|
||||
/** Eagerly construct the worker if it does not already exist. */
|
||||
public ensureWorker(): WorkerLike {
|
||||
if (this.#worker) return this.#worker;
|
||||
let w: WorkerLike;
|
||||
try {
|
||||
w = this.#factory();
|
||||
} catch (err) {
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.WORKER_SPAWN_FAILED,
|
||||
"audio-codec-ffmpeg-wasm.worker.spawn-threw",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
this.#messageListener = (ev) => this.#onMessage(ev);
|
||||
this.#errorListener = (ev) => this.#onError(ev);
|
||||
w.addEventListener("message", this.#messageListener);
|
||||
w.addEventListener("error", this.#errorListener);
|
||||
this.#worker = w;
|
||||
return w;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time `load` handshake with the worker. Subsequent calls reuse the
|
||||
* same promise. Re-entrant safe; if the worker is later recycled via
|
||||
* `terminate()` the next call will construct a fresh worker and re-load.
|
||||
*/
|
||||
public load(): Promise<void> {
|
||||
if (this.#loaded) return this.#loaded;
|
||||
const worker = this.ensureWorker();
|
||||
const id = this.#nextId++;
|
||||
this.#loaded = new Promise<void>((resolve, reject) => {
|
||||
const pending: PendingCall = {
|
||||
id,
|
||||
resolve: () => {
|
||||
this.#loadedFlag = true;
|
||||
resolve();
|
||||
},
|
||||
reject,
|
||||
category: "decode",
|
||||
startedAt: this.#clock(),
|
||||
timer: undefined,
|
||||
abortListener: undefined,
|
||||
signal: undefined,
|
||||
stderrCapture: "",
|
||||
};
|
||||
this.#pending.set(id, pending);
|
||||
const request: WorkerRequest = { id, kind: "load" };
|
||||
try {
|
||||
worker.postMessage(request);
|
||||
} catch (err) {
|
||||
this.#pending.delete(id);
|
||||
reject(
|
||||
new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.WORKER_SPAWN_FAILED,
|
||||
"audio-codec-ffmpeg-wasm.worker.load-post-failed",
|
||||
{ cause: err }
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
return this.#loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a full ffmpeg invocation. The worker responds with a single
|
||||
* `ok: true` or `ok: false` per request id.
|
||||
*/
|
||||
public async submitFfmpeg(
|
||||
input: { path: string; bytes: Uint8Array },
|
||||
outputPath: string,
|
||||
args: readonly string[],
|
||||
opts: WorkerCallOptions
|
||||
): Promise<FfmpegRunResult> {
|
||||
if (opts.signal?.aborted) {
|
||||
throw new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED,
|
||||
"audio-codec-ffmpeg-wasm.worker.pre-aborted"
|
||||
);
|
||||
}
|
||||
await this.load();
|
||||
await this.#dispatch(
|
||||
{ kind: "writeFile", path: input.path, bytes: input.bytes },
|
||||
opts
|
||||
);
|
||||
const runResult = (await this.#dispatch({ kind: "run", args }, opts)) as
|
||||
| { stderr?: string }
|
||||
| undefined;
|
||||
const stderr =
|
||||
runResult && typeof runResult.stderr === "string"
|
||||
? runResult.stderr
|
||||
: "";
|
||||
let outputBytes: Uint8Array;
|
||||
try {
|
||||
outputBytes = (await this.#dispatch(
|
||||
{ kind: "readFile", path: outputPath },
|
||||
opts
|
||||
)) as Uint8Array;
|
||||
} finally {
|
||||
await this.#tryDelete(input.path, opts);
|
||||
await this.#tryDelete(outputPath, opts);
|
||||
}
|
||||
return { outputBytes, stderr };
|
||||
}
|
||||
|
||||
/** Terminate the underlying worker and reject every pending call. */
|
||||
public terminate(reason?: AudioCodecFfmpegWasmError): void {
|
||||
const worker = this.#worker;
|
||||
const pending = Array.from(this.#pending.values());
|
||||
this.#pending.clear();
|
||||
this.#loaded = undefined;
|
||||
this.#loadedFlag = false;
|
||||
this.#worker = undefined;
|
||||
if (worker) {
|
||||
// `ensureWorker` always attaches both listeners alongside the
|
||||
// worker; if the worker reference exists, the listeners do too.
|
||||
const msg = this.#messageListener as (ev: MessageEvent) => void;
|
||||
const err = this.#errorListener as (ev: ErrorEvent) => void;
|
||||
worker.removeEventListener("message", msg);
|
||||
worker.removeEventListener("error", err);
|
||||
this.#messageListener = undefined;
|
||||
this.#errorListener = undefined;
|
||||
try {
|
||||
worker.terminate();
|
||||
} catch (err) {
|
||||
this.#logger?.warn?.(
|
||||
"audio-codec-ffmpeg-wasm.worker.terminate-threw",
|
||||
{ error: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const p of pending) {
|
||||
this.#clearWatchdog(p);
|
||||
const fault =
|
||||
reason ??
|
||||
new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED,
|
||||
"audio-codec-ffmpeg-wasm.worker.terminated"
|
||||
);
|
||||
p.reject(fault);
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridge from the worker's progress event to the user callback. */
|
||||
public onProgress(progress: number): void {
|
||||
if (!this.#progressCallback) return;
|
||||
const clamped = Math.max(0, Math.min(1, progress));
|
||||
try {
|
||||
this.#progressCallback(clamped);
|
||||
} catch (err) {
|
||||
this.#logger?.warn?.(
|
||||
"audio-codec-ffmpeg-wasm.worker.progress-threw",
|
||||
{ error: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #tryDelete(path: string, opts: WorkerCallOptions): Promise<void> {
|
||||
try {
|
||||
await this.#dispatch({ kind: "deleteFile", path }, opts);
|
||||
} catch (err) {
|
||||
this.#logger?.warn?.(
|
||||
"audio-codec-ffmpeg-wasm.worker.delete-failed",
|
||||
{ path, error: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#dispatch(
|
||||
request: Omit<WorkerRequest, "id">,
|
||||
opts: WorkerCallOptions
|
||||
): Promise<unknown> {
|
||||
if (opts.signal?.aborted) {
|
||||
return Promise.reject(
|
||||
new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED,
|
||||
"audio-codec-ffmpeg-wasm.worker.pre-aborted"
|
||||
)
|
||||
);
|
||||
}
|
||||
const worker = this.ensureWorker();
|
||||
const id = this.#nextId++;
|
||||
const startedAt = this.#clock();
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUTS[opts.category];
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const pending: PendingCall = {
|
||||
id,
|
||||
resolve,
|
||||
reject,
|
||||
category: opts.category,
|
||||
startedAt,
|
||||
timer: undefined,
|
||||
abortListener: undefined,
|
||||
signal: opts.signal,
|
||||
stderrCapture: "",
|
||||
};
|
||||
pending.timer = setTimeout(() => {
|
||||
const elapsedMs = this.#clock() - pending.startedAt;
|
||||
const err = new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.TIMEOUT,
|
||||
"audio-codec-ffmpeg-wasm.worker.timeout",
|
||||
{ context: { elapsedMs, timeoutMs } }
|
||||
);
|
||||
this.terminate(err);
|
||||
}, timeoutMs);
|
||||
if (opts.signal) {
|
||||
pending.abortListener = () => {
|
||||
const err = new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.ABORTED,
|
||||
"audio-codec-ffmpeg-wasm.worker.mid-call-abort"
|
||||
);
|
||||
this.terminate(err);
|
||||
};
|
||||
opts.signal.addEventListener("abort", pending.abortListener, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
this.#pending.set(id, pending);
|
||||
const payload: WorkerRequest = { id, ...request };
|
||||
try {
|
||||
worker.postMessage(payload);
|
||||
} catch (err) {
|
||||
this.#pending.delete(id);
|
||||
this.#clearWatchdog(pending);
|
||||
reject(
|
||||
new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.WORKER_SPAWN_FAILED,
|
||||
"audio-codec-ffmpeg-wasm.worker.post-failed",
|
||||
{ cause: err }
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#onMessage(ev: MessageEvent): void {
|
||||
const data = ev.data as
|
||||
| WorkerResponse
|
||||
| { kind: "progress"; progress: number }
|
||||
| { kind: "stderr"; line: string };
|
||||
if (data && typeof data === "object" && "kind" in data) {
|
||||
if (data.kind === "progress") {
|
||||
this.onProgress(data.progress);
|
||||
return;
|
||||
}
|
||||
if (data.kind === "stderr") {
|
||||
this.#absorbStderrLine(data.line);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const response = data as WorkerResponse;
|
||||
const pending = this.#pending.get(response.id);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(response.id);
|
||||
this.#clearWatchdog(pending);
|
||||
if (response.ok) {
|
||||
pending.resolve(response.value);
|
||||
return;
|
||||
}
|
||||
const stderr =
|
||||
typeof response.stderr === "string" ? response.stderr : "";
|
||||
if (MEMORY_LIMIT_REGEX.test(stderr)) {
|
||||
const match = MEMORY_BYTES_REGEX.exec(stderr);
|
||||
const requestedBytes = match
|
||||
? Number.parseInt(match[1]!, 10)
|
||||
: undefined;
|
||||
pending.reject(
|
||||
new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.MEMORY_LIMIT_EXCEEDED,
|
||||
"audio-codec-ffmpeg-wasm.worker.oom",
|
||||
{ context: { stderr, requestedBytes } }
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const code =
|
||||
pending.category === "decode"
|
||||
? AUDIO_CODEC_FFMPEG_WASM_CODES.DECODE_FAILED
|
||||
: AUDIO_CODEC_FFMPEG_WASM_CODES.ENCODE_FAILED;
|
||||
pending.reject(
|
||||
new AudioCodecFfmpegWasmError(
|
||||
code,
|
||||
"audio-codec-ffmpeg-wasm.worker.failed",
|
||||
{
|
||||
context: {
|
||||
stderr,
|
||||
errorMessage: response.errorMessage,
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#absorbStderrLine(line: string): void {
|
||||
if (!MEMORY_LIMIT_REGEX.test(line)) return;
|
||||
const next = this.#pending.values().next();
|
||||
if (next.done) return;
|
||||
const pending = next.value;
|
||||
this.#pending.delete(pending.id);
|
||||
this.#clearWatchdog(pending);
|
||||
const match = MEMORY_BYTES_REGEX.exec(line);
|
||||
const requestedBytes = match
|
||||
? Number.parseInt(match[1]!, 10)
|
||||
: undefined;
|
||||
pending.reject(
|
||||
new AudioCodecFfmpegWasmError(
|
||||
AUDIO_CODEC_FFMPEG_WASM_CODES.MEMORY_LIMIT_EXCEEDED,
|
||||
"audio-codec-ffmpeg-wasm.worker.oom-stderr",
|
||||
{ context: { stderr: line, requestedBytes } }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#onError(ev: ErrorEvent): void {
|
||||
const next = this.#pending.values().next();
|
||||
if (next.done) {
|
||||
this.#logger?.warn?.(
|
||||
"audio-codec-ffmpeg-wasm.worker.unhandled-error",
|
||||
{ message: ev.message }
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pending = next.value;
|
||||
this.#pending.delete(pending.id);
|
||||
this.#clearWatchdog(pending);
|
||||
const code = this.#loadedFlag
|
||||
? pending.category === "decode"
|
||||
? AUDIO_CODEC_FFMPEG_WASM_CODES.DECODE_FAILED
|
||||
: AUDIO_CODEC_FFMPEG_WASM_CODES.ENCODE_FAILED
|
||||
: AUDIO_CODEC_FFMPEG_WASM_CODES.WORKER_SPAWN_FAILED;
|
||||
pending.reject(
|
||||
new AudioCodecFfmpegWasmError(
|
||||
code,
|
||||
"audio-codec-ffmpeg-wasm.worker.error-event",
|
||||
{
|
||||
context: { message: ev.message },
|
||||
cause: ev.error,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#clearWatchdog(pending: PendingCall): void {
|
||||
if (pending.timer !== undefined) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.timer = undefined;
|
||||
}
|
||||
if (pending.signal && pending.abortListener) {
|
||||
pending.signal.removeEventListener("abort", pending.abortListener);
|
||||
pending.abortListener = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Minimal interface the worker host needs from a worker handle. Both the real
|
||||
* DOM `Worker` and the test-supplied shim conform to this. Methods follow
|
||||
* the WHATWG/MDN signatures exactly so a real `Worker` is assignable.
|
||||
*/
|
||||
export interface WorkerLike {
|
||||
postMessage(message: unknown): void;
|
||||
addEventListener(
|
||||
type: "message",
|
||||
listener: (ev: MessageEvent) => void
|
||||
): void;
|
||||
addEventListener(type: "error", listener: (ev: ErrorEvent) => void): void;
|
||||
removeEventListener(
|
||||
type: "message",
|
||||
listener: (ev: MessageEvent) => void
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "error",
|
||||
listener: (ev: ErrorEvent) => void
|
||||
): void;
|
||||
terminate(): void;
|
||||
}
|
||||
|
||||
/** Structured response shape posted back from the worker. */
|
||||
export interface WorkerResponse {
|
||||
readonly id: number;
|
||||
readonly ok: boolean;
|
||||
readonly value?: unknown;
|
||||
readonly stderr?: string;
|
||||
readonly errorMessage?: string;
|
||||
}
|
||||
|
||||
/** Structured request shape posted to the worker. */
|
||||
export interface WorkerRequest {
|
||||
readonly id: number;
|
||||
readonly kind: "load" | "run" | "writeFile" | "readFile" | "deleteFile";
|
||||
readonly args?: readonly string[];
|
||||
readonly path?: string;
|
||||
readonly bytes?: Uint8Array;
|
||||
}
|
||||
|
||||
/** Construction options for the worker host. */
|
||||
export interface WorkerHostOptions {
|
||||
readonly workerFactory: () => WorkerLike;
|
||||
readonly clock: Clock;
|
||||
readonly logger?: LoggerLike | undefined;
|
||||
readonly progressCallback?: ((progress: number) => void) | undefined;
|
||||
}
|
||||
|
||||
/** Per-call options. */
|
||||
export interface WorkerCallOptions {
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly timeoutMs?: number | undefined;
|
||||
readonly category: "decode" | "encode";
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of `submitFfmpeg`: stderr captured during the run plus the raw output
|
||||
* bytes read from MEMFS. The pipeline modules interpret these.
|
||||
*/
|
||||
export interface FfmpegRunResult {
|
||||
readonly outputBytes: Uint8Array;
|
||||
readonly stderr: string;
|
||||
}
|
||||
Reference in New Issue
Block a user