mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(cli): configurable transcribe timeout with duration-scaled default
Adds a `--timeout <ms>` CLI flag (and `HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS` env var) plus a model-slowdown factor in the auto-scaled default so `hyperframes transcribe` doesn't hard-fail with `spawnSync ETIMEDOUT` on slow CPUs running heavier whisper models. Field-signal ts=1784165471 (win32/arm64 emulating x64 on Snapdragon, CLI 0.7.59) reported the failure on a 63s wav with `-m medium` at ~13x realtime — the historical 10x-realtime scale (PR #2463) gave 10.5 min while the machine needed ~13.7 min. Splitting audio and merging offsets was the manual workaround. - Add `--timeout <ms>` and `HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS` (min 5000). Explicit override bypasses auto-scaling; still capped at 12h. - Add per-model slowdown factor (tiny 0.5, base 0.7, small 1, medium 2, large 4, large-v3-turbo 2). Multiplied into the 10s/audio-second baseline so medium/large get proportional headroom while `small.en` (the default) preserves the historical safety window. - Wrap whisper's spawn error with a discoverability hint naming `--timeout`, the env var, and the effective timeout when the child was killed by SIGTERM/ETIMEDOUT (mirrors PR #2504 protocol-timeout). - Docs: new `--timeout` row in `docs/packages/cli.mdx` Flags table. Regression coverage in `packages/cli/src/whisper/transcribe.test.ts` (56 tests) and `packages/cli/src/commands/transcribe.test.ts` (5 tests): - Model factor per known name + case-insensitive + safe unknown fallback. - 63s field-signal case on medium.en → 1_260_000ms (was 630_000ms). - Explicit override honored below the auto floor + capped at 12h. - Model factor ignored when overrideMs is set. - SIGTERM/ETIMEDOUT detection + augmented message contract. - CLI rejects below-minimum `--timeout` with error naming both the flag and the 5000ms floor. — Via Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -289,6 +289,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
|
|||||||
| `--to` | Export transcript sidecar format: `srt` or `vtt` |
|
| `--to` | Export transcript sidecar format: `srt` or `vtt` |
|
||||||
| `--output, -o` | Output path for exported SRT/VTT sidecar |
|
| `--output, -o` | Output path for exported SRT/VTT sidecar |
|
||||||
| `--preserve-cues` | Keep each transcript entry as its own caption cue (skip word-level grouping) |
|
| `--preserve-cues` | Keep each transcript entry as its own caption cue (skip word-level grouping) |
|
||||||
|
| `--timeout` | Whisper spawn timeout in ms. Overrides the duration+model auto-scaled default. Increase on slow CPUs (e.g. emulated arm64/x64) where medium/large models take many seconds per audio second. Minimum 5000. Env: `HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS`. |
|
||||||
| `--json` | Output result as JSON |
|
| `--json` | Output result as JSON |
|
||||||
|
|
||||||
The command auto-detects the input type. Audio/video files are transcribed with whisper.cpp. Transcript files (`.json`, `.srt`, `.vtt`) are normalized and imported. Pass `--to srt` or `--to vtt` with a transcript input to write a caption sidecar instead.
|
The command auto-detects the input type. Audio/video files are transcribed with whisper.cpp. Transcript files (`.json`, `.srt`, `.vtt`) are normalized and imported. Pass `--to srt` or `--to vtt` with a transcript input to write a caption sidecar instead.
|
||||||
|
|||||||
@@ -99,6 +99,32 @@ Render video. Built for agents.
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a below-minimum --timeout with a discoverable error", async () => {
|
||||||
|
const { dir, input } = dummyAudio();
|
||||||
|
dirs.push(dir);
|
||||||
|
const consoleLog = vi.mocked(console.log);
|
||||||
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||||
|
throw new Error(`__exit_${code}__`);
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
// 100 is well below the 5000ms minimum — must fail loud instead of silently
|
||||||
|
// reverting to the auto-scaled default (the whole point of the flag is
|
||||||
|
// that the user explicitly asked for a specific value).
|
||||||
|
await expect(
|
||||||
|
transcribeCmd.run!({ args: { input, json: true, timeout: "100" } } as never),
|
||||||
|
).rejects.toThrow("__exit_1__");
|
||||||
|
|
||||||
|
const log = consoleLog.mock.calls.at(-1)?.[0];
|
||||||
|
expect(typeof log).toBe("string");
|
||||||
|
if (typeof log !== "string") throw new Error("Expected JSON log output");
|
||||||
|
const parsed = JSON.parse(log);
|
||||||
|
expect(parsed.ok).toBe(false);
|
||||||
|
expect(parsed.error).toContain("--timeout");
|
||||||
|
expect(parsed.error).toContain("5000");
|
||||||
|
|
||||||
|
exitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("--preserve-cues keeps single-word cues separate when exporting from JSON", async () => {
|
it("--preserve-cues keeps single-word cues separate when exporting from JSON", async () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "hf-transcribe-test-"));
|
const dir = mkdtempSync(join(tmpdir(), "hf-transcribe-test-"));
|
||||||
dirs.push(dir);
|
dirs.push(dir);
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ import { resolve, join, extname, dirname } from "node:path";
|
|||||||
import * as clack from "@clack/prompts";
|
import * as clack from "@clack/prompts";
|
||||||
import { c } from "../ui/colors.js";
|
import { c } from "../ui/colors.js";
|
||||||
import { DEFAULT_MODEL, isWhisperUnavailable } from "../whisper/manager.js";
|
import { DEFAULT_MODEL, isWhisperUnavailable } from "../whisper/manager.js";
|
||||||
|
|
||||||
|
// Minimum accepted value for `--timeout` / `HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS`.
|
||||||
|
// Kept out of `whisper/transcribe.ts` (avoids a top-level import into this
|
||||||
|
// command module) so the CLI test file can hoist its
|
||||||
|
// `vi.mock("../whisper/transcribe.js")` factory without the mocked module
|
||||||
|
// entering the sync-import graph. Below this floor the whisper spawn has no
|
||||||
|
// realistic chance of completing even on the fastest hardware for the shortest clip.
|
||||||
|
const CLI_TIMEOUT_MIN_MS = 5000;
|
||||||
import { trackCommandFailure, trackTranscribeUnavailable } from "../telemetry/events.js";
|
import { trackCommandFailure, trackTranscribeUnavailable } from "../telemetry/events.js";
|
||||||
|
|
||||||
export default defineCommand({
|
export default defineCommand({
|
||||||
@@ -85,6 +93,16 @@ export default defineCommand({
|
|||||||
"Treat captions as optional: if whisper-cpp is unavailable, skip and exit 0 instead of failing. For pipelines that continue without captions.",
|
"Treat captions as optional: if whisper-cpp is unavailable, skip and exit 0 instead of failing. For pipelines that continue without captions.",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
timeout: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Whisper spawn timeout in ms. Overrides the duration+model auto-scaled " +
|
||||||
|
"default. Increase on slow CPUs (e.g. emulated arm64/x64, low-power " +
|
||||||
|
"laptops) where whisper.cpp takes many seconds per audio second on " +
|
||||||
|
"medium/large models. Applies to the whisper engine only; Parakeet has " +
|
||||||
|
"a separate fixed timeout. Minimum 5000 (5 s). " +
|
||||||
|
"Env: HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
const inputPath = resolve(args.input);
|
const inputPath = resolve(args.input);
|
||||||
@@ -120,16 +138,42 @@ export default defineCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Transcribe mode: run the ASR engine ──────────────────────────────
|
// ── Transcribe mode: run the ASR engine ──────────────────────────────
|
||||||
|
const timeoutMs = parseTimeoutMs(args.timeout, args.json);
|
||||||
|
|
||||||
return transcribeAudio(inputPath, dir, {
|
return transcribeAudio(inputPath, dir, {
|
||||||
engine: args.engine,
|
engine: args.engine,
|
||||||
model: args.model,
|
model: args.model,
|
||||||
language: args.language,
|
language: args.language,
|
||||||
json: args.json,
|
json: args.json,
|
||||||
optional: args.optional,
|
optional: args.optional,
|
||||||
|
timeoutMs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the whisper timeout override from `--timeout <ms>` or the
|
||||||
|
* `HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS` env var. The flag wins over env; both
|
||||||
|
* paths share the same integer + minimum-5000ms validation so a bad value
|
||||||
|
* fails loud instead of silently reverting to the auto-scaled default.
|
||||||
|
* Returns `undefined` when neither source is set — the transcribe layer then
|
||||||
|
* derives the timeout from audio duration and model factor.
|
||||||
|
*/
|
||||||
|
function parseTimeoutMs(raw: string | undefined, json: boolean): number | undefined {
|
||||||
|
const source = raw ?? process.env["HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS"];
|
||||||
|
if (source == null || source === "") return undefined;
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(source, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < CLI_TIMEOUT_MIN_MS) {
|
||||||
|
const origin = raw != null ? "--timeout" : "HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS";
|
||||||
|
failWith(
|
||||||
|
`Invalid ${origin}: "${source}". Must be an integer >= ${CLI_TIMEOUT_MIN_MS} (ms).`,
|
||||||
|
json,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function failWith(message: string, json: boolean): never {
|
function failWith(message: string, json: boolean): never {
|
||||||
trackCommandFailure("transcribe", message);
|
trackCommandFailure("transcribe", message);
|
||||||
if (json) {
|
if (json) {
|
||||||
@@ -226,7 +270,14 @@ async function exportTranscript(
|
|||||||
async function transcribeAudio(
|
async function transcribeAudio(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
dir: string,
|
dir: string,
|
||||||
opts: { engine?: string; model?: string; language?: string; json?: boolean; optional?: boolean },
|
opts: {
|
||||||
|
engine?: string;
|
||||||
|
model?: string;
|
||||||
|
language?: string;
|
||||||
|
json?: boolean;
|
||||||
|
optional?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { transcribe } = await import("../whisper/transcribe.js");
|
const { transcribe } = await import("../whisper/transcribe.js");
|
||||||
const { loadTranscript, patchCaptionHtml, stripBeforeOnset } =
|
const { loadTranscript, patchCaptionHtml, stripBeforeOnset } =
|
||||||
@@ -260,6 +311,7 @@ async function transcribeAudio(
|
|||||||
model,
|
model,
|
||||||
language: opts.language,
|
language: opts.language,
|
||||||
onProgress: spin ? (msg) => spin.message(msg) : undefined,
|
onProgress: spin ? (msg) => spin.message(msg) : undefined,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
let { words } = loadTranscript(result.transcriptPath);
|
let { words } = loadTranscript(result.transcriptPath);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, expect, it, test } from "vitest";
|
import { describe, expect, it, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
dtwPresetForModel,
|
dtwPresetForModel,
|
||||||
|
isWhisperTimeoutError,
|
||||||
resolveAudioPreparationTimeoutMs,
|
resolveAudioPreparationTimeoutMs,
|
||||||
resolveWhisperTimeoutMs,
|
resolveWhisperTimeoutMs,
|
||||||
|
whisperModelSlowdownFactor,
|
||||||
|
wrapWhisperTimeoutError,
|
||||||
} from "./transcribe.js";
|
} from "./transcribe.js";
|
||||||
|
|
||||||
describe("dtwPresetForModel", () => {
|
describe("dtwPresetForModel", () => {
|
||||||
@@ -55,6 +58,179 @@ describe("resolveWhisperTimeoutMs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("whisperModelSlowdownFactor", () => {
|
||||||
|
it.each([
|
||||||
|
["tiny", 0.5],
|
||||||
|
["tiny.en", 0.5],
|
||||||
|
["base", 0.7],
|
||||||
|
["base.en", 0.7],
|
||||||
|
["small", 1],
|
||||||
|
["small.en", 1],
|
||||||
|
["medium", 2],
|
||||||
|
["medium.en", 2],
|
||||||
|
["large-v1", 4],
|
||||||
|
["large-v2", 4],
|
||||||
|
["large-v3", 4],
|
||||||
|
["large-v3-turbo", 2],
|
||||||
|
])("returns factor %s for known model %s", (model, expected) => {
|
||||||
|
expect(whisperModelSlowdownFactor(model)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
expect(whisperModelSlowdownFactor("MEDIUM.EN")).toBe(2);
|
||||||
|
expect(whisperModelSlowdownFactor("Large-V3")).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the small.en baseline (1) for unknown model names", () => {
|
||||||
|
// Unknown model names must never shorten the safety window, so the default
|
||||||
|
// is the baseline factor rather than a smaller (tiny/base) value.
|
||||||
|
expect(whisperModelSlowdownFactor("my-custom-finetune")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveWhisperTimeoutMs with model factor", () => {
|
||||||
|
it("keeps small.en (default) at the historical baseline for 63s clips", () => {
|
||||||
|
// Regression guard: `small.en` (the CLI default model) must not lose
|
||||||
|
// headroom when the caller passes a model. 63s * 10s/s * 1 = 630_000ms.
|
||||||
|
expect(resolveWhisperTimeoutMs(63, { model: "small.en" })).toBe(630_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doubles the auto-scaled window for medium.en (field-signal ts=1784165471)", () => {
|
||||||
|
// 63s clip on medium.en: emulated arm64/x64 saw ~13x realtime — 63*13 =
|
||||||
|
// 819s needed. The 10x baseline (630_000ms = 10.5min) was insufficient;
|
||||||
|
// the 2x medium factor lifts it to 1_260_000ms (21min), covering the
|
||||||
|
// reported case with margin.
|
||||||
|
expect(resolveWhisperTimeoutMs(63, { model: "medium.en" })).toBe(1_260_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quadruples the auto-scaled window for the large family", () => {
|
||||||
|
expect(resolveWhisperTimeoutMs(60, { model: "large-v3" })).toBe(2_400_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still enforces the five-minute floor on tiny/base short clips", () => {
|
||||||
|
// 10s * 10 * 0.5 = 50_000ms — below the floor, so the floor wins.
|
||||||
|
expect(resolveWhisperTimeoutMs(10, { model: "tiny.en" })).toBe(300_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still enforces the twelve-hour cap on large-model marathon clips", () => {
|
||||||
|
// 4320s * 10 * 4 = 172_800_000ms — the cap wins.
|
||||||
|
expect(resolveWhisperTimeoutMs(4320, { model: "large-v3" })).toBe(43_200_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scales the null-duration fallback by the model factor", () => {
|
||||||
|
// When ffprobe can't read the WAV, we lose duration signal but still know
|
||||||
|
// the model. Scaling the floor keeps heavy models proportionally covered.
|
||||||
|
expect(resolveWhisperTimeoutMs(null, { model: "medium.en" })).toBe(600_000);
|
||||||
|
expect(resolveWhisperTimeoutMs(null, { model: "large-v3" })).toBe(1_200_000);
|
||||||
|
expect(resolveWhisperTimeoutMs(null, { model: "small.en" })).toBe(300_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveWhisperTimeoutMs with overrideMs", () => {
|
||||||
|
it("respects the explicit override even below the auto-scaled floor", () => {
|
||||||
|
// A user who deliberately passed --timeout 30000 on a short clip meant 30s,
|
||||||
|
// not five minutes. The auto-scaled floor must NOT stomp on the explicit
|
||||||
|
// value — that would silently defeat the flag.
|
||||||
|
expect(resolveWhisperTimeoutMs(60, { overrideMs: 30_000 })).toBe(30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the override at the twelve-hour ceiling", () => {
|
||||||
|
// A runaway value still can't hang the process forever.
|
||||||
|
expect(resolveWhisperTimeoutMs(60, { overrideMs: 999_999_999 })).toBe(43_200_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the model factor when overrideMs is set", () => {
|
||||||
|
// Override wins outright — the model factor only affects the auto-scaled
|
||||||
|
// path. Otherwise `--timeout 60000` on `medium.en` would silently become
|
||||||
|
// 120_000ms and break the discoverability contract.
|
||||||
|
expect(resolveWhisperTimeoutMs(60, { overrideMs: 60_000, model: "medium.en" })).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||||
|
"falls back to auto-scaling when overrideMs is invalid (%s)",
|
||||||
|
(invalid) => {
|
||||||
|
// Invalid override → auto-scaled default kicks in (10s * 10 = 100_000ms →
|
||||||
|
// floor 300_000ms). Prevents callers from accidentally disabling the
|
||||||
|
// timeout via a bad env var.
|
||||||
|
expect(resolveWhisperTimeoutMs(10, { overrideMs: invalid })).toBe(300_000);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isWhisperTimeoutError", () => {
|
||||||
|
it("returns true for a Node SIGTERM child-timeout error", () => {
|
||||||
|
const err = Object.assign(new Error("Command failed"), { signal: "SIGTERM" });
|
||||||
|
expect(isWhisperTimeoutError(err)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for an ETIMEDOUT-coded error", () => {
|
||||||
|
const err = Object.assign(new Error("Command failed"), { code: "ETIMEDOUT" });
|
||||||
|
expect(isWhisperTimeoutError(err)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a non-timeout child error", () => {
|
||||||
|
const err = Object.assign(new Error("Exit code 1"), { status: 1, signal: null });
|
||||||
|
expect(isWhisperTimeoutError(err)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a plain string", () => {
|
||||||
|
expect(isWhisperTimeoutError("boom")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wrapWhisperTimeoutError", () => {
|
||||||
|
it("passes non-timeout errors through unchanged", () => {
|
||||||
|
const err = new Error("segfault");
|
||||||
|
const result = wrapWhisperTimeoutError(err, {
|
||||||
|
effectiveTimeoutMs: 600_000,
|
||||||
|
model: "small.en",
|
||||||
|
wasOverride: false,
|
||||||
|
});
|
||||||
|
expect(result).toBe(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps SIGTERM timeouts with a discoverable hint naming --timeout and the env var", () => {
|
||||||
|
const original = Object.assign(new Error("Command failed"), { signal: "SIGTERM" });
|
||||||
|
const wrapped = wrapWhisperTimeoutError(original, {
|
||||||
|
effectiveTimeoutMs: 1_260_000,
|
||||||
|
model: "medium.en",
|
||||||
|
wasOverride: false,
|
||||||
|
});
|
||||||
|
// Discoverability contract: message must name the flag, env var, effective
|
||||||
|
// timeout, and cite the model so slow-CPU reporters see the knob.
|
||||||
|
expect(wrapped).not.toBe(original);
|
||||||
|
expect(wrapped.message).toContain("--timeout");
|
||||||
|
expect(wrapped.message).toContain("HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS");
|
||||||
|
expect(wrapped.message).toContain("1260s");
|
||||||
|
expect(wrapped.message).toContain("medium.en");
|
||||||
|
expect((wrapped as { cause?: unknown }).cause).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the source as explicit when wasOverride is true", () => {
|
||||||
|
const original = Object.assign(new Error("Command failed"), { code: "ETIMEDOUT" });
|
||||||
|
const wrapped = wrapWhisperTimeoutError(original, {
|
||||||
|
effectiveTimeoutMs: 90_000,
|
||||||
|
model: "medium.en",
|
||||||
|
wasOverride: true,
|
||||||
|
});
|
||||||
|
expect(wrapped.message).toContain("explicit --timeout 90000ms");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces non-Error timeout-shaped values to Error without augmenting", () => {
|
||||||
|
// A non-Error thrown value can't carry `.signal`, so it flows through
|
||||||
|
// untouched but is still normalized to an Error instance for downstream
|
||||||
|
// handlers that use instanceof checks.
|
||||||
|
const wrapped = wrapWhisperTimeoutError("something bad", {
|
||||||
|
effectiveTimeoutMs: 600_000,
|
||||||
|
model: "small.en",
|
||||||
|
wasOverride: false,
|
||||||
|
});
|
||||||
|
expect(wrapped).toBeInstanceOf(Error);
|
||||||
|
expect(wrapped.message).toBe("something bad");
|
||||||
|
expect(wrapped.message).not.toContain("--timeout");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveAudioPreparationTimeoutMs", () => {
|
describe("resolveAudioPreparationTimeoutMs", () => {
|
||||||
it.each([
|
it.each([
|
||||||
[10, 120_000],
|
[10, 120_000],
|
||||||
|
|||||||
@@ -46,19 +46,95 @@ const AUDIO_PREPARATION_TIMEOUT_PER_MEDIA_SECOND_MS = 500;
|
|||||||
const AUDIO_PREPARATION_TIMEOUT_CAP_MS = 21_600_000;
|
const AUDIO_PREPARATION_TIMEOUT_CAP_MS = 21_600_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Give long recordings enough time to transcribe while retaining a bounded
|
* Model-specific slowdown factors relative to the `small.en` default. whisper.cpp's
|
||||||
* failure window. Short recordings keep the historical five-minute timeout.
|
* per-token inference cost scales with model size — `medium` runs ~2x slower than
|
||||||
|
* `small`, and the `large` family ~4x slower — so the 10x-realtime baseline that
|
||||||
|
* comfortably covers `small.en` can still time out on `medium.en`/`large-v3` when
|
||||||
|
* the CPU itself is slow. Applying the factor keeps the historical safety window
|
||||||
|
* for the default model while giving heavier models the headroom they need on
|
||||||
|
* emulated arm64/x64 hardware (field-signal ts=1784165471: Snapdragon emulating
|
||||||
|
* x64 saw ~13x realtime on medium.en for a 63s clip).
|
||||||
|
*
|
||||||
|
* Values are conservative bounds, not tight upper bounds — the auto-scaled
|
||||||
|
* timeout is still capped at 12h and gated by an explicit `--timeout` override.
|
||||||
*/
|
*/
|
||||||
export function resolveWhisperTimeoutMs(durationSeconds: number | null): number {
|
const WHISPER_MODEL_SLOWDOWN_FACTORS: Readonly<Record<string, number>> = {
|
||||||
|
tiny: 0.5,
|
||||||
|
"tiny.en": 0.5,
|
||||||
|
base: 0.7,
|
||||||
|
"base.en": 0.7,
|
||||||
|
small: 1,
|
||||||
|
"small.en": 1,
|
||||||
|
medium: 2,
|
||||||
|
"medium.en": 2,
|
||||||
|
"large-v1": 4,
|
||||||
|
"large-v2": 4,
|
||||||
|
"large-v3": 4,
|
||||||
|
"large-v3-turbo": 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unknown model names fall back to the `small.en` baseline so the returned
|
||||||
|
// timeout never dips below the historical safe window for a novel/custom model.
|
||||||
|
const DEFAULT_MODEL_SLOWDOWN_FACTOR = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the auto-scale slowdown factor for a whisper model name. Case-
|
||||||
|
* insensitive. Unknown names fall back to the `small.en` baseline (factor 1)
|
||||||
|
* rather than a smaller factor so unknown models never accidentally shorten
|
||||||
|
* the safety window.
|
||||||
|
*/
|
||||||
|
export function whisperModelSlowdownFactor(model: string): number {
|
||||||
|
return WHISPER_MODEL_SLOWDOWN_FACTORS[model.toLowerCase()] ?? DEFAULT_MODEL_SLOWDOWN_FACTOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveWhisperTimeoutOptions {
|
||||||
|
/** Whisper model name (e.g. `small.en`, `medium.en`, `large-v3`). Selects the slowdown factor. */
|
||||||
|
model?: string;
|
||||||
|
/**
|
||||||
|
* Explicit override in milliseconds. Bypasses duration+model auto-scaling.
|
||||||
|
* Still clamped to the 12h cap so a runaway value can't hang the process
|
||||||
|
* indefinitely; validation of the lower bound is the caller's responsibility.
|
||||||
|
*/
|
||||||
|
overrideMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give long recordings enough time to transcribe while retaining a bounded
|
||||||
|
* failure window. Short recordings keep the historical five-minute floor.
|
||||||
|
*
|
||||||
|
* Formula: `clamp(FLOOR, duration * PER_SECOND * modelFactor, CAP)`.
|
||||||
|
* An explicit `overrideMs` bypasses the formula entirely (still capped at 12h).
|
||||||
|
*/
|
||||||
|
export function resolveWhisperTimeoutMs(
|
||||||
|
durationSeconds: number | null,
|
||||||
|
options?: ResolveWhisperTimeoutOptions,
|
||||||
|
): number {
|
||||||
|
// Explicit override wins — respect the caller's exact value (still capped at
|
||||||
|
// the 12h ceiling so a runaway value can't leave the process hung forever).
|
||||||
|
// We do NOT re-apply the floor here: a user who deliberately passed
|
||||||
|
// `--timeout 30000` on a 3s clip meant 30 seconds, not five minutes.
|
||||||
|
if (
|
||||||
|
options?.overrideMs != null &&
|
||||||
|
Number.isFinite(options.overrideMs) &&
|
||||||
|
options.overrideMs > 0
|
||||||
|
) {
|
||||||
|
return Math.min(WHISPER_TIMEOUT_CAP_MS, options.overrideMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const factor = options?.model ? whisperModelSlowdownFactor(options.model) : 1;
|
||||||
|
|
||||||
if (durationSeconds === null || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
if (durationSeconds === null || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||||
return WHISPER_TIMEOUT_FLOOR_MS;
|
// Duration unknown: keep the historical five-minute floor for the default
|
||||||
|
// model, but scale it up for heavier models so `medium`/`large` still get
|
||||||
|
// a proportionate window when ffprobe can't read the WAV header.
|
||||||
|
return Math.min(WHISPER_TIMEOUT_CAP_MS, Math.ceil(WHISPER_TIMEOUT_FLOOR_MS * factor));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.min(
|
return Math.min(
|
||||||
WHISPER_TIMEOUT_CAP_MS,
|
WHISPER_TIMEOUT_CAP_MS,
|
||||||
Math.max(
|
Math.max(
|
||||||
WHISPER_TIMEOUT_FLOOR_MS,
|
WHISPER_TIMEOUT_FLOOR_MS,
|
||||||
Math.ceil(durationSeconds * WHISPER_TIMEOUT_PER_AUDIO_SECOND_MS),
|
Math.ceil(durationSeconds * WHISPER_TIMEOUT_PER_AUDIO_SECOND_MS * factor),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -185,6 +261,12 @@ export interface TranscribeOptions {
|
|||||||
model?: string;
|
model?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
onProgress?: (message: string) => void;
|
onProgress?: (message: string) => void;
|
||||||
|
/**
|
||||||
|
* Explicit whisper spawn timeout in ms. Overrides the duration+model auto-
|
||||||
|
* scaled default. Callers that leave this undefined get the auto-scaled
|
||||||
|
* default derived from prepared WAV duration and the selected model.
|
||||||
|
*/
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscribeResult {
|
export interface TranscribeResult {
|
||||||
@@ -388,11 +470,26 @@ export async function transcribe(
|
|||||||
}
|
}
|
||||||
whisperArgs.push(wavPath);
|
whisperArgs.push(wavPath);
|
||||||
|
|
||||||
const whisperTimeoutMs = resolveWhisperTimeoutMs(getPreparedWavDurationSeconds(wavPath));
|
const whisperTimeoutMs = resolveWhisperTimeoutMs(getPreparedWavDurationSeconds(wavPath), {
|
||||||
|
model: effectiveModel,
|
||||||
|
overrideMs: options?.timeoutMs,
|
||||||
|
});
|
||||||
|
try {
|
||||||
execFileSync(whisper.executablePath, whisperArgs, {
|
execFileSync(whisper.executablePath, whisperArgs, {
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
timeout: whisperTimeoutMs,
|
timeout: whisperTimeoutMs,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Surface the timeout knob when the child was killed by our own timeout —
|
||||||
|
// otherwise the reporter sees a bare ETIMEDOUT / SIGTERM with no hint that
|
||||||
|
// `--timeout` even exists. Non-timeout errors flow through unchanged so the
|
||||||
|
// existing stderr-tail handling in `transcribeAudio` still applies.
|
||||||
|
throw wrapWhisperTimeoutError(err, {
|
||||||
|
effectiveTimeoutMs: whisperTimeoutMs,
|
||||||
|
model: effectiveModel,
|
||||||
|
wasOverride: options?.timeoutMs != null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Read and validate output
|
// 6. Read and validate output
|
||||||
const transcriptPath = `${outputBase}.json`;
|
const transcriptPath = `${outputBase}.json`;
|
||||||
@@ -433,3 +530,51 @@ export async function transcribe(
|
|||||||
speechOnsetSeconds,
|
speechOnsetSeconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Timeout error discoverability
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Node's `execFileSync` kills the child with SIGTERM when its `timeout` option
|
||||||
|
// fires, so the resulting Error carries `signal === "SIGTERM"`. On some platforms
|
||||||
|
// / Node versions `code === "ETIMEDOUT"` is also set. Match either signal so we
|
||||||
|
// don't miss a timeout on a platform we haven't validated.
|
||||||
|
export function isWhisperTimeoutError(err: unknown): boolean {
|
||||||
|
if (!(err instanceof Error)) return false;
|
||||||
|
const record = err as { signal?: unknown; code?: unknown };
|
||||||
|
return record.signal === "SIGTERM" || record.code === "ETIMEDOUT";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WrapWhisperTimeoutOptions {
|
||||||
|
effectiveTimeoutMs: number;
|
||||||
|
model: string;
|
||||||
|
/** True when the timeout was set via `--timeout`; false when it was auto-scaled. */
|
||||||
|
wasOverride: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a whisper spawn error with a discoverability hint when the child was
|
||||||
|
* killed by our timeout. Names the effective timeout, the CLI flag, and the
|
||||||
|
* env var so slow-CPU users see the knob rather than a bare `ETIMEDOUT`.
|
||||||
|
* Non-timeout errors flow through unchanged (as `Error` for well-typed
|
||||||
|
* downstream handling).
|
||||||
|
*/
|
||||||
|
export function wrapWhisperTimeoutError(err: unknown, options: WrapWhisperTimeoutOptions): Error {
|
||||||
|
if (!isWhisperTimeoutError(err)) {
|
||||||
|
return err instanceof Error ? err : new Error(String(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = Math.round(options.effectiveTimeoutMs / 1000);
|
||||||
|
const source = options.wasOverride
|
||||||
|
? `explicit --timeout ${options.effectiveTimeoutMs}ms`
|
||||||
|
: `auto-scaled default for model ${options.model}`;
|
||||||
|
const message =
|
||||||
|
`Whisper transcription exceeded ${seconds}s (${source}). ` +
|
||||||
|
`Raise --timeout <ms> or set HYPERFRAMES_TRANSCRIBE_TIMEOUT_MS. ` +
|
||||||
|
`Slow CPUs (e.g. emulated arm64/x64, low-power laptops) may need many ` +
|
||||||
|
`multiples of realtime on heavier models — medium.en can run ~10-15x ` +
|
||||||
|
`realtime on constrained hardware.`;
|
||||||
|
const wrapped = new Error(message);
|
||||||
|
(wrapped as { cause?: unknown }).cause = err;
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user