Skip to content

fix(runtime): comprehensive audio stutter fix#707

Merged
miguel-heygen merged 2 commits into
nextfrom
fix/audio-stutter-comprehensive
May 10, 2026
Merged

fix(runtime): comprehensive audio stutter fix#707
miguel-heygen merged 2 commits into
nextfrom
fix/audio-stutter-comprehensive

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

Summary

Fixes audio play/stop/play/stop stutter during Studio playback. Three root causes identified and fixed.

Root causes

1. Per-tick timeline.pause() (60x/sec)

seekRuntimeTimeline added timeline.pause() before every totalTime() seek. During transport-driven playback this runs 60 times per second. GSAP cascades pause events to child tweens and media elements on every frame → audio stutters.

Fix: Restore original inline seek for the captured root timeline (totalTime without pause). The timeline is already paused once in player.play(). seekRuntimeTimeline with pause() remains only for standalone child timelines where explicit pause control is needed.

2. Zero-duration clock cycle

player.play() removed the !tl guard (for the notion-preview fix), allowing play without a captured timeline. But getSafeTimelineDurationSeconds(null) returns 0, so the clock has no duration → immediately reaches end → stops → something restarts it → immediate stop again.

Fix: When no timeline provides duration, fall back to the root composition element's data-duration attribute.

3. networkState timing flicker

Audio source attachment added a networkState !== NETWORK_NO_SOURCE guard that could cause the TransportClock to flicker between audio-source timing and monotonic timing on transient media states — each switch causes a timing discontinuity that triggers media re-seeks.

Fix: Keep !rawEl.error guard (prevents errored audio from freezing the clock — needed for notion fix) but drop the networkState check.

Test plan

  • All 6 init tests pass (including "plays without captured root timeline when audio has failed")
  • All 29 player tests pass
  • Lint + typecheck + commitlint pass
  • Manual test: play apple presentation in Studio — audio should be smooth, no stutter

🤖 Generated with Claude Code

miguel-heygen and others added 2 commits May 10, 2026 19:48
Three changes that together caused audio play/stop/play/stop stutter
during transport-driven playback:

1. seekRuntimeTimeline called timeline.pause() before every totalTime()
   seek, 60x per second. GSAP cascades pause to media elements on every
   frame. Fix: restore original inline seek for the captured timeline
   (totalTime without pause). The timeline is already paused once in
   player.play(). seekRuntimeTimeline with pause() remains only for
   standalone child timelines.

2. player.play() removed the !tl guard, allowing play without a
   captured timeline. But getSafeTimelineDurationSeconds(null) returns
   0, so the clock has no duration → immediately reaches end → stops →
   restarts. Fix: when no timeline provides duration, fall back to the
   root composition element's data-duration attribute.

3. Audio source attachment added networkState guard that could cause
   the clock to flicker between audio-source and monotonic timing
   on transient media states. Fix: keep !rawEl.error guard (prevents
   errored audio from freezing the clock) but drop the networkState
   check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Seeking a playing video resets the browser's decoder pipeline, causing
a ~150ms freeze while it re-buffers. During that freeze the monotonic
clock advances, drift grows, and strict sync fires another seek —
creating a perpetual stutter loop (176 seek events / 8s observed on
the apple-presentation composition).

Skip strict and force drift corrections for playing video elements;
only hard sync (>0.5s catastrophic drift) warrants the decoder-reset
cost. Audio elements are unaffected and retain the full correction
tiers.

Also propagate the asset-loading overlay state to the timeline so
controls are disabled during "Preparing preview assets", matching the
existing behavior for the initial composition loading overlay.
@miguel-heygen miguel-heygen merged commit 3b66dc5 into next May 10, 2026
22 checks passed
@miguel-heygen miguel-heygen deleted the fix/audio-stutter-comprehensive branch May 10, 2026 20:43
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

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

Post-merge advisory review (this PR is already merged into next). Findings flagged for follow-up, not gating.

Verdict — Approve, 3 follow-ups. Strong runtime detective work.

The three root causes in this PR are all real and the analyses are precise: per-tick timeline.pause() cascading to media every frame, zero-duration clock cycle from removed !tl guard, and the strict-sync seek loop on playing video. Each fix is minimal and addresses the actual cause.

Follow-ups

  1. Skipping strict/force drift correction for playing video elements is a quality tradeoff. The fix: "only hard sync (>0.5s catastrophic drift) warrants the decoder-reset cost" for playing videos. Reason: this is correct for the apple-presentation stutter case, but it means playing videos can now accumulate up to 0.5s of drift before correction. For a 25-second composition that's fine; for a 5-minute deck with audio-locked timing, 0.5s of A/V drift on a video clip is perceptible. Worth a follow-up to confirm: do we have any compositions long enough to hit this? And does the strict-sync skip apply per-element or globally?

  2. The fix uses el.tagName === "VIDEO" && !el.paused to detect playing videos. Reason: !el.paused doesn't mean "actually advancing frames" — a video can be !paused but stalled (readyState too low). In that case the drift-correction skip still applies, and we may want a sync because the decoder isn't producing frames anyway. Worth checking whether el.readyState >= HAVE_FUTURE_DATA should also gate the skip.

  3. The audio buffering guard dropped networkState !== NETWORK_NO_SOURCE. Comment in the diff says "could cause the TransportClock to flicker between audio-source timing and monotonic timing on transient media states." That's a plausible cause for the flicker, but the new guard (!rawEl.error) is broader — any audio element without an error is now eligible to attach the audio source. Reason: this should be fine for the apple-presentation case (the failed audio has .error), but I'd want a test asserting: "if audio has readyState=0 and networkState=NETWORK_LOADING, we still freeze on the audio clock and don't fall through to monotonic." That assertion isn't explicit in the existing tests.

Important

  • Two distinct bug fixes in one PR (audio stutter + drift loop on playing video). The second commit was added after merge of the first. Reason: would have been cleaner as two PRs, but pragmatically these are both runtime-init.ts touching the same module so the cost of splitting is low. Worth flagging only because the PR title says "comprehensive audio stutter" but commit 2 is actually about video drift.

Nits

  • The 176-seek-events / 8s observation is gold for the PR body — would be even more useful as a numbered chart artifact in CI for future regression-watching.

Praise

  • All three root causes are causal, not symptomatic. "GSAP cascades pause to media elements on every frame," "getSafeTimelineDurationSeconds returns 0 with no captured timeline, so the clock has no duration → immediately reaches end," "seeking a playing video resets the decoder pipeline" — these are the explanations you want from runtime debugging.
  • Excellent test for the no-captured-timeline play path ("plays without captured root timeline when audio has failed"). That's the right kind of regression coverage for a subtle runtime invariant.

— Vai

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.

2 participants