Skip to content

fix(hyperframes-media): cap TTS synthesis concurrency instead of firing every line at once#1862

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/audio-tts-unbounded-concurrency
Jul 3, 2026
Merged

fix(hyperframes-media): cap TTS synthesis concurrency instead of firing every line at once#1862
miguel-heygen merged 1 commit into
mainfrom
fix/audio-tts-unbounded-concurrency

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

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).

"audio.mjs engine's Promise.all over all TTS+whisper-transcribe lines OOM'd 12/13 lines concurrently on a resource-constrained laptop (32GB total, ~7GB free) — had to patch to sequential for-loop locally to get all 13 lines to synthesize; worth a --concurrency flag or safer default."

"audio engine's Promise.all-parallel Kokoro TTS (8 lines via npx hyperframes tts subprocesses) failed 7/8 on first run, succeeded 8/8 on retry once the model was cached - looks like concurrent cold-start model loads overwhelm the machine; a concurrency cap or serialized first-line warmup would help."

Root cause

audio.mjs fired every line's Kokoro TTS + whisper-transcribe subprocess concurrently via a bare Promise.all with 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.mjs is 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, and pr-to-video each carry a thin wrapper that spawns this file as a subprocess (confirmed via their DEFAULT_ENGINE path 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 via HYPERFRAMES_TTS_CONCURRENCY, floored at 1 (matching the first report's own manual workaround of going fully sequential).

Extracted into lib/concurrency.mjs rather than inlined: audio.mjs is 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 unmodified main.

…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 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 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 on fn: if any synthLine(items[i]) throws, worker() rejects, Promise.all short-circuits, and the OTHER in-flight worker() calls continue running detached (unawaited). The result is an unhandled rejection warning + orphan TTS/whisper subprocesses continuing to burn CPU after the outer plan() has already failed. synthLine in audio.mjs:126-159 currently catches its own errors and returns null on failure, so today this can't fire — but the moment anyone extends synthLine (or reuses mapWithConcurrency elsewhere), it becomes a subprocess-leak. Suggest wrapping the await fn(...) in try/catch and 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 — the ttsConcurrency value 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-concurrency CLI flag later.

Nits

  • 🟡 lib/concurrency.mjs:11Math.min(limit, items.length) gracefully handles limit > items (test coverage confirms), but items.length === 0 is 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-liner if (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 jrusso1020 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.

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

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