Skip to content

fix(player): preview voice no longer goes silent while other tracks play - #6859

Merged
miguel-heygen merged 9 commits into
mainfrom
fix/short-decode-plays-from-element
Oct 30, 2026
Merged

miguel-heygen merged 9 commits into
mainfrom
fix/short-decode-plays-from-element

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

A voice track in the preview no longer goes silent while the rest of the film plays on. Two causes, one fix each:

  1. Not loaded yet. While playing, a video or audio clip that is due but has no data pauses everything, and the stage shows a "Buffering" card. Playback resumes on its own once every clip due at the playhead can play.
  2. Decodes short. An audio file whose decoded copy is shorter than the clip (a container timescale that disagrees with its stream) now plays from its <audio> element, so its last seconds are not silent.

Why

The runtime only noticed a waiting clip for video, and only to report it. An <audio> clip whose data had not arrived (a cold cache or a slow network) sat at readyState 1 while the playhead followed another track, so in a podcast the voice went quiet while the music bed and the picture kept going. Reproduced on a synthetic podcast (a 40 s music bed, three 12 s voice clips, one video) served at 60 kB/s with a cold cache: on main the first voice clip was due but had no data for about 11 s while the music played and the playhead kept moving, and nothing on the stage said why.

The second cause is narrower: the Web Audio transport plays a copy of each audio file made with decodeAudioData. When an MP4's track timescale disagrees with its AAC stream, decodeAudioData trusts the container and returns a copy that is short by the ratio of the two rates, while the element plays the whole file. A 4 s, 48 kHz AAC file whose mdhd timescale says 44.1 kHz decodes to 3.669 s in Chromium 156 and in WebKit 27.2; its element reports and plays 4 s. The transport's copy runs out 8% before the clip does and the rest of the clip is silent, which reads as a dropout. A re-mux rewrites the timescale and hides it.

How

Buffering

  • init.ts: each tick, after media sync, mediaNotReadyAt(t) lists clips due at the playhead (inside their window, not merely cued) that have no data ahead, skipping what Web Audio is already playing from a decoded copy. A clip has data ahead when it reports HAVE_FUTURE_DATA, or when it has at least its current frame and a buffered range runs 1 s past its position (or to its end): WebKit reports a paused element as HAVE_CURRENT_DATA however much it has buffered, so on readyState alone a hold there never ended. Running after media sync means a clip a slow frame skipped into has been asked to play, and so to fetch, before the transport holds for it. A clip counts only if the clock would also treat it as playable (isPlayableTrack, now shared with followedOrLongestRunningAudio): connected, not hidden, has a source, no error, not ended. If any are listed while playing, the transport halts the same way a pause does and remembers them; once none of them is still not ready, it plays again. A user pause during the hold clears it, so it does not resume by itself; Play during a hold stays in the hold. The transport loop does not park while holding.
  • The hold has no time limit, on purpose: the product rule for this is to stop everything and say Buffering rather than run on without a track. Chromium and WebKit both keep downloading a held clip (measured below), so a hold ends when its data arrives. iOS Safari is not measured here.
  • The state post carries buffering, and isPlaying stays true during the hold so hosts keep showing a playing transport.
  • Player: a buffering state shows the existing loading card titled "Buffering" (after the same 150 ms delay as "Loading assets", so a short hold does not flash it) and sets a buffering attribute on the element. assets-loading-ui="none" hides the card as before.
  • Export rendering is unchanged: it never plays the clock, so it never holds.
  • Not in this change: images. A still that has not decoded by the time it shows is not held for; only video and audio clips are.
  • hasFrozenPicture from feat(core): playback health reports how long a video on screen showed no moving picture #6856 is not reused: it reports a video below HAVE_CURRENT_DATA or one whose time stands still over a 1 s look, while the hold needs any clip, audio included, below HAVE_FUTURE_DATA.
  • Two audioClockSource tests described the old rule ("then lets the picture run on" after 3 s); they now assert that the playhead holds until data arrives, then plays on from where it held.

Short decode

fetchAndDecode, the one place a file becomes the transport's copy, compares the copy's length with the element's. A copy more than 0.25 s short is treated like an undecodable file: no copy, the source is not retried, and the clip stays on its element, which the runtime leaves audible when the transport does not own it. 0.25 s sits above AAC priming and padding: Apple's HE-AAC of the same tone decodes to 4.0 s under a 4.133 s element, and AAC-LC to 3.989 s in WebKit, and both keep their decoded copy. A copy is kept when the element's duration is not finite (streams, MediaRecorder recordings), since there is nothing to compare it with.

