Skip to content

Fix looping sounds that specify a start time and duration - #9231

Merged
willeastcott merged 4 commits into
mainfrom
fix/looping-sound-with-start-time-and-duration
Aug 27, 2026
Merged

Fix looping sounds that specify a start time and duration#9231
willeastcott merged 4 commits into
mainfrom
fix/looping-sound-with-start-time-and-duration

Conversation

@willeastcott

Copy link
Copy Markdown
Contributor

Description

A SoundInstance (or sound slot) that has both a start time and a duration set does not loop — it plays a single iteration, fires end and stops.

Root cause

SoundInstance passed this._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, "including any whole or partial loop iterations" — so the source stopped at the end of the first iteration, onended fired, and _onEnded() turned that into an end event plus stop().

This only ever worked because the engine assigns source.loop after start() (the "moved to be after start() because of Chrome bug" comment), and Blink decided whether to honour the grain duration at start() time:

// third_party/blink/renderer/modules/webaudio/audio_buffer_source_handler.cc
// AudioBufferSourceHandler::ClampGrainParameters(), called from StartSource()
if (is_duration_given_ && Loop()) {
  end_time_ = start_time_ + grain_duration_;   // spec behaviour
} else {
  grain_duration_ = ClampTo(grain_duration_, 0.0, buffer_duration - grain_offset_);
}

With loop still false at start(), no end time was scheduled and the duration was effectively discarded. That accident is now gone everywhere:

Engine Behaviour
Firefox ≥ 84 (Nov 2020) honours the duration regardless of ordering (bug 1618225) — the original report
Chromium ≤ 149 legacy is_duration_given_ && Loop() gate — engine works by accident
Chromium ≥ 150 (branch 7871, stable 2026-06-30) handler rewrite applies the limit unconditionally in the render loop — engine breaks

which 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

  1. Only pass the duration to the source for non-looping instances (_startSource()). For a looping instance, loopStart/loopEnd already define the region to repeat, which every browser honours identically.
  2. Since a looping source is now started without a duration, disabling loop mid-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.
  3. Clamp loopEnd instead of wrapping it with a modulo — see below.

Second, independent bug fixed here

this.source.loopEnd = Math.max(this.source.loopStart, capTime(this._startTime + this._duration, buffer.duration));

capTime() is a modulo, so whenever startTime + duration landed on or past a multiple of the buffer duration the wrapped value collapsed onto loopStart. Both the spec and Blink's ResolveLoopPoints() treat loopEnd <= loopStart as "loop the whole buffer". Measured on a 4 s clip, startTime 1 / duration 3 ("loop the last 3 s"), startTime 2 / duration 2 and startTime 3 / duration 2 all 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):

  • Reproduced the ordering dependence directly — loop = true before start(0, 1, 2) stops after 2 s and fires onended; set after start() it loops forever.
  • With the fix, the loop region renders correctly for startTime/duration of 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.
  • Validated _stopAtEndOfLoopRegion() by suspending the render mid-playback: for startTime 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

  • The source.loop assignment is still left after start() — unchanged, since that ordering also relates to the volume/gain setup next to it.
  • The inverse transition (enabling loop mid-playback on an instance that was started non-looping with a duration) still stops at the duration, because the duration was baked into start(). 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.
  • Two adjacent pre-existing bugs found while investigating are deliberately not touched: resume() passes the full _duration rather than the remaining duration, so pause/resume extends a non-looping grain; and _updateCurrentTime() derives _currentTime from the absolute buffer offset while the currentTime setter treats the value as relative to _startTime, so currentTime is wrong unless startTime is an integer multiple of duration. Happy to file these separately.
  • No unit test added — there is no existing SoundInstance test and covering this needs a real (or offline) AudioContext rather than a mock. Say the word if you'd like a test harness for it.

Fixes #4712

Checklist

  • I have read the contributing guidelines
  • My code follows the project's coding standards
  • This PR focuses on a single change

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

Build size report

This PR changes the size of the minified bundles.

Bundle Minified Gzip Brotli
playcanvas.min.js 2387.7 KB (+1.0 KB, +0.04%) 613.6 KB (+0.2 KB, +0.04%) 476.1 KB (+0.4 KB, +0.08%)
playcanvas.min.mjs 2385.1 KB (+1.0 KB, +0.04%) 612.4 KB (+0.3 KB, +0.04%) 475.4 KB (+0.0 KB, +0.01%)

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

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 SoundInstance play, resume, pause, pitch, loop, and onended state 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 --check passed.
  • 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.

Comment thread src/platform/sound/instance.js Outdated
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: sound Sound related issue bugfix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audio doesn't loop on Firefox when Start Time and Duration are used

2 participants