Description
Setting transport.loop = true before transport.start(time, offset) can cause an immediate wrap to loopStart instead of playing from the intended offset. This occurs after stop/start cycles when Clock._lastUpdate is stale.
Root Cause
Clock._loop() processes ticks in the range [_lastUpdate, now()]. After stop/start cycles:
_lastUpdate retains its value from the previous cycle
transport.start(time, offset) calls setTicksAtTime(ticks, time) on the TickSource
- For
tickTime values in the stale range (_lastUpdate to time), TickSource.forEachTickBetween generates ticks using previous accumulated state — not the new offset
- These "ghost ticks" can be
>= _loopEnd
- Since
_loop.get(tickTime) returns true (set before start), _processTick wraps to loopStart
- The intended offset is never reached
This also affects forEachAtTime event processing — ghost ticks can fire Transport.schedule() callbacks at past timeline positions, creating duplicate sources when starting mid-timeline.
Reproduction
Standalone HTML using Tone.js 15.1.22 from CDN. Rapid stop/start cycles with a small loop region reliably trigger the bug:
const transport = Tone.getTransport();
// Schedule some events (like a real app)
transport.schedule(() => {}, 0);
transport.schedule(() => {}, 3);
// Do 5 rapid start/stop cycles to accumulate stale TickSource state
for (let i = 0; i < 5; i++) {
transport.loopStart = 0;
transport.loopEnd = 0.1; // ~38 ticks at 120bpm/192ppq
transport.loop = true;
transport.start(Tone.now(), 0.05);
await new Promise(r => setTimeout(r, 20));
transport.stop();
transport.loop = false;
}
// 6th play — ghost ticks trigger wrap
transport.loopStart = 0;
transport.loopEnd = 0.1;
transport.loop = true;
transport.start(Tone.now(), 0.05); // Intended: play from 0.05s
// ACTUAL: first _processTick batch sees stale ticks=39 >= _loopEnd=38.4
// → wraps to loopStart=0 instead of playing from offset 0.05
Observed tick log (patching clock.callback):
tick #0: tickTime=31.327, ticks=39, _loop.get=true, _loopEnd=38.4 >>> WRAPS!
tick #1: tickTime=31.330, ticks=1 ← wrapped to loopStart
...
tick #12: tickTime=31.380, ticks=0 ← only NOW does the new start offset take effect
Key values: clock._lastUpdate=31.326 (stale), transport.start() called at 31.380. The range [31.327, 31.380] generates ghost ticks from accumulated TickSource state.
Workaround
Advance Clock._lastUpdate to the start time after transport.start(). This skips the stale tick range entirely — Clock._loop() only processes [startTime, now()], using the current play cycle's correct offset:
transport.loopStart = loopStart;
transport.loopEnd = loopEnd;
transport.loop = true; // Safe — ghost ticks are skipped
transport.start(Tone.now(), offset);
// Skip stale ticks from previous play cycle
transport._clock._lastUpdate = Tone.now();
Environment
- Tone.js 15.1.22
- Chrome 133, Safari, macOS
Description
Setting
transport.loop = truebeforetransport.start(time, offset)can cause an immediate wrap toloopStartinstead of playing from the intended offset. This occurs after stop/start cycles whenClock._lastUpdateis stale.Root Cause
Clock._loop()processes ticks in the range[_lastUpdate, now()]. After stop/start cycles:_lastUpdateretains its value from the previous cycletransport.start(time, offset)callssetTicksAtTime(ticks, time)on the TickSourcetickTimevalues in the stale range (_lastUpdatetotime),TickSource.forEachTickBetweengenerates ticks using previous accumulated state — not the new offset>= _loopEnd_loop.get(tickTime)returnstrue(set before start),_processTickwraps toloopStartThis also affects
forEachAtTimeevent processing — ghost ticks can fireTransport.schedule()callbacks at past timeline positions, creating duplicate sources when starting mid-timeline.Reproduction
Standalone HTML using Tone.js 15.1.22 from CDN. Rapid stop/start cycles with a small loop region reliably trigger the bug:
Observed tick log (patching
clock.callback):Key values:
clock._lastUpdate=31.326(stale),transport.start()called at31.380. The range[31.327, 31.380]generates ghost ticks from accumulated TickSource state.Workaround
Advance
Clock._lastUpdateto the start time aftertransport.start(). This skips the stale tick range entirely —Clock._loop()only processes[startTime, now()], using the current play cycle's correct offset:Environment