Skip to content

fix(player): iPhone Safari keeps every take's sound after the first seconds of an edit - #6724

Merged
miguel-heygen merged 5 commits into
mainfrom
fix/ios-audio-activation
Oct 29, 2026
Merged

miguel-heygen merged 5 commits into
mainfrom
fix/ios-audio-activation

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

On iPhone Safari, an edit with one <audio> per take went silent from the first take that started more than about 5 s after the tap, for the rest of the edit. This makes every take start with sound for the whole edit.

Reported with traces by Robin Ebers (Attention Machine): https://gist.github.com/robinebers/d06818f0c2fae0f15e11b6decd1ae529 (problem 1).

Why it broke

iOS only lets an unmuted element start while the page still has user activation (about 5 s after the tap), unless that element was played inside a tap before. The runtime starts each take's <audio> when its window opens, so late takes get NotAllowedError. The autoplay fallback (#6417) then mutes the composition and moves sound to parent-frame Audio() copies that were never played in a tap either, so those are refused too. Desktop Chrome lets any element play after one tap, so it never showed.

Change

Inside the tap that starts playback, play-then-pause every idle timed media element once, in both places that own audio:

  • runtime transport.play() primes the composition's clips (primeMediaForLaterStarts)
  • the player's play() primes the parent-frame copies (ParentMediaManager.primeAll)

Only on iPhone and iPad (startsSoundOnlyInsideATap, one detector in mediaClock.ts shared by both), and not clips authored muted (muted attribute): those start freely, and Studio and desktop browsers keep their deferred loading untouched. Elements already sounding, already starting or without a usable source are skipped. It is one pass over the clips on play.

Checked

iOS 26.5 Simulator (iPhone 17 Pro), real Safari, one tap on Play, edits shaped like the report's (3 takes 24.8 s, 10 takes 53.1 s; one muted <video> and one <audio> per take on one MP4; fixture from tests/playbench):

Edit Build Refused play() calls Audio handed to parent
3 takes 0.8.210 480 (first: take 2 at 8.8 s) at 10.2 s
3 takes this branch 0 never
10 takes 0.8.210 1137 (first: take 1 at 7.6 s) at 8.2 s
10 takes this branch 0 never

fails without the fix: init.test.ts "primes a clip that starts later inside the tap on iOS, so a late start is not refused" and media.test.ts "plays then pauses every idle unmuted take so iOS lets it start after the tap" (both fail on a build with priming switched off; run on the branch and again with it disabled). parent-media.test.ts covers the parent-frame copies, and a case in each file shows nothing is primed off iOS or for muted clips.

Before

0.8.210 in the iOS 26.5 Simulator, 3-take edit after one tap: 480 refused plays, audio handed to the parent copies and muted.

Before: 0.8.210 in the iOS Simulator, 480 refused plays

After

This branch, same edit and tap: 0 refused plays, no ownership change, plays to the end.

After: this branch, 0 refused plays

Not in this PR

The picture stops at the start and at each cut (the playhead waits for a freshly started <audio>, problem 2 in the report) is separate and not fixed here. It is still measurable on this branch.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES · 1 blocker · 1 important · 0 nits · head a591248b

  • blocker (thread gate): this is a fix PR, but the body has no fails without the fix: line naming a test that fails on main. Add one, e.g. the new media.test.ts / parent-media.test.ts priming cases. Those fail on main only because primeMediaForLaterStarts / primeAll don't exist there, so if a regression test can pin the refused-play behavior instead, name that one.
Scope

Read all 8 files end to end. I checked the play/pause listeners that the priming events reach (webAudioTransport.ts:478-486, init.ts:3634 wake, video-source.ts), and they net out neutral. No prior reviews. CI was still running at review time (an earlier PR captures run on this head failed; it is being re-run).

— Genesis

Comment thread packages/core/src/runtime/media.ts Outdated
// Play-then-pause each idle element from the tap's own call stack to allow later starts.
export function primeMediaForLaterStarts(els: Iterable<HTMLMediaElement>): void {
for (const el of els) {
if (!el.paused || playRequested.has(el) || isUnplayable(el)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

important: this primes every idle clip on every platform, including muted <video>s. In Studio preview (defersMedia()), deferMediaUntilDue deliberately parks later clips at preload="none". A play() call starts their fetch, and the immediate pause() doesn't stop it. So the first Play in Desktop/Studio now downloads and opens a decoder for every take in the edit at once, which defeats the deferral and the release window. iOS only refuses unmuted starts, so at minimum skip el.muted elements. Consider also gating to the platform that needs this (touch WebKit) so desktop keeps the deferred loading.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES · 2 blocker · 0 important · 1 nit · a591248b → 2baef6c8

The code is fixed: priming is iOS-only, skips muted clips, and the fails without the fix: line is there. Two red checks on this head are caused by the PR:

  • Comments: packages/core/src/runtime/mediaClock.ts goes from 0/6 to 1/12 comment lines. Drop the startsSoundOnlyInsideATap doc line, or move it into the name.
  • PR captures: the two screenshots sit under ## Not in this PR. ## Before and ## After have only text, so the check still fails on the current body. Move each image into its section.

nit, not gating: skipping el.muted also skips clips muted only because the player is muted. After a muted autoplay, a tap on unmute (not play) primes nothing, and later takes can still be refused. Skipping defaultMuted (authored muted) instead would cover that. This came from my own earlier suggestion.

— Genesis

@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 149 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 (0)

Unstable (1)

  • seqplainkeys-none-pct-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE — 3 resolved, 0 open, 0 new · 2baef6c8 → ec8b253c. All runs are green. Windows engine-cli first failed in browserManager.test.ts BeginFrame probe, the known flake, which this diff doesn't touch; it passed on rerun (attempt 2).

— Genesis

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 48ba0e5 Oct 29, 2026
90 of 93 checks passed
@miguel-heygen
miguel-heygen deleted the fix/ios-audio-activation branch October 29, 2026 08:20
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.

1 participant