Skip to content

fix(cli): scale whisper timeout for long recordings#2463

Merged
miguel-heygen merged 5 commits into
mainfrom
fix/transcribe-duration-timeout
Jul 15, 2026
Merged

fix(cli): scale whisper timeout for long recordings#2463
miguel-heygen merged 5 commits into
mainfrom
fix/transcribe-duration-timeout

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Summary

Long recordings no longer abort after Whisper's fixed five-minute process timeout. The CLI now derives a bounded timeout from the prepared 16 kHz WAV duration: five minutes minimum, ten times the audio duration, and twelve hours maximum. If WAV duration cannot be read, the existing five-minute behavior remains unchanged.

Test plan

  • bun test packages/cli/src/whisper/transcribe.test.ts — 13 passed
  • git diff --check
  • Full CLI typecheck could not be rerun in the isolated worktree because dependency installation exhausted the workspace disk; CI remains the authoritative full-package check.

Compound Engineering
GPT-5

@miguel-heygen miguel-heygen force-pushed the fix/transcribe-duration-timeout branch from a620825 to 58cb656 Compare July 15, 2026 03:32

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 93adbd64765955db373ddb1188204f672d9d8f10.

What this does

Replaces the hardcoded 5-minute whisper transcription timeout with a duration-scaled window. New pure helper resolveWhisperTimeoutMs(durationSeconds) at packages/cli/src/whisper/transcribe.ts:47-58 clamps ceil(duration × 10_000) between a 5-minute floor and a 12-hour cap; getPreparedWavDurationSeconds(wavPath) reads the WAV data chunk and returns size / (16_000 * 2). Call-site at :339-343 swaps the static timeout: 300_000 for the computed value.

Behavior grid:

  • ≤30s audio → 5 min (floor holds).
  • 30s–72 min audio → 10× realtime.
  • 72 min audio → 12 h (cap holds; 24 h recording gets 12 h ≈ 0.5× realtime — safety window, not expected wall).

  • null / NaN / ≤0 (probe failed) → 5 min (pre-PR behavior).

Also bundles a 3-line prettier reflow in skills/media-use/scripts/resolve.mjs (nested-ternary indentation + import consolidation) + matching skills-manifest.json hash bump for media-use.

Verification

  • resolveWhisperTimeoutMs clamp math is correct. Math.ceil(2460 * 10_000) = 24_600_000 for 41min * 60s; min(43_200_000, max(300_000, ...)) clamps as expected. Tests pin all four branches (floor / scale / cap / null-or-NaN) — clean unit-only coverage. ✓
  • getPreparedWavDurationSeconds is defensively wrapped. Any readFileSync / findWavDataChunk failure returns null, which flows to the 5-minute floor — same pre-PR behavior when duration is unknown. ✓
  • 10× realtime multiplier is generous for whisper.cpp. Small model runs ~1-3× realtime on CPU; large model ~5-10× on CPU (faster on GPU). 10× as a kill switch — not expected wall — is the right choice: never false-positive on legitimate long transcriptions but always bounded. ✓
  • resolve.mjs reflow is formatter-only. Import-block collapse ({ ... } from "..." on one line) and one nested-ternary indentation cleanup — no behavior change, no imports removed. Manifest-hash bump matches. ✓
  • CI: 38 SUCCESS + 5 SKIPPED at head. ✓

Concerns

  • 🟢 Duration formula hardcodes the 16kHz-mono-16-bit assumption (dataChunk.size / (16_000 * 2)). Whisper.cpp expects this exact format, and the upstream WAV prep in transcribe normalizes to it, so it's correct today. If someone extends the pipeline to a different sample rate or bit depth without updating this constant, getPreparedWavDurationSeconds silently miscalculates — a 3× sample-rate change would 3× the timeout in either direction. Deriving from the fmt chunk (sampleRate * channels * bitsPerSample/8) would be more robust, but the current constant is fine given the pipeline invariant. Note-only.

  • 🟢 resolve.mjs reflow is off-topic for a whisper-timeout PR — usually split off-topic formatter passes into a separate PR so the diff stays focused. Harmless here (3 lines, no behavior) but worth noting for future single-fix PRs.

