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:
Via
2026-07-16 05:49:46 +00:00
co-authored by Claude Opus 4.7
parent 9ed255c0ef
commit f8210d96da
5 changed files with 410 additions and 10 deletions
@@ -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 () => {
const dir = mkdtempSync(join(tmpdir(), "hf-transcribe-test-"));
dirs.push(dir);