Test plan

  • Unit tests: init.buffering.test.ts "a track not ready pauses everything with Buffering, and resumes when it can play", "holds on the frame a slow tick skips into a clip with no data, after asking it to play", "resumes for a held clip with data buffered ahead that reports only its current frame, as WebKit's do" and "a pause while buffering stays paused once the track is ready"; player "shows Buffering while the runtime holds playback for a track, and hides it on resume"; webAudioTransport.test.ts "hands a clip that decodes short to its element, and keeps one short only by its priming".
  • fails without the fix: on main the buffering tests fail (expected 1.0166666666666657 to be less than 0.1: the playhead ran on for a second while the voice had no data) and the player test fails (no buffering attribute); with the check back before media sync the slow-tick test fails (expected { isPlaying: true, buffering: false } to deeply equal { isPlaying: true, buffering: true }); with readiness on readyState alone the WebKit-shaped test fails (expected 0.016666666666666666 to be greater than 0.9); with the Number.isFinite guard removed the short-decode test fails on the infinite-duration recording (expected null not to be null)
  • With the fix: init.buffering.test.ts, audioClockSource.test.ts and capturedMediaIdleRoute.test.ts pass 3 runs in a row (24/24); webAudioTransport.test.ts 3 runs (122/122); the runtime suite 2670/2670 and the player suite 575/575; oxfmt, oxlint and tsc (core and player) clean
  • Browser, Buffering: headless Chromium and WebKit (Linux), the synthetic podcast above, each file served at 60 kB/s from a cold cache, main against this branch built from the same main commit.
    • Chromium: voice silent while the music played 8.1 s on main, 0 on this branch; Buffering card never on main, 38.1 s in total on this branch. On main the video also stood on one frame from 1.5 s to the end while the audio and the playhead ran on; on this branch it plays through. The 40 s film took 44 s on main and 88 s on this branch, the time its files need to arrive at that rate (the video alone is above 60 kB/s, so it holds again every few seconds).
    • WebKit: an earlier revision of this branch held at 1.3 s for good (the readyState case above); this branch plays the film to its end in 48.8 s. Headless WebKit keeps the clips paused under its autoplay rules even on main, so its silence counts are not a measure here.
    • Unthrottled, 5 runs each in Chromium: main played 39.9 s with no playhead stop over 50 ms every time; this branch played 39.9 s with none in 3 runs, and had short stops (at most 70 ms, no card) in 2. WebKit unthrottled: 38.9 s with one 1 s stop at the start on both.
  • Browser, short decode: Linux, Chromium 156 and WebKit 27.2, a synthetic 1 kHz tone in six containers (AAC-LC 48 kHz; with the sample entry or the mdhd timescale at 44.1 kHz; HE-AAC with explicit SBR, with implicit SBR, and implicit with a 44.1 kHz sample entry). Every file plays at 1000 Hz through both decodeAudioData and the element, so none changes pitch; only the mdhd mismatch changes length (3.669 s in WebKit, 3.672 s in Chromium, against 4 s).
  • Documentation updated (not applicable)
  • Comments follow CONTRIBUTING.md "Comments"

Before

Main, Chromium, 60 kB/s: the voice drops out while the music plays on, the picture freezes at 1.5 s, and nothing on the stage says why.

buffering-before.mp4

After

This branch, same fixture: playback pauses with the Buffering card until every due clip has data, then plays on with all tracks.

buffering-after-v2.mp4

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 112 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-px-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 · 0 blocker · 1 important · 0 nits · head 023e86a5

The change only reaches the decode fallback (init.ts ~5934, after element capture fails). A short copy now takes the same path as an undecodable file, which stays audible on the element. The fails without the fix: line is present.

Scope / CI

I read the diff, fetchAndDecode, decodeKey, and the one caller in init.ts. decodeAudioData has no other runtime call site that feeds the transport. All required checks are green. Six Studio: edit accuracy shards failed with "Unable to download artifact" (infra) and passed on rerun.

@miguel-heygen
miguel-heygen force-pushed the fix/short-decode-plays-from-element branch from 023e86a to 4568017 Compare October 30, 2026 20:15
@miguel-heygen miguel-heygen changed the title fix(core): audio that decodes short plays from its element in preview fix(player): preview voice no longer goes silent while other tracks play Oct 30, 2026

@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 resolved, 0 open, 2 new (1 blocker, 1 important) · 023e86a → 4568017. My approval on 023e86a covered only the short-decode check; the Buffering hold is new.

  • blocker: the required Studio and player captures check fails. The PR touches packages/player, and the body has its two recordings but no ## Before / ## After headings. Put each video under its own heading; the edited event re-runs the check.
CI / scope

All other required jobs are green. Smoke: global install failed with an npm 404 on tslib-2.9.1.tgz: registry/infra, not required. I read the full diff since 023e86a, plus the tick order in init.ts around the hold (the hold check runs before syncMediaForCurrentState), the visibility/prefetch pass, and the player card.

Comment thread packages/core/src/runtime/init.ts Resolved

@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 — 2 resolved, 0 open, 0 new · 4568017 → 042bedc. All required checks are green at this head, including Studio and player captures. This approval covers 042bedcd only.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 443ded4 Oct 30, 2026
105 of 106 checks passed
@miguel-heygen
miguel-heygen deleted the fix/short-decode-plays-from-element branch October 30, 2026 21:40
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