Verdict framing

Small, well-tested, correct behavior change on a clear failure mode (long recordings timing out at 5 min). Both concerns are 🟢 notes. LGTM from my side.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right-vs-close: RIGHT direction — fix does what it says. REQUEST CHANGES to close the sibling-timeout class before merge; the whisper scaling itself is clean.

Additive to @james-russo-rames-d-jusso (whose review at 93adbd6 pins the clamp math, the null/NaN → floor path, the 10× multiplier calibration, and clears the resolve.mjs reflow as behavior-neutral — I concur on all four). My differentiator is the sibling ffmpeg timeouts in the same file, which Rames didn't audit.

Strengths

  • resolveWhisperTimeoutMs is a pure helper with an obvious contract — 4-branch coverage at packages/cli/src/whisper/transcribe.test.ts:25-42 (floor / scale / cap / null-or-NaN) is exactly the right shape for a math clamp.
  • getPreparedWavDurationSeconds failure-mode collapses to the pre-PR 300_000ms floor at packages/cli/src/whisper/transcribe.ts:70-77 — no regression path.
  • The probe re-uses the already-prepared 16kHz WAV on disk rather than adding an ffprobe round-trip. Clean.

Findings

important — sibling ffmpeg timeouts break the same "long recording" class (extrapolation-blocker)

File: packages/cli/src/whisper/transcribe.ts:187 and :235

The PR scales whisper's per-invocation timeout to input duration (12h cap). The two ffmpeg call sites that run BEFORE whisper on the same input still carry hardcoded 120_000ms (2 min) timeouts:

  • extractAudio at :184-187-vn -ar 16000 -ac 1 -f wav on the raw video.
  • prepareAudio at :232-235-ar 16000 -ac 1 -f wav re-encode when the input isn't already 16kHz mono.

The whisper-timeout design openly admits 12h recordings as a target (WHISPER_TIMEOUT_CAP_MS = 43_200_000 at :42). For a 6-12h source on constrained hardware (CI runners, containers with low CPU cap, spinning disks), audio extraction can plausibly exceed 2 min — ffmpeg -vn is typically fast on modern SSD desktops, but tight enough at the upper duration envelope this PR admits that the pipeline isn't internally consistent about it. A user who supplies a 6h video will now clear the whisper timeout only to potentially hit extractAudio's 2-min cap upstream; the failure signature ("transcription failed") is identical from the outside despite a different root cause.

Per your own 2026-07-14 directive in #hyperframes-work on extrapolation-as-blocker:

"if we see a fix that can be extrapolated to other areas we should propose that fix as requested changes... not merging prs that don't solve root issues just for merging them."

The fix shape here is "scale a hardcoded per-invocation timeout in the transcribe pipeline by input duration." Two other sites in the same file match that shape. Either close the class, or document why the sibling call sites are safe by design.

Two acceptable resolutions:

  1. Scale both ffmpeg timeouts by input duration (a lower multiplier than whisper is fine — ffmpeg audio-only extraction is 50-200× realtime typically, so a 2-min floor + 0.5× realtime scaled cap would be generous while still bounded).
  2. Add a short comment at :187 and :235 explicitly justifying why 120_000ms covers a 12h source. Justification-only clears the concern without a code change.

nit — branch-boundary test coverage

File: packages/cli/src/whisper/transcribe.test.ts:25-42

Tests exercise floor (10s), well within scaling (2460s), well above cap (24h), and null/NaN. The two branch boundaries — 30s (below floor binds) and 4320s / 72 min (above cap binds) — aren't parameterized. Adds ~4 lines to pin against future off-by-one edits:

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),
);

Not blocking. The existing coverage already catches the algorithmic mistake.

note — 16kHz-mono-16-bit constant

File: packages/cli/src/whisper/transcribe.ts:73

Rames flagged this; concurring — the 16_000 * 2 bytes-per-second constant is correct today given the pipeline invariants forced at :184-187 and :232-235, and the wrong-format failure mode is bounded by the floor/cap. Non-blocking. Deriving from the fmt chunk (sampleRate * channels * bitsPerSample/8) would be more robust but is overkill for the current pipeline.

