Skip to content

Transport loop wrap on start: stale Clock._lastUpdate causes ghost ticks >= _loopEnd #1419

@naomiaro

Description

@naomiaro

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:

  1. _lastUpdate retains its value from the previous cycle
  2. transport.start(time, offset) calls setTicksAtTime(ticks, time) on the TickSource
  3. For tickTime values in the stale range (_lastUpdate to time), TickSource.forEachTickBetween generates ticks using previous accumulated state — not the new offset
  4. These "ghost ticks" can be >= _loopEnd
  5. Since _loop.get(tickTime) returns true (set before start), _processTick wraps to loopStart
  6. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions