From a04ce6a24488bd877ed478d1daac600d854536a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 00:36:34 -0400 Subject: [PATCH] fix(cli): scale whisper timeout for long recordings (#2463) * fix(cli): scale whisper timeout with audio duration * style(cli): format whisper timeout scaling * style(media-use): format resolver script * chore(skills): refresh bundled manifest * fix(cli): scale audio preparation timeouts --- packages/cli/src/whisper/transcribe.test.ts | 51 ++++++++++- packages/cli/src/whisper/transcribe.ts | 94 ++++++++++++++++++++- 2 files changed, 140 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/whisper/transcribe.test.ts b/packages/cli/src/whisper/transcribe.test.ts index 4678f41f1..b3d792d40 100644 --- a/packages/cli/src/whisper/transcribe.test.ts +++ b/packages/cli/src/whisper/transcribe.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, test } from "vitest"; -import { dtwPresetForModel } from "./transcribe.js"; +import { describe, expect, it, test } from "vitest"; +import { + dtwPresetForModel, + resolveAudioPreparationTimeoutMs, + resolveWhisperTimeoutMs, +} from "./transcribe.js"; describe("dtwPresetForModel", () => { // The large family is the regression: model files are hyphenated but @@ -22,3 +26,46 @@ describe("dtwPresetForModel", () => { }, ); }); + +describe("resolveWhisperTimeoutMs", () => { + it("keeps the existing five-minute floor for short recordings", () => { + expect(resolveWhisperTimeoutMs(10)).toBe(300_000); + }); + + it("scales the timeout for long recordings", () => { + expect(resolveWhisperTimeoutMs(41 * 60)).toBe(24_600_000); + }); + + it("caps the safety window at twelve hours", () => { + expect(resolveWhisperTimeoutMs(24 * 60 * 60)).toBe(43_200_000); + }); + + it("falls back to five minutes when duration is unavailable", () => { + expect(resolveWhisperTimeoutMs(null)).toBe(300_000); + expect(resolveWhisperTimeoutMs(Number.NaN)).toBe(300_000); + }); + + it.each([ + [30, 300_000], + [30.1, 301_000], + [4319, 43_190_000], + [4320, 43_200_000], + ])("clamps duration %ss to %sms", (duration, expected) => { + expect(resolveWhisperTimeoutMs(duration)).toBe(expected); + }); +}); + +describe("resolveAudioPreparationTimeoutMs", () => { + it.each([ + [10, 120_000], + [6 * 60 * 60, 10_800_000], + [24 * 60 * 60, 21_600_000], + ])("scales duration %ss to %sms", (duration, expected) => { + expect(resolveAudioPreparationTimeoutMs(duration)).toBe(expected); + }); + + it("falls back to two minutes when duration is unavailable", () => { + expect(resolveAudioPreparationTimeoutMs(null)).toBe(120_000); + expect(resolveAudioPreparationTimeoutMs(Number.NaN)).toBe(120_000); + }); +}); diff --git a/packages/cli/src/whisper/transcribe.ts b/packages/cli/src/whisper/transcribe.ts index 96c8ec48f..cc15eb7df 100644 --- a/packages/cli/src/whisper/transcribe.ts +++ b/packages/cli/src/whisper/transcribe.ts @@ -38,6 +38,84 @@ function findWavDataChunk(buf: Buffer): { offset: number; size: number } | null return null; } +const WHISPER_TIMEOUT_FLOOR_MS = 300_000; +const WHISPER_TIMEOUT_PER_AUDIO_SECOND_MS = 10_000; +const WHISPER_TIMEOUT_CAP_MS = 43_200_000; +const AUDIO_PREPARATION_TIMEOUT_FLOOR_MS = 120_000; +const AUDIO_PREPARATION_TIMEOUT_PER_MEDIA_SECOND_MS = 500; +const AUDIO_PREPARATION_TIMEOUT_CAP_MS = 21_600_000; + +/** + * Give long recordings enough time to transcribe while retaining a bounded + * failure window. Short recordings keep the historical five-minute timeout. + */ +export function resolveWhisperTimeoutMs(durationSeconds: number | null): number { + if (durationSeconds === null || !Number.isFinite(durationSeconds) || durationSeconds <= 0) { + return WHISPER_TIMEOUT_FLOOR_MS; + } + + return Math.min( + WHISPER_TIMEOUT_CAP_MS, + Math.max( + WHISPER_TIMEOUT_FLOOR_MS, + Math.ceil(durationSeconds * WHISPER_TIMEOUT_PER_AUDIO_SECOND_MS), + ), + ); +} + +/** + * Bound FFmpeg audio preparation while allowing long recordings to scale past + * the historical two-minute timeout. The half-realtime allowance is generous + * for audio-only extraction without inheriting Whisper's much larger window. + */ +export function resolveAudioPreparationTimeoutMs(durationSeconds: number | null): number { + if (durationSeconds === null || !Number.isFinite(durationSeconds) || durationSeconds <= 0) { + return AUDIO_PREPARATION_TIMEOUT_FLOOR_MS; + } + + return Math.min( + AUDIO_PREPARATION_TIMEOUT_CAP_MS, + Math.max( + AUDIO_PREPARATION_TIMEOUT_FLOOR_MS, + Math.ceil(durationSeconds * AUDIO_PREPARATION_TIMEOUT_PER_MEDIA_SECOND_MS), + ), + ); +} + +function getMediaDurationSeconds(filePath: string): number | null { + try { + const ffprobePath = findFFprobe(); + if (!ffprobePath) return null; + const raw = execFileSync( + ffprobePath, + [ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + filePath, + ], + { encoding: "utf-8", timeout: 10_000 }, + ); + const durationSeconds = Number.parseFloat(raw.trim()); + return Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : null; + } catch { + return null; + } +} + +function getPreparedWavDurationSeconds(wavPath: string): number | null { + try { + const dataChunk = findWavDataChunk(readFileSync(wavPath)); + if (!dataChunk) return null; + return dataChunk.size / (16_000 * 2); + } catch { + return null; + } +} + /** * Detect when speech begins in a 16kHz mono WAV by finding the first * sustained energy jump above the track's median RMS. Returns onset time in @@ -152,7 +230,10 @@ function extractAudio(videoPath: string): string { execFileSync( ffmpegPath, ["-i", videoPath, "-vn", "-ar", "16000", "-ac", "1", "-f", "wav", "-y", wavPath], - { stdio: "ignore", timeout: 120_000 }, + { + stdio: "ignore", + timeout: resolveAudioPreparationTimeoutMs(getMediaDurationSeconds(videoPath)), + }, ); return wavPath; } @@ -200,7 +281,10 @@ function prepareAudio(audioPath: string): string { execFileSync( ffmpegPath, ["-i", audioPath, "-ar", "16000", "-ac", "1", "-f", "wav", "-y", wavPath], - { stdio: "ignore", timeout: 120_000 }, + { + stdio: "ignore", + timeout: resolveAudioPreparationTimeoutMs(getMediaDurationSeconds(audioPath)), + }, ); return wavPath; } @@ -304,7 +388,11 @@ export async function transcribe( } whisperArgs.push(wavPath); - execFileSync(whisper.executablePath, whisperArgs, { stdio: "ignore", timeout: 300_000 }); + const whisperTimeoutMs = resolveWhisperTimeoutMs(getPreparedWavDurationSeconds(wavPath)); + execFileSync(whisper.executablePath, whisperArgs, { + stdio: "ignore", + timeout: whisperTimeoutMs, + }); // 6. Read and validate output const transcriptPath = `${outputBase}.json`;