Fix looping sounds that specify a start time and duration - #9231
Merged
willeastcott merged 4 commits intoAug 27, 2026
Conversation
SoundInstance passed its duration as the third argument to AudioBufferSourceNode.start() even when the instance was looping. Per the Web Audio spec that argument is a hard limit on total playback - the source stops after that many seconds of buffer content, whole and partial loop iterations included - so a looping instance with a start time and duration played a single iteration, fired 'end' and stopped. This only ever worked because the engine assigns source.loop after start(), and Blink decided whether to honour the grain duration at start() time (ClampGrainParameters: `is_duration_given_ && Loop()`), so with loop still false the duration was discarded. Firefox has honoured it regardless of ordering since FF84, and Chromium's handler rewrite in M150 applies the limit unconditionally in the render loop, so the workaround no longer holds anywhere. Only pass the duration to the source for non-looping instances; for a looping instance loopStart/loopEnd already define the region to repeat. Since a looping source is now started without a duration, disabling loop mid-playback schedules a stop at the end of the current iteration so the duration is still respected. Also clamp loopEnd instead of wrapping it with a modulo. Whenever startTime + duration landed on or past a multiple of the buffer duration the wrapped value collapsed onto loopStart, which the Web Audio API reads as 'loop the whole buffer' - so looping the last 3 seconds of a 4 second clip silently looped the entire clip. Fixes #4712 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build size reportThis PR changes the size of the minified bundles.
|
mvaligursky
reviewed
Aug 27, 2026
mvaligursky
left a comment
Contributor
There was a problem hiding this comment.
Automated PR review by Codex (GPT-5) at exact head eb28263204109ccb92350c76e4d760c7c7eed836.
The primary fix is well targeted: looping sources no longer receive a finite start() duration, and clamping loopEnd avoids the whole-buffer fallback when the previous modulo collapsed the loop points. I found one actionable issue in the new mid-playback loop-disable path.
Verification:
- Reviewed the complete PR diff and surrounding
SoundInstanceplay, resume, pause, pitch, loop, andonendedstate transitions. - Reproduced the changed behavior in a real Chromium
OfflineAudioContext: the basic start-time/duration loop remained active, but an off/on loop toggle still stopped at the previously scheduled deadline, and a post-schedule pitch change also stopped at the stale deadline. git diff --checkpassed.- All GitHub build, lint, type, unit-test, docs, example, API, size, and deployment checks are green.
The absence of a real-Web-Audio regression test currently leaves this state-transition case uncovered.
The stop scheduled when looping is disabled mid-playback is an absolute context-time deadline, and it stayed active regardless of what happened next. Re-enabling loop before the region ended left the old deadline in place, so the instance fell silent even though loop was true, and a pitch change after the deadline was set left it computed against the old playback rate, cutting the region short or long. Track the pending region stop and revisit it whenever loop or pitch changes: re-enabling loop pushes the stop out of reach (the Web Audio API can only replace a scheduled stop, not cancel it), and a pitch change recomputes the deadline against the new rate. The position the deadline is derived from is now tracked separately from _currentTime, which is capped to the duration of the instance rather than the buffer and so does not describe where in the buffer the source actually is. It is re-baselined on every pitch change so a rate change part way through an iteration is accounted for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
willeastcott
deleted the
fix/looping-sound-with-start-time-and-duration
branch
August 27, 2026 15:03
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
A
SoundInstance(or sound slot) that has both a start time and a duration set does not loop — it plays a single iteration, firesendand stops.Root cause
SoundInstancepassedthis._durationas the third argument toAudioBufferSourceNode.start()even when the instance was looping. Per the Web Audio spec that argument is a hard limit on total playback — the source stops after that many seconds of buffer content, "including any whole or partial loop iterations" — so the source stopped at the end of the first iteration,onendedfired, and_onEnded()turned that into anendevent plusstop().This only ever worked because the engine assigns
source.loopafterstart()(the "moved to be after start() because of Chrome bug" comment), and Blink decided whether to honour the grain duration atstart()time:With
loopstill false atstart(), no end time was scheduled and the duration was effectively discarded. That accident is now gone everywhere:is_duration_given_ && Loop()gate — engine works by accidentwhich matches the recent report on the issue that Chrome and Brave now fail too. I bisected the milestone branches to confirm 149 → 150 is the flip.
Fix
_startSource()). For a looping instance,loopStart/loopEndalready define the region to repeat, which every browser honours identically.loopmid-playback would let it run to the end of the buffer._stopAtEndOfLoopRegion()schedules a stop at the end of the current iteration so the duration is still respected.loopEndinstead of wrapping it with a modulo — see below.Second, independent bug fixed here
capTime()is a modulo, so wheneverstartTime + durationlanded on or past a multiple of the buffer duration the wrapped value collapsed ontoloopStart. Both the spec and Blink'sResolveLoopPoints()treatloopEnd <= loopStartas "loop the whole buffer". Measured on a 4 s clip,startTime 1 / duration 3("loop the last 3 s"),startTime 2 / duration 2andstartTime 3 / duration 2all looped the entire clip. This one is browser-independent and was simply masked by looping being broken anyway.Verification
Verified against a real Web Audio implementation using
OfflineAudioContext(4 s buffer with a distinct DC level per second, so the rendered samples identify which part of the buffer is playing):loop = truebeforestart(0, 1, 2)stops after 2 s and firesonended; set afterstart()it loops forever.startTime/durationof 1/1, 1/2, 1/3, 2/2, 3/2, 0/2 and 0/4, including the three cases that previously fell back to looping the whole buffer._stopAtEndOfLoopRegion()by suspending the render mid-playback: forstartTime 1, duration 2, disabling loop at t=3.5 s schedules the stop at exactly 4.0 s and every sampled output value matches the prediction.ESLint is clean on the changed file.
Notes for reviewers
source.loopassignment is still left afterstart()— unchanged, since that ordering also relates to the volume/gain setup next to it.loopmid-playback on an instance that was started non-looping with a duration) still stops at the duration, because the duration was baked intostart(). Fixing that would need the source to be recreated; it is the same root cause but a separate, much rarer case, so it is left alone here.resume()passes the full_durationrather than the remaining duration, so pause/resume extends a non-looping grain; and_updateCurrentTime()derives_currentTimefrom the absolute buffer offset while thecurrentTimesetter treats the value as relative to_startTime, socurrentTimeis wrong unlessstartTimeis an integer multiple ofduration. Happy to file these separately.SoundInstancetest and covering this needs a real (or offline)AudioContextrather than a mock. Say the word if you'd like a test harness for it.Fixes #4712
Checklist
🤖 Generated with Claude Code