fix(runtime): comprehensive audio stutter fix#707
Conversation
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.
vanceingalls
left a comment
There was a problem hiding this comment.
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
-
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?
-
The fix uses
el.tagName === "VIDEO" && !el.pausedto detect playing videos. Reason:!el.pauseddoesn't mean "actually advancing frames" — a video can be!pausedbut 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 whetherel.readyState >= HAVE_FUTURE_DATAshould also gate the skip. -
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 hasreadyState=0andnetworkState=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
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)
seekRuntimeTimelineaddedtimeline.pause()before everytotalTime()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 (
totalTimewithoutpause). The timeline is already paused once inplayer.play().seekRuntimeTimelinewithpause()remains only for standalone child timelines where explicit pause control is needed.2. Zero-duration clock cycle
player.play()removed the!tlguard (for the notion-preview fix), allowing play without a captured timeline. ButgetSafeTimelineDurationSeconds(null)returns0, 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-durationattribute.3. networkState timing flicker
Audio source attachment added a
networkState !== NETWORK_NO_SOURCEguard 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.errorguard (prevents errored audio from freezing the clock — needed for notion fix) but drop thenetworkStatecheck.Test plan
🤖 Generated with Claude Code