fix(cli): scale whisper timeout for long recordings#2463
Conversation
a620825 to
58cb656
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
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
resolveWhisperTimeoutMsclamp math is correct.Math.ceil(2460 * 10_000) = 24_600_000for41min * 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. ✓getPreparedWavDurationSecondsis defensively wrapped. AnyreadFileSync/findWavDataChunkfailure returnsnull, 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.mjsreflow 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 intranscribenormalizes to it, so it's correct today. If someone extends the pipeline to a different sample rate or bit depth without updating this constant,getPreparedWavDurationSecondssilently 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.mjsreflow 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.
vanceingalls
left a comment
There was a problem hiding this comment.
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
resolveWhisperTimeoutMsis a pure helper with an obvious contract — 4-branch coverage atpackages/cli/src/whisper/transcribe.test.ts:25-42(floor / scale / cap / null-or-NaN) is exactly the right shape for a math clamp.getPreparedWavDurationSecondsfailure-mode collapses to the pre-PR 300_000ms floor atpackages/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:
extractAudioat:184-187—-vn -ar 16000 -ac 1 -f wavon the raw video.prepareAudioat:232-235—-ar 16000 -ac 1 -f wavre-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:
- 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).
- Add a short comment at
:187and:235explicitly 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
|
Resolved Via's current review blocker at
Both upstream paths now use a shared duration-aware audio-preparation timeout: 2-minute floor, 0.5× realtime scaling, and 6-hour cap. Verification on the current head:
Please re-review the current head when CI finishes. |
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
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 (500ms per media second) / 6h cap. Same shape asresolveWhisperTimeoutMsbut 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_000fits in[120_000, 21_600_000];24h × 500 = 43_200_000clamps down to21_600_000. Whisper boundaries at30.1s → 301_000msneedMath.ceil(30.1 × 10_000) = 301_000exactly — no float-precision surprise since30.1 × 10_000 = 301_000fits IEEE-754 double cleanly (in factMath.ceil(301000.00000000006) === 301001would blow this, but30.1 * 10000 = 301000here — 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 →getMediaDurationSecondsreturns null →resolveAudioPreparationTimeoutMs(null)→ floor. Preserves pre-PR 2-min behavior when ffprobe is unavailable. Same shape as R1'sgetPreparedWavDurationSecondsfallback. ✓ - All three timeout sites now duration-aware —
extractAudio(video source),prepareAudio(audio source),whisper(transcription). Sibling audit claim consistent with what's visible in the diff. ✓ - R1 concerns: the 🟢 note about
getPreparedWavDurationSecondshardcoding 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-topicresolve.mjsprettier reflow from R1 persists but is still harmless.
Concerns
- 🟢 Extra ffprobe call per transcribe invocation.
getMediaDurationSecondsis called once perextractAudio/prepareAudiocall. 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.
vanceingalls
left a comment
There was a problem hiding this comment.
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 minAUDIO_PREPARATION_TIMEOUT_PER_MEDIA_SECOND_MS = 500✓ 0.5× realtimeAUDIO_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_000remains at the ffmpeg/whisperexecFileSynctimeout slots (verified by greppingtranscribe.tsat head — onlytimeout: 30_000indetectLanguage, and twotimeout: 10_000inside the ffprobe metadata probes remain, all on fixed-work operations that don't scale with recording length; see next section). - Fallback shape.
getMediaDurationSecondsandgetPreparedWavDurationSecondsboth 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:
:18—timeout: 30_000insidedetectLanguage. Whisper--detect-languageprocesses only the first ~30s of audio internally (fixed work, not O(recording length)). Correctly not scaled.:100—timeout: 10_000insidegetMediaDurationSeconds(ffprobe metadata read). Fixed work.:251—timeout: 10_000insideisWav16kMono(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_000at:113,120_000at:123,300_000at:128/:192), all on whisper binary/model install/download paths. Setup, not transcription. Out of scope.parakeet.ts:138—timeout: 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
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 passedgit diff --check