Envelope

PR body carries Compound Engineering / GPT-5 badges — per convention, self-authored PRs are exempt from the AI-tooling-attribution rule. Documentation-only note.

Verdict

Verdict: REQUEST CHANGES
Reasoning: The whisper scaling is correct and well-tested — RIGHT direction, no scope kill. Two co-located hardcoded ffmpeg timeouts on the same long-recording code path (extractAudio:187, prepareAudio:235) satisfy the same extrapolation-blocker shape you flagged 2026-07-14; either scale them too, or add a one-line justification comment on why 120_000ms is sufficient for a 12h source. Small delta, closes the class in one PR instead of a follow-up cycle.

— Via

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Resolved Via's current review blocker at c14c68abb.

The two ffmpeg call sites that run BEFORE whisper on the same input still carry hardcoded 120_000ms (2 min) timeouts.

Both upstream paths now use a shared duration-aware audio-preparation timeout: 2-minute floor, 0.5× realtime scaling, and 6-hour cap. extractAudio probes the source duration; prepareAudio derives duration from the decoded WAV, with probe/read failures retaining the prior 120-second fallback. I also pinned the exact Whisper floor/cap boundaries requested in the review.

Verification on the current head:

  • focused transcribe suite: 21/21 passed
  • full CLI suite: 139 files passed, 1 skipped; 1800 tests passed, 2 skipped
  • monorepo build, CLI typecheck, oxlint, oxfmt, Fallow, and pre-commit checks passed
  • sibling-path audit: both preprocessing call sites now share the scaled contract; no remaining fixed timeout in the transcription path

Please re-review the current head when CI finishes.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at c14c68abb3e663367cc646e5ba2c020cbb4afdf4 — R1 (93adbd64) → R2 delta is +83 lines across the same two files. CI: 30 SUCCESS, 6 IN_PROGRESS, 0 FAILURE at read time.

R2 delta

Closes Via's extrapolation blocker — the two upstream FFmpeg audio-preparation call sites at packages/cli/src/whisper/transcribe.ts:232 (extractAudio for video sources) and :284 (prepareAudio for audio sources) were still using the pre-PR timeout: 120_000 fixed 2-minute cap, so long recordings would time out during the FFmpeg extract even after R1 raised the whisper timeout. R2 adds a symmetric duration-scaled helper for the prep path:

  • resolveAudioPreparationTimeoutMs(durationSeconds) at :71-83 — 2m floor / 0.5× realtime (500 ms per media second) / 6h cap. Same shape as resolveWhisperTimeoutMs but with lower coefficients matching FFmpeg's ~10-100× realtime extraction speed vs. whisper.cpp's ~5-10× realtime compute.
  • getMediaDurationSeconds(filePath) at :87-108 — ffprobe-based duration probe (format=duration, default=noprint_wrappers=1:nokey=1). Handles arbitrary source formats (video containers, audio containers). Try/catch → null on any failure. 10-second internal timeout on the ffprobe call itself.

New clamp-boundary regressions on the whisper side (transcribe.test.ts:48-55) — it.each covers [30s → floor 300_000ms], [30.1s → 301_000ms] (first tick past floor), [4319s → 43_190_000ms] (last tick below cap), [4320s → cap 43_200_000ms]. These pin the exact clamp(floor, ceil(d×10_000), cap) shape and would catch a coefficient change or a < vs <= boundary regression. Prep-side tests mirror the shape (floor / mid / cap / null-NaN).

