mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 04:46:59 +00:00
* fix(cli): non-English transcription reaches whisper, and whisper failures say why transcribe --language <non-English> runs an installed whisper (Parakeet picks its own language); whisper models are pinned by size and sha256 and re-downloaded when damaged; a whisper-cli failure names its build, model file and last stderr lines without paths; the unused --dtw flag is dropped. * docs(cli): --engine and --language say a non-English language runs an installed whisper Also strips paths that follow a colon or a bracket from whisper-cli's error tail.
484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import * as pipeline from "./background-removal/pipeline.js";
|
|
import * as manager from "./whisper/manager.js";
|
|
import * as sherpa from "./whisper/sherpa.js";
|
|
import * as whisper from "./whisper/transcribe.js";
|
|
import {
|
|
RemoveBackgroundError,
|
|
TranscribeError,
|
|
removeBackground,
|
|
transcribe,
|
|
type TranscribeOptions,
|
|
} from "./api.js";
|
|
|
|
const SRT = `1
|
|
00:00:01,000 --> 00:00:03,500
|
|
Write HTML.
|
|
|
|
2
|
|
00:00:03,500 --> 00:00:06,000
|
|
Render video. Built for agents.
|
|
`;
|
|
|
|
let dir: string;
|
|
let transcribeMock: MockInstance<typeof whisper.transcribe>;
|
|
let renderMock: MockInstance<typeof pipeline.render>;
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), "hf-api-test-"));
|
|
transcribeMock = vi.spyOn(whisper, "transcribe");
|
|
renderMock = vi.spyOn(pipeline, "render");
|
|
// Parakeet runs on parakeet-mlx in these tests, whatever this machine has installed.
|
|
vi.spyOn(sherpa, "sherpaParakeetInstalled").mockReturnValue(false);
|
|
vi.spyOn(console, "log");
|
|
vi.spyOn(console, "error");
|
|
});
|
|
afterEach(() => {
|
|
// The API never prints: that is the CLI's job.
|
|
expect(console.log).not.toHaveBeenCalled();
|
|
expect(console.error).not.toHaveBeenCalled();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
function dummyAudio(): string {
|
|
const input = join(dir, "narration.wav");
|
|
writeFileSync(input, "not-real-audio");
|
|
return input;
|
|
}
|
|
|
|
/** What a whisper run leaves behind: a one-word transcript in the output folder. */
|
|
function whisperResult(outputDir: string) {
|
|
const transcriptPath = join(outputDir, "transcript.json");
|
|
writeFileSync(transcriptPath, JSON.stringify([{ text: "hola", start: 0, end: 1 }]));
|
|
return {
|
|
model: "small.en",
|
|
detectedLanguage: "es",
|
|
transcriptPath,
|
|
wordCount: 1,
|
|
durationSeconds: 2,
|
|
speechOnsetSeconds: null,
|
|
};
|
|
}
|
|
|
|
async function transcribeError(options: Parameters<typeof transcribe>[0]) {
|
|
const err = await transcribe(options).catch((e: unknown) => e);
|
|
if (!(err instanceof TranscribeError)) throw new Error(`expected a TranscribeError, got ${err}`);
|
|
return err;
|
|
}
|
|
|
|
describe("transcribe", () => {
|
|
it("imports a transcript file into transcript.json", async () => {
|
|
const input = join(dir, "sample.srt");
|
|
writeFileSync(input, SRT);
|
|
|
|
const result = await transcribe({ input });
|
|
|
|
const transcriptPath = join(dir, "transcript.json");
|
|
expect(result).toEqual({ kind: "imported", format: "srt", wordCount: 2, transcriptPath });
|
|
expect(JSON.parse(readFileSync(transcriptPath, "utf8"))).toHaveLength(2);
|
|
});
|
|
|
|
it("writes only the caption file for a transcript input with captions", async () => {
|
|
const input = join(dir, "words.json");
|
|
writeFileSync(
|
|
input,
|
|
JSON.stringify([
|
|
{ text: "Yes", start: 0, end: 1 },
|
|
{ text: "No", start: 1, end: 2 },
|
|
]),
|
|
);
|
|
|
|
const result = await transcribe({ input, captions: { format: "srt", preserveCues: true } });
|
|
|
|
const outputPath = join(dir, "transcript.srt");
|
|
expect(result).toEqual({ kind: "exported", format: "srt", wordCount: 2, outputPath });
|
|
expect(readFileSync(outputPath, "utf8")).toBe(
|
|
"1\n00:00:00,000 --> 00:00:01,000\nYes\n\n2\n00:00:01,000 --> 00:00:02,000\nNo\n",
|
|
);
|
|
expect(existsSync(join(dir, "transcript.json"))).toBe(false);
|
|
});
|
|
|
|
it("transcribes with whisper and hands back the runner, status and progress", async () => {
|
|
const input = dummyAudio();
|
|
transcribeMock.mockImplementation(async (_input, outputDir, options) => {
|
|
options?.onProgress?.("Checking model...");
|
|
options?.onEvent?.({
|
|
type: "progress",
|
|
phase: "transcription",
|
|
model: "small.en",
|
|
status: "started",
|
|
});
|
|
return whisperResult(outputDir);
|
|
});
|
|
const onEngine = vi.fn();
|
|
const onStatus = vi.fn();
|
|
const onProgress = vi.fn();
|
|
|
|
const result = await transcribe({
|
|
input,
|
|
engine: "whisper",
|
|
model: "small.en",
|
|
onEngine,
|
|
onStatus,
|
|
onProgress,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
kind: "transcribed",
|
|
engine: "whisper",
|
|
model: "small.en",
|
|
detectedLanguage: "es",
|
|
wordCount: 1,
|
|
durationSeconds: 2,
|
|
speechOnsetSeconds: null,
|
|
transcriptPath: join(dir, "transcript.json"),
|
|
});
|
|
expect(onEngine).toHaveBeenCalledExactlyOnceWith({
|
|
runner: "whisper",
|
|
engine: "whisper",
|
|
model: "small.en",
|
|
});
|
|
expect(onStatus).toHaveBeenCalledWith("Checking model...");
|
|
expect(onProgress).toHaveBeenCalledWith({
|
|
type: "progress",
|
|
phase: "transcription",
|
|
model: "small.en",
|
|
status: "started",
|
|
});
|
|
});
|
|
|
|
it("asks whisper for progress only when the caller listens for it", async () => {
|
|
// Whisper adds --print-progress whenever it gets an onEvent, so an unused one is not free.
|
|
const input = dummyAudio();
|
|
transcribeMock.mockImplementation(async (_input, outputDir) => whisperResult(outputDir));
|
|
|
|
await transcribe({ input, engine: "whisper" });
|
|
|
|
expect(transcribeMock.mock.calls[0]?.[2]).toMatchObject({
|
|
onEvent: undefined,
|
|
onProgress: undefined,
|
|
});
|
|
});
|
|
|
|
it("stops on the caller's AbortSignal without installing process signal handlers", async () => {
|
|
const input = dummyAudio();
|
|
const controller = new AbortController();
|
|
let handlersAdded = -1;
|
|
let signal: unknown;
|
|
transcribeMock.mockImplementation(async (_input, _outputDir, options) => {
|
|
const before = process.listenerCount("SIGINT");
|
|
signal = options?.startCancellation?.();
|
|
handlersAdded = process.listenerCount("SIGINT") - before;
|
|
controller.abort();
|
|
throw new Error("Command failed: whisper-cli");
|
|
});
|
|
|
|
const err = await transcribeError({ input, engine: "whisper", signal: controller.signal });
|
|
|
|
expect(err.code).toBe("cancelled");
|
|
expect(signal).toBe(controller.signal);
|
|
expect(handlersAdded).toBe(0);
|
|
});
|
|
|
|
it("names a missing whisper as whisper_unavailable, with the original error as cause", async () => {
|
|
const input = dummyAudio();
|
|
const missing = new manager.WhisperUnavailableError("whisper-cpp not found");
|
|
transcribeMock.mockRejectedValue(missing);
|
|
|
|
const err = await transcribeError({ input, engine: "whisper" });
|
|
|
|
expect(err.code).toBe("whisper_unavailable");
|
|
expect(err.cause).toBe(missing);
|
|
expect(err.install).toBeUndefined();
|
|
});
|
|
|
|
it("adds the ASR program's last stderr lines to a failure", async () => {
|
|
const input = dummyAudio();
|
|
const crash = Object.assign(new Error("Command failed: whisper-cli"), {
|
|
stderr: "loading model\nout of memory\n",
|
|
});
|
|
transcribeMock.mockRejectedValue(crash);
|
|
|
|
const err = await transcribeError({ input, engine: "whisper" });
|
|
|
|
expect(err.code).toBe("failed");
|
|
expect(err.message).toBe("Command failed: whisper-cli\nloading model\nout of memory");
|
|
expect(err.cause).toBe(crash);
|
|
});
|
|
|
|
it("reports the model whisper will run, not the English-only default", async () => {
|
|
const input = dummyAudio();
|
|
transcribeMock.mockImplementation(async (_input, outputDir) => whisperResult(outputDir));
|
|
const onEngine = vi.fn();
|
|
|
|
await transcribe({ input, engine: "whisper", language: "es", onEngine });
|
|
|
|
expect(onEngine).toHaveBeenCalledWith(expect.objectContaining({ model: "small" }));
|
|
});
|
|
|
|
it("throws at once for a signal that is already aborted, before any setup", async () => {
|
|
const input = dummyAudio();
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
|
|
const err = await transcribeError({ input, engine: "whisper", signal: controller.signal });
|
|
|
|
expect(err.code).toBe("cancelled");
|
|
expect(transcribeMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"stops a running parakeet-mlx when the signal aborts",
|
|
async () => {
|
|
const pidFile = join(dir, "parakeet.pid");
|
|
const runner = join(dir, "parakeet-mlx");
|
|
writeFileSync(
|
|
runner,
|
|
`#!/bin/sh\n[ "$1" = --help ] && exit 0\necho $$ > "${pidFile}"\nexec sleep 30\n`,
|
|
);
|
|
chmodSync(runner, 0o755);
|
|
vi.stubEnv("HYPERFRAMES_PARAKEET", runner);
|
|
const controller = new AbortController();
|
|
const started = Date.now();
|
|
// Abort once the child is really running; a blocked event loop never gets this far.
|
|
const poll = setInterval(() => {
|
|
if (existsSync(pidFile) && readFileSync(pidFile, "utf8").trim()) controller.abort();
|
|
}, 20);
|
|
|
|
const err = await transcribeError({
|
|
input: dummyAudio(),
|
|
engine: "parakeet",
|
|
signal: controller.signal,
|
|
}).finally(() => clearInterval(poll));
|
|
|
|
expect(err.code).toBe("cancelled");
|
|
expect(Date.now() - started).toBeLessThan(10_000);
|
|
const pid = Number(readFileSync(pidFile, "utf8"));
|
|
expect(() => process.kill(pid, 0)).toThrow();
|
|
},
|
|
);
|
|
|
|
/** A stand-in tool that starts a process of its own, writes both pids, then waits for it. */
|
|
function hangingTool(path: string, pidFile: string, before = ""): void {
|
|
writeFileSync(path, `#!/bin/sh\n${before}sleep 15 &\necho "$$ $!" > "${pidFile}"\nwait\n`);
|
|
chmodSync(path, 0o755);
|
|
}
|
|
|
|
/** Aborts once the stand-in is running; a blocked event loop never gets to. */
|
|
async function abortWhileRunning(pidFile: string, options: Omit<TranscribeOptions, "signal">) {
|
|
const controller = new AbortController();
|
|
const poll = setInterval(() => {
|
|
if (existsSync(pidFile) && readFileSync(pidFile, "utf8").includes(" ")) controller.abort();
|
|
}, 20);
|
|
const started = Date.now();
|
|
const err = await transcribeError({ ...options, signal: controller.signal }).finally(() =>
|
|
clearInterval(poll),
|
|
);
|
|
const pids = readFileSync(pidFile, "utf8").trim().split(" ").map(Number);
|
|
return { err, elapsed: Date.now() - started, pids };
|
|
}
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"stops a whisper runtime install in progress when the signal aborts",
|
|
async (ctx) => {
|
|
const bin = join(dir, "bin");
|
|
mkdirSync(bin);
|
|
const pidFile = join(dir, "install.pid");
|
|
// Homebrew on macOS, git clone elsewhere: whichever install step runs first never ends.
|
|
hangingTool(join(bin, "brew"), pidFile);
|
|
hangingTool(join(bin, "git"), pidFile);
|
|
writeFileSync(join(bin, "cmake"), "#!/bin/sh\nexit 1\n");
|
|
chmodSync(join(bin, "cmake"), 0o755);
|
|
vi.stubEnv("PATH", `${bin}:/usr/bin:/bin`);
|
|
vi.stubEnv("HYPERFRAMES_WHISPER_PATH", "");
|
|
if (manager.findWhisper()) ctx.skip();
|
|
|
|
const { err, elapsed, pids } = await abortWhileRunning(pidFile, {
|
|
input: dummyAudio(),
|
|
engine: "whisper",
|
|
});
|
|
|
|
expect(err.code).toBe("cancelled");
|
|
expect(elapsed).toBeLessThan(10_000);
|
|
for (const pid of pids) expect(() => process.kill(pid, 0), `pid ${pid}`).toThrow();
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"stops ffmpeg preparing the audio when the signal aborts, and removes its partial WAV",
|
|
async () => {
|
|
vi.spyOn(manager, "ensureWhisper").mockResolvedValue({
|
|
executablePath: "/no/whisper-cli",
|
|
source: "system",
|
|
});
|
|
vi.spyOn(manager, "ensureModel").mockResolvedValue(join(dir, "ggml-small.en.bin"));
|
|
const ffmpeg = join(dir, "ffmpeg");
|
|
const pidFile = join(dir, "ffmpeg.pid");
|
|
hangingTool(ffmpeg, pidFile, 'for out; do :; done\nprintf partial > "$out"\n');
|
|
vi.stubEnv("HYPERFRAMES_FFMPEG_PATH", ffmpeg);
|
|
vi.stubEnv("HYPERFRAMES_FFPROBE_PATH", join(dir, "no-ffprobe"));
|
|
const input = join(dir, "narration.mp3");
|
|
writeFileSync(input, "not-real-audio");
|
|
const wavs = () =>
|
|
readdirSync(tmpdir()).filter((f) => f.startsWith(`hyperframes-audio-${process.pid}-`));
|
|
|
|
const { err, elapsed, pids } = await abortWhileRunning(pidFile, { input, engine: "whisper" });
|
|
|
|
expect(err.code).toBe("cancelled");
|
|
expect(elapsed).toBeLessThan(10_000);
|
|
for (const pid of pids) expect(() => process.kill(pid, 0), `pid ${pid}`).toThrow();
|
|
expect(wavs()).toEqual([]);
|
|
},
|
|
);
|
|
|
|
// Spelled so that this file's own source, which it also reads, does not match.
|
|
const localLoad = /\bimport\(|\bimport[OAM](?:riginal|ctual|ock)\b/;
|
|
|
|
it("keeps every module load at module scope, in the API, the code it runs and their tests", () => {
|
|
for (const sample of [
|
|
"await import" + "('x')",
|
|
"vi.import" + "Actual('x')",
|
|
"import" + "Original()",
|
|
]) {
|
|
expect(sample).toMatch(localLoad);
|
|
}
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const api = readdirSync(join(here, "api")).map((f) => join("api", f));
|
|
const files = [
|
|
"api.ts",
|
|
...api,
|
|
"api.test.ts",
|
|
"commands/remove-background.ts",
|
|
"commands/transcribe.ts",
|
|
"commands/transcribe.test.ts",
|
|
"utils/download.ts",
|
|
"utils/download.test.ts",
|
|
"utils/stoppableExecFile.ts",
|
|
"utils/stoppableExecFile.test.ts",
|
|
"whisper/manager.ts",
|
|
"whisper/modelFile.ts",
|
|
"whisper/modelForLanguage.ts",
|
|
"whisper/parakeet.ts",
|
|
"whisper/sherpa.ts",
|
|
"whisper/transcribe.ts",
|
|
"whisper/transcribe.test.ts",
|
|
"whisper/transcribe.signal.test.ts",
|
|
];
|
|
for (const file of files) {
|
|
expect(readFileSync(join(here, file), "utf8"), file).not.toMatch(localLoad);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["a missing input", () => ({ input: join(dir, "missing.wav") }), "input_not_found"],
|
|
[
|
|
"a transcript with no words",
|
|
() => {
|
|
writeFileSync(join(dir, "empty.json"), "[]");
|
|
return { input: join(dir, "empty.json") };
|
|
},
|
|
"no_words",
|
|
],
|
|
[
|
|
"a timeout below the minimum",
|
|
() => ({ input: dummyAudio(), timeoutMs: 100 }),
|
|
"invalid_timeout",
|
|
],
|
|
])("rejects %s before any engine runs", async (_case, options, code) => {
|
|
const err = await transcribeError(options());
|
|
expect(err.code).toBe(code);
|
|
expect(transcribeMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an unknown caption format from an untyped caller", async () => {
|
|
// @ts-expect-error -- plain JavaScript callers get no compile-time check.
|
|
const err = await transcribeError({ input: dummyAudio(), captions: { format: "txt" } });
|
|
expect(err).toMatchObject({
|
|
code: "invalid_option",
|
|
message: "Unsupported caption export format: txt. Use srt or vtt.",
|
|
});
|
|
expect(transcribeMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("removeBackground", () => {
|
|
it("hands the pipeline absolute paths and returns its result", async () => {
|
|
const rendered = {
|
|
outputPath: resolve("cutout.webm"),
|
|
framesProcessed: 3,
|
|
durationSeconds: 1,
|
|
avgMsPerFrame: 10,
|
|
provider: "cpu",
|
|
format: "webm" as const,
|
|
};
|
|
renderMock.mockResolvedValue(rendered);
|
|
const onProgress = vi.fn();
|
|
|
|
const result = await removeBackground({
|
|
input: "avatar.mp4",
|
|
output: "cutout.webm",
|
|
device: "cpu",
|
|
quality: "fast",
|
|
onProgress,
|
|
});
|
|
|
|
expect(result).toBe(rendered);
|
|
expect(renderMock).toHaveBeenCalledExactlyOnceWith({
|
|
inputPath: resolve("avatar.mp4"),
|
|
outputPath: resolve("cutout.webm"),
|
|
backgroundOutputPath: undefined,
|
|
device: "cpu",
|
|
quality: "fast",
|
|
onProgress,
|
|
});
|
|
});
|
|
|
|
async function rejected(options: Parameters<typeof removeBackground>[0]) {
|
|
const err = await removeBackground(options).catch((e: unknown) => e);
|
|
expect(renderMock).not.toHaveBeenCalled();
|
|
return err;
|
|
}
|
|
|
|
it("rejects an unknown device or quality from an untyped caller", async () => {
|
|
const paths = { input: "avatar.mp4", output: "cutout.webm" };
|
|
// @ts-expect-error -- plain JavaScript callers get no compile-time check.
|
|
expect(await rejected({ ...paths, device: "gpu" })).toMatchObject({
|
|
name: "RemoveBackgroundError",
|
|
code: "invalid_option",
|
|
message: "Invalid device 'gpu'. Use: auto, cpu, coreml, cuda.",
|
|
});
|
|
// @ts-expect-error -- plain JavaScript callers get no compile-time check.
|
|
expect(await rejected({ ...paths, quality: "max" })).toMatchObject({
|
|
name: "RemoveBackgroundError",
|
|
code: "invalid_option",
|
|
message: "Invalid quality 'max'. Use: fast, balanced, best.",
|
|
});
|
|
});
|
|
|
|
it("wraps a pipeline failure, keeping its message and the original as cause", async () => {
|
|
const failure = new Error("ffmpeg and ffprobe are required.");
|
|
renderMock.mockRejectedValue(failure);
|
|
|
|
const err = await removeBackground({ input: "avatar.mp4", output: "cutout.webm" }).catch(
|
|
(e: unknown) => e,
|
|
);
|
|
|
|
expect(err).toBeInstanceOf(RemoveBackgroundError);
|
|
expect(err).toMatchObject({ code: "failed", message: failure.message, cause: failure });
|
|
});
|
|
});
|