Skip to content

fix(core): music keeps playing smoothly across video cuts in preview - #4939

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/music-steady-at-cuts
Oct 3, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
fix/music-steady-at-cuts

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

What

In Studio preview, a music bed now plays straight through video cuts. Before, when each shot of a sequence carried its own short sound clip (the shot's camera audio, faded in and out), the music jumped at every cut: a short gap and a skip back of 60 to 900 ms, depending on how slowly the next shot's sound started.

Export was never affected: the rendered audio of the fixture below is byte-identical on main and on this branch, and its music has no jump.

Why

While no Web Audio buffer plays, the preview playhead follows an audio element (the clock's tier 2). It followed the first in-window <audio> in page order. A sub-composition placed before the music in the page put its shot sounds first, so the playhead followed a 0.48 s shot sound instead of the music. At each cut the playhead moved to the next shot's sound, which starts late (seek, play, data). Since #4679 the playhead holds while the clip it follows catches up rather than stepping back, so it held for that delay. The music kept playing through the hold, drifted ahead of the playhead, and media sync then seeked it back. That seek is the jump you hear.

Related work

Refs #4679, whose rule that the playhead holds for the clip it follows is what turned a late shot sound into a music jump. That rule stays.

How

init.ts: the clock's audio pick moves into followedOrLongestRunningAudio. The clip the playhead already follows keeps it, as before. Otherwise the in-window clip that runs longest takes it (start plus resolveMediaElementDurationSeconds, the existing owner of a media element's timeline length), not the first in page order. With a music bed in the window, the bed leads and a cut hands nothing over. A clip that has ended is never picked, so a bed whose file runs out early hands the playhead to a clip still playing. The buffering freeze and the never-step-back rule are unchanged, but whenever nothing is followed yet (at Play, after a seek out of the window, at a handover) they now apply to the longest clip rather than the first in page order: with a voice-over under a longer music bed, the playhead follows the bed and the picture waits for the bed if it is still loading, while the voice is the clip media sync corrects. A clip with no data-duration ranks as open-ended until its metadata loads.

Test plan

  • Unit tests added/updated

  • Manual testing performed

  • Documentation updated (if applicable)

  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

  • audioClockSource.test.ts, new case: three 1 s shot sounds earlier in the page, each starting 250 ms late, and a 10 s music bed. Every frame the playhead must sit on the music's time, and the runtime must never write the music's currentTime after Play. It fails on main: the playhead lags the music by a frame from the start, and with that check removed main seeks the music from 0.30 s back to 0.05 s. Passes 3 runs in a row with the fix.

  • Two more cases pin the pick: a longer bed that starts late cannot take the playhead from a playing voice it already follows, and a bed whose file ended early hands it to the clip still playing. Removing either line in the pick fails its case.

  • Core runtime suite: 66 files, 1746 tests pass with the fix. tsc, oxlint, oxfmt, the comment checks and the dead-code audit pass.

  • Measured in Studio on a Linux box with headful Chrome, playing across the cuts with every media event and the music element logged per frame:

    • Fixture below, 9 cuts: main seeked the music 8 and 9 times while playing (jumps of 64 to 175 ms, playhead holds of 115 to 175 ms at cuts). This branch: 0 seeks, 0 jumps, no holds; the probe's per-frame gap between music and playhead stays under 70 ms on a heavily loaded box (main: up to 180 ms).
    • A longer real-world shape (9 shots of 0.48 s with sound, plus SFX, under a 62 s music bed): main seeked the music 8 to 9 times in 2.5 s, jumps up to 1.4 s. This branch: none at the cuts.
    • The recorded sound of the captures below: the music has a click every 0.25 s. On main, 10 of 26 click intervals are off by more than 12 ms (94 to 466 ms). On this branch, 0 of 21.

Independent review

No blocking or major findings. Three minor ones, all taken: an ended bed could win the pick (now skipped, with a test), the "followed clip keeps the playhead" rule had no test (added), and the behaviour change when nothing is followed is now stated above. One nit kept: && !el.loop in the ended check is redundant, since a looping element never reports ended.

Before

Studio on main (70dde41), a fixture built for this PR from generated test patterns and tones: ten 0.48 s shots in a sub-composition, each a muted <video> plus its own sound clip with a 40 ms fade, under one music bed (a rising tone with a click every quarter second) that starts with the first shot. Play from 00:00.4 to 00:06.2. The readout compares the music element's time with the playhead and counts the music's seeks during playback: 10 seeks, worst gap 203 ms, and the clicks stumble at the cuts.

cuts-before-70dde41b5.mp4

After

The same steps with this branch's change on the same main: 0 seeks, the music stays on the playhead, the clicks stay even.

cuts-after-70dde41b5.mp4

While no Web Audio buffer plays, the preview playhead follows an audio element. It followed the first
one in page order, so a sub-composition whose shots each carry a short sound clip took the playhead
away from the music bed. At every cut the next shot's sound starts late, the playhead waited for it,
the music played on, and media sync then pulled the music back: a jump at each cut. The followed clip
still keeps the playhead; otherwise the in-window clip that runs longest takes it.
…lip keeps it

A bed whose file ends before its authored length no longer wins the pick and leaves the playhead on wall
time. Pins the rule that a longer clip starting late cannot take the playhead from the followed one.
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 3, 2026 10:04

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

Approving at ff4ae940.

The fix. With no clip being followed, the playhead now picks the in-window clip that runs longest, not the first one in page order. A clip it already follows keeps it, and an ended clip is skipped. I ran audioClockSource, clock, clock-stall, clock-drift and media at this head: 229 pass. Against main's init.ts, the new cuts test fails. I also deleted each of the two pick lines (the ended skip and the followed early return), and each deletion fails its own test, as the body says.

The real seeks you asked about all behave the same as on main:

  • Scrubbing while playing. clock.seek doesn't drop the followed clip. A seek inside its window holds on el.seeking as before. A seek outside the window drops it at the window check, and the longest clip takes over.
  • Pausing. Pausing still detaches the source, so Play always starts a fresh pick.
  • Clips that need resyncing. That logic is untouched: every clip the playhead doesn't follow still goes through syncRuntimeMedia's hard, strict and force tiers.
  • Audio that starts mid-film. It can't take over from a clip that is already playing. Your "longer bed starts late" test covers this.
  • The paused-but-ready case. It still detaches without trying the next clip, exactly as the old break did.

Per-clip audio vs its own video. The cost of a late start moves from the music onto the shot sound. I recorded seeks in your cuts test: on main the runtime writes the music's currentTime, and at this head it writes the shot sounds' instead, pulling each one onto the playhead. The muted shot video follows the playhead, so each shot sound comes back into sync with its picture through the normal strict sync. It lags by its cold-start delay until then. On main, any shot sound placed after the bed in the page already got exactly this treatment, so it's not a new class of behaviour. If a shot ever carries dialogue, its first beat is where to listen. Your measurements only checked the music.

Non-blocking: a looping bed with no data-duration still loses the pick. resolveMediaElementDurationSeconds ranks it by one pass of its file, though its window is open-ended. I probed your cuts fixture with the bed as loop and no data-duration, a 2 s file, playing from 3 s. The playhead still holds 250 ms for each shot, the same as main. With data-duration="10" it holds 0 ms. Studio clips normally carry data-duration, so this is your call. The fix would be to rank a looping clip by its window end.

— Rames

@miguel-heygen
miguel-heygen force-pushed the fix/music-steady-at-cuts branch from 80708ab to ff4ae94 Compare October 3, 2026 10:29
@jrusso1020

Copy link
Copy Markdown
Collaborator

Correction to my review's non-blocking note on a looping bed with no data-duration: withdrawn. refreshRuntimeMediaCache (media.ts:141-146) windows such a clip to one pass of its file, so media sync stops it there and export matches. At 3 s my probe was past that pass, so no music should have been playing. The pick ranking by the same length media sync uses is right. My approval at ff4ae940 stands.

— Rames

@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown

Edit accuracy: accurate 1216 (base branch 1216), smooth 1064 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (1)

Unstable (1)

  • crop-none-pct-r0-nested-z50: tracking 0.05, pressJump 0, drop 40.07, reload 40.07, render 40.02, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0.07, reload 0.07, render 0.02, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0.07, reload 0.07, render 0.02, renderKey -, undo true, teleport true

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit b3c5207 Oct 3, 2026
201 of 217 checks passed
@miguel-heygen
miguel-heygen deleted the fix/music-steady-at-cuts branch October 3, 2026 11:08
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