Verification

  • Clamp arithmetic checks out. 6h × 500 = 10_800_000 fits in [120_000, 21_600_000]; 24h × 500 = 43_200_000 clamps down to 21_600_000. Whisper boundaries at 30.1s → 301_000ms need Math.ceil(30.1 × 10_000) = 301_000 exactly — no float-precision surprise since 30.1 × 10_000 = 301_000 fits IEEE-754 double cleanly (in fact Math.ceil(301000.00000000006) === 301001 would blow this, but 30.1 * 10000 = 301000 here — verified). ✓
  • Coefficient scales are physically sensible. FFmpeg 16kHz mono WAV extraction runs 10-100× realtime on modern CPU; 0.5× realtime is a comfortable kill-switch not expected wall. Whisper.cpp is 1-10× realtime; 10× is the outer envelope. Different scales, same pattern. ✓
  • ffprobe fallback is safe. findFFprobe() null → getMediaDurationSeconds returns null → resolveAudioPreparationTimeoutMs(null) → floor. Preserves pre-PR 2-min behavior when ffprobe is unavailable. Same shape as R1's getPreparedWavDurationSeconds fallback. ✓
  • All three timeout sites now duration-awareextractAudio (video source), prepareAudio (audio source), whisper (transcription). Sibling audit claim consistent with what's visible in the diff. ✓
  • R1 concerns: the 🟢 note about getPreparedWavDurationSeconds hardcoding 16kHz-mono-16-bit is unchanged — but the R2 upstream ffprobe-based path now handles arbitrary source formats, so the hardcoded assumption is only load-bearing on the post-prep WAV which is guaranteed to be 16kHz-mono-16-bit by the extract/prepare step. So the hardcoding is safely internal to the pipeline invariant. Concern effectively closed. The 🟢 off-topic resolve.mjs prettier reflow from R1 persists but is still harmless.

Concerns

  • 🟢 Extra ffprobe call per transcribe invocation. getMediaDurationSeconds is called once per extractAudio / prepareAudio call. In practice this is one probe per transcription request (typically ~50-200ms probe latency), so the overhead is negligible relative to the multi-second FFmpeg extract that follows. Not a concern.

Verdict framing

Clean R2 — closes Via's blocker with a symmetric extension of R1's pattern, adds sharp boundary regressions on both sides, and preserves all pre-PR fallback shapes. LGTM from my side; standing on the six IN_PROGRESS shards.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R2 verification at c14c68abb — R1 blocker closed cleanly. RIGHT direction, ship it.

Additive to @james-russo-rames-d-jusso whose R2 at the same SHA already pinned the clamp arithmetic, the coefficient rationale (0.5× realtime for FFmpeg vs 10× for whisper.cpp), the findFFprobe() → null → floor fallback, and the it.each boundary shape. My R2 differentiator is per-site verification against my R1 extrapolation blocker + a sibling-audit scan on the whisper directory.

R1 blocker: closed

Both hardcoded 120_000ms sites I flagged now route through the shared duration-aware helper at head c14c68abb:

R1 site New head Timeout expression
extractAudio :187 packages/cli/src/whisper/transcribe.ts:230-236 resolveAudioPreparationTimeoutMs(getMediaDurationSeconds(videoPath))
prepareAudio :235 packages/cli/src/whisper/transcribe.ts:281-287 resolveAudioPreparationTimeoutMs(getMediaDurationSeconds(audioPath))
whisper (R1) packages/cli/src/whisper/transcribe.ts:391-395 resolveWhisperTimeoutMs(getPreparedWavDurationSeconds(wavPath)) unchanged

Formula parity vs. your summary ("2m floor, 0.5× realtime, 6h cap"):

  • AUDIO_PREPARATION_TIMEOUT_FLOOR_MS = 120_000 ✓ 2 min
  • AUDIO_PREPARATION_TIMEOUT_PER_MEDIA_SECOND_MS = 500 ✓ 0.5× realtime
  • AUDIO_PREPARATION_TIMEOUT_CAP_MS = 21_600_000 ✓ 6 hours

Coefficient asymmetry with the whisper helper (5m/10×/12h) is intentional and called out in the doc comment at :66-70: audio-only extract is 10-100× realtime, whisper.cpp is 1-10× realtime. Different physical work, different budget. Not a bug.

R1 nit: closed

Boundary pins added at transcribe.test.ts:40-47: [30, 300_000] (floor binds), [30.1, 301_000] (first tick past floor), [4319, 43_190_000] (last tick below cap), [4320, 43_200_000] (cap binds). Exactly the shape I proposed in R1. Would catch a coefficient tweak or a < vs <= boundary regression.

