mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +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:
@@ -23,6 +23,14 @@ import { resolve, join, extname, dirname } from "node:path";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.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";
|
||||
|
||||
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.",
|
||||
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 }) {
|
||||
const inputPath = resolve(args.input);
|
||||
@@ -120,16 +138,42 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
// ── Transcribe mode: run the ASR engine ──────────────────────────────
|
||||
const timeoutMs = parseTimeoutMs(args.timeout, args.json);
|
||||
|
||||
return transcribeAudio(inputPath, dir, {
|
||||
engine: args.engine,
|
||||
model: args.model,
|
||||
language: args.language,
|
||||
json: args.json,
|
||||
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 {
|
||||
trackCommandFailure("transcribe", message);
|
||||
if (json) {
|
||||
@@ -226,7 +270,14 @@ async function exportTranscript(
|
||||
async function transcribeAudio(
|
||||
inputPath: 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> {
|
||||
const { transcribe } = await import("../whisper/transcribe.js");
|
||||
const { loadTranscript, patchCaptionHtml, stripBeforeOnset } =
|
||||
@@ -260,6 +311,7 @@ async function transcribeAudio(
|
||||
model,
|
||||
language: opts.language,
|
||||
onProgress: spin ? (msg) => spin.message(msg) : undefined,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
|
||||
let { words } = loadTranscript(result.transcriptPath);
|
||||
|
||||
Reference in New Issue
Block a user