fix(hyperframes-media): cap TTS synthesis concurrency instead of firing every line at once#1862
Conversation
…ng every line at once Two independent post-release feedback reports of the same mechanism from two different skills (both delegate to this one shared engine): audio.mjs fired every line's Kokoro TTS + whisper-transcribe subprocess concurrently via a bare Promise.all with no cap. - One OOM'd 12/13 lines on a resource-constrained laptop (32GB total, ~7GB free), requiring a manual patch to a sequential for-loop. - The other saw 7/8 lines fail on first run, then pass on retry once the model was cached — concurrent cold-start model loads overwhelming the machine, not a real synthesis failure. Kokoro/Whisper each load their own local model per subprocess, so firing every line at once multiplies that cost by the line count. Extracted the concurrency cap into lib/concurrency.mjs (audio.mjs is a script — it runs CLI/exit side effects on import, so it can't be unit-tested directly; the cap is small enough to pull out and test in isolation). Default 4, overridable via HYPERFRAMES_TTS_CONCURRENCY, floored at 1 (matching one report's own manual workaround). hyperframes-media/scripts/audio.mjs is the single canonical engine per its own header comment; product-launch-video, faceless-explainer, and pr-to-video each carry a thin wrapper that spawns this file as a subprocess (confirmed via their DEFAULT_ENGINE path), so this one fix covers all four skills without touching the other three. Tests: 4 new cases for mapWithConcurrency (order preserved regardless of completion order, cap actually enforced, limit > item count doesn't hang, empty input). Full skills test suite (514 tests) shows no new failures — the 444 pre-existing failures are environment-dependent and reproduce identically on unmodified main.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at b62826f92c (batch review, Group A TTS/audio; layering on Magi's own vetting).
Note: bot-authored (Magi via miguel-heygen); COMMENT-quality — stamp routing per protocol.
Summary — Extract mapWithConcurrency (default 4, HYPERFRAMES_TTS_CONCURRENCY override, floor 1) to bound concurrent Kokoro TTS + whisper-transcribe subprocess model loads; replace the unbounded Promise.all(lines.map(synthLine)) in audio.mjs.
Root-cause diagnosis is clean — Kokoro/Whisper load their own model per subprocess so unbounded fan-out multiplies memory + cold-start contention by line count. Preserving parallelism (not going sequential) while capping it is the right call. The extraction to lib/concurrency.mjs is well-justified since audio.mjs runs CLI side-effects on import and can't be unit-tested inline. Ordered-result preservation via index-slot + shared next counter is the canonical shape.
Concerns
- 🟠
lib/concurrency.mjs:6-11— no error handling onfn: if anysynthLine(items[i])throws,worker()rejects,Promise.allshort-circuits, and the OTHER in-flightworker()calls continue running detached (unawaited). The result is an unhandled rejection warning + orphan TTS/whisper subprocesses continuing to burn CPU after the outerplan()has already failed.synthLineinaudio.mjs:126-159currently catches its own errors and returnsnullon failure, so today this can't fire — but the moment anyone extendssynthLine(or reusesmapWithConcurrencyelsewhere), it becomes a subprocess-leak. Suggest wrapping theawait fn(...)intry/catchand storing the error result in-slot (or documenting "fn must never throw" in the JSDoc as a load-bearing contract). Non-blocking for this PR — the current caller is safe — but the extracted helper's contract is under-specified. - 🟡
audio.mjs:78— thettsConcurrencyvalue is read once at module top-level from env. If a caller wants per-invocation control (e.g. a wrapper skill running on a beefier machine), they can't override without setting env. Not a bug, just a signal that this may need a--tts-concurrencyCLI flag later.
Nits
- 🟡
lib/concurrency.mjs:11—Math.min(limit, items.length)gracefully handles limit > items (test coverage confirms), butitems.length === 0is an implicit fast-path:Math.min(limit, 0) === 0,Array.from({length: 0}, worker) === [],Promise.all([]) → []. Works but reads as "empty-input relies on emergent behavior across three primitives" — a one-linerif (items.length === 0) return [];at the top of the function would document intent. Test covers it, so this is preference not correctness.
Questions
- ↩️ Why 4? Kokoro model is ~350MB; whisper base is ~140MB. On a 32GB laptop with 7GB free (the first reporter's setup) that's ~2GB of steady-state per line at concurrency 4 = ~8GB peak — right at the boundary. Was 4 the value that empirically fit, or a "feels reasonable" pick? Not asking for a change, just curious if the reports informed it.
Coordination note — This PR is orthogonal to #1877 and #1845 (different files in the same skill, but the manifest hash change is the only overlap; whichever lands second gets a trivial rebase).
— Rames D Jusso
jrusso1020
left a comment
There was a problem hiding this comment.
APPROVE — verified CI green (0 fail / 0 pending) + no open CR at this head. Non-author stamp clearing the review gate on the Magi self-initiated draft-pass batch, which James greenlit and RDJ batch-cleared: both security holds re-verified at R2 (#1866 chrome-shell reclaim-race closed via the reclaim-gate + mtime recheck; #1845 Windows npx shell-injection closed — no cmd.exe, node <npx-cli.js> with pure argv), zero drift on the other nine, all green.
Merge via Magi's normal path (no admin-merge). Ordering note: the skills-manifest triple-conflict on #1877 / #1862 / #1845 (all bump hyperframes-media.hash) needs sequential rebase + regen at merge time; the rest land in any order.
— Rames Jusso
Root-caused from two independent post-release feedback reports of the same mechanism, hit via two different skills (which both delegate to this one shared engine — see below).
Root cause
audio.mjsfired every line's Kokoro TTS + whisper-transcribe subprocess concurrently via a barePromise.allwith no cap. Kokoro/Whisper each load their own local model per subprocess, so firing every line at once multiplies that cost by the line count — OOM on a constrained machine, or spurious failures from cold-start contention that "fix themselves" on retry once the model is warm.skills/hyperframes-media/scripts/audio.mjsis the single canonical engine per its own header comment ("ONE implementation of TTS + BGM + SFX for every video workflow... Workflows do NOT vendor a copy").product-launch-video,faceless-explainer, andpr-to-videoeach carry a thin wrapper that spawns this file as a subprocess (confirmed via theirDEFAULT_ENGINEpath resolving here) — so this one fix covers all four skills without touching the other three.Fix
Cap concurrency instead of removing it —
mapWithConcurrency(lines, ttsConcurrency, synthLine), default 4, overridable viaHYPERFRAMES_TTS_CONCURRENCY, floored at 1 (matching the first report's own manual workaround of going fully sequential).Extracted into
lib/concurrency.mjsrather than inlined:audio.mjsis a script that runs CLI/exit side effects on import, so it can't be unit-tested directly, but the cap itself is small and self-contained enough to pull out and test in isolation.Tests
4 new cases for
mapWithConcurrency(input order preserved regardless of completion order, the cap is actually enforced, a limit larger than the item count doesn't hang, empty input). Full skills test suite (514 tests) shows no new failures — the 444 pre-existing failures are environment-dependent and reproduce identically on unmodifiedmain.