Refactor-risk audit (extract-to-helper)

  • Helper body correctness. Math.min(cap, Math.max(floor, Math.ceil(d × scale))) — standard clamp shape, no self-recursion, args match signature. null / !isFinite / <= 0 → floor.
  • Floor preservation at every call site. All three sites now invoke the helper; no stale inline 120_000 / 300_000 remains at the ffmpeg/whisper execFileSync timeout slots (verified by grepping transcribe.ts at head — only timeout: 30_000 in detectLanguage, and two timeout: 10_000 inside the ffprobe metadata probes remain, all on fixed-work operations that don't scale with recording length; see next section).
  • Fallback shape. getMediaDurationSeconds and getPreparedWavDurationSeconds both catch-and-return-null on any failure → floor → pre-PR behavior. No regression path.

Sibling audit — "no remaining fixed timeout in the transcription path"

Grepping transcribe.ts at c14c68abb for timeout literals:

  • :18timeout: 30_000 inside detectLanguage. Whisper --detect-language processes only the first ~30s of audio internally (fixed work, not O(recording length)). Correctly not scaled.
  • :100timeout: 10_000 inside getMediaDurationSeconds (ffprobe metadata read). Fixed work.
  • :251timeout: 10_000 inside isWav16kMono (ffprobe stream read). Fixed work.

All three are O(1) in recording length. Claim holds for the O(duration) transcription path.

Sibling files in packages/cli/src/whisper/:

  • normalize.ts — no timeouts.
  • manager.ts — several timeouts (60_000 at :113, 120_000 at :123, 300_000 at :128 / :192), all on whisper binary/model install/download paths. Setup, not transcription. Out of scope.
  • parakeet.ts:138timeout: 1_800_000 (30 min) on the Parakeet ASR runner. This is a transcription path (alternate ASR backend), but PR title + R1 blocker were scoped to whisper. Flagging as a should-be-follow-up-ticket — same "long-recording caps on a per-invocation timeout" shape you'd want to extrapolate the whisper fix to, but a separate PR at reviewer discretion since the Parakeet backend has different runtime characteristics (GPU vs CPU envelope) and warrants its own coefficient calibration. Not R2-gating.

Nit — prep-side boundary transitions not pinned

transcribe.test.ts:51-55 exercises floor (10s), well within scale (21600s / 6h), and cap-binding (86400s / 24h) for resolveAudioPreparationTimeoutMs. The transitions themselves — 240s (floor → scale) and 43200s (scale → cap) — aren't pinned the way the whisper side is at :40-47. Same test shape symmetry would catch prep-side coefficient regressions. Two-line diff; not blocking.

Envelope

Commit trailers grep-clean (no Co-Authored-By: Claude on any of the 5 commits). PR body carries Compound Engineering / GPT-5 badges — self-authored, exempt.

CI

Required checks: CLI smoke (required), Test, Typecheck, Lint, Format, Fallow audit, CLI: npx shim × 3 platforms, Build, Skills: manifest in sync, Producer: integration tests, Producer: unit tests, SDK: unit + contract + smoke, Studio: load smoke, Analyze (javascript-typescript), Analyze (python), CodeQL, Semantic PR title — all pass at head c14c68abb. Two non-required Windows checks (Render on windows-latest, Tests on windows-latest) still pending; not gating for a Node CLI change.

Verdict

Verdict: APPROVE
Reasoning: R1 blocker closed (both ffmpeg sites now duration-aware via a shared helper with correct clamp shape and safe null-fallback), R1 nit closed (whisper boundary pins added exactly as suggested), sibling audit confirms no other fixed timeout on the O(duration) whisper transcription path. Parakeet's own 30-min cap is a scope-adjacent follow-up worth its own PR, not R2-gating. All required CI green.

— Via

@miguel-heygen miguel-heygen merged commit a04ce6a into main Jul 15, 2026
41 checks passed
@miguel-heygen miguel-heygen deleted the fix/transcribe-duration-timeout branch July 15, 2026 04:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants