Last active
March 3, 2026 00:20
-
-
Save naomiaro/407530c4635242694a1e3070aba3e365 to your computer and use it in GitHub Desktop.
Tone.js Transport loop wrap debug — standalone reproduction for Tonejs/Tone.js#1419
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
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>Tone.js Transport Loop Debug</title> | |
| <script src="https://unpkg.com/tone@15.1.22/build/Tone.js"></script> | |
| <style> | |
| body { font-family: monospace; padding: 20px; background: #1a1a1a; color: #ddd; } | |
| button { padding: 8px 16px; margin: 4px; font-size: 14px; cursor: pointer; } | |
| #log { white-space: pre-wrap; background: #111; padding: 16px; margin-top: 16px; border: 1px solid #333; max-height: 600px; overflow-y: auto; font-size: 12px; } | |
| .section { color: #63C75F; font-weight: bold; } | |
| .warn { color: #c49a6c; } | |
| .error { color: #d08070; } | |
| h3 { color: #63C75F; } | |
| </style> | |
| </head> | |
| <body> | |
| <h3>Tone.js Transport Loop Debug</h3> | |
| <p>Open DevTools console for raw output. Formatted output below.</p> | |
| <div> | |
| <button onclick="reproduceBug()" style="background:#d08070;color:#fff;font-weight:bold;padding:10px 20px;font-size:16px"> | |
| Reproduce Bug (one click) | |
| </button> | |
| </div> | |
| <hr style="border-color:#333;margin:12px 0"> | |
| <details> | |
| <summary style="cursor:pointer;color:#63C75F">Advanced: individual tests</summary> | |
| <div style="margin-top:8px"> | |
| <button onclick="initTone()">1. Init Tone (required first)</button> | |
| </div> | |
| <div> | |
| <button onclick="testLoopBeforeStart()">2a. loop=true BEFORE start (bare)</button> | |
| <button onclick="testLoopDeferred()">2b. Deferred loop=true (bare)</button> | |
| <button onclick="testLoopAfterStart()">2c. loop=true AFTER start (bare)</button> | |
| </div> | |
| <div> | |
| <button onclick="testWithSchedule(true)">3a. loop=true BEFORE + schedule()</button> | |
| <button onclick="testWithSchedule(false)">3b. Deferred + schedule()</button> | |
| </div> | |
| <div> | |
| <button onclick="testAppPattern()">4. Full app pattern (TonePlayout.play replica)</button> | |
| <button onclick="testStopStartCycle()">5. Stop/Start CYCLE</button> | |
| <button onclick="testRapidCycle()">6. Rapid cycles (stress)</button> | |
| <button onclick="testNearLoopEnd()">7. Near loopEnd (trigger wrap)</button> | |
| <button onclick="testFreshContext()">8. Fresh context (first play)</button> | |
| <button onclick="testFreshContextDeferred()">9. Fresh + deferred (fix)</button> | |
| <button onclick="testScheduleDoubleFire()">10. Schedule double-fire (ghost ticks fire past callbacks)</button> | |
| </div> | |
| </details> | |
| <div> | |
| <button onclick="stopTransport()">Stop Transport</button> | |
| <button onclick="clearLog()">Clear Log</button> | |
| </div> | |
| <div> | |
| Loop region: <input id="loopStart" value="2" size="4">s to <input id="loopEnd" value="5" size="4">s | |
| Start offset: <input id="offset" value="3" size="4">s | |
| </div> | |
| <div id="log"></div> | |
| <script> | |
| const logEl = document.getElementById('log'); | |
| let tickCount = 0; | |
| let origProcessTick = null; | |
| function log(msg, cls) { | |
| const line = document.createElement('span'); | |
| if (cls) line.className = cls; | |
| line.textContent = msg + '\n'; | |
| logEl.appendChild(line); | |
| logEl.scrollTop = logEl.scrollHeight; | |
| console.log(msg); | |
| } | |
| function clearLog() { | |
| while (logEl.firstChild) logEl.removeChild(logEl.firstChild); | |
| } | |
| function getInputs() { | |
| return { | |
| loopStart: parseFloat(document.getElementById('loopStart').value), | |
| loopEnd: parseFloat(document.getElementById('loopEnd').value), | |
| offset: parseFloat(document.getElementById('offset').value), | |
| }; | |
| } | |
| function patchProcessTick() { | |
| var transport = Tone.getTransport(); | |
| var clock = transport._clock; | |
| // Patch the Clock's callback, not transport._processTick (which is bound at construction) | |
| if (!origProcessTick) { | |
| origProcessTick = clock.callback; | |
| } | |
| tickCount = 0; | |
| clock.callback = function(tickTime, ticks) { | |
| if (tickCount < 15) { | |
| var loopVal = transport._loop.get(tickTime); | |
| var wouldWrap = loopVal && ticks >= transport._loopEnd; | |
| var msg = ' tick #' + tickCount + ': tickTime=' + tickTime.toFixed(6) + | |
| ', ticks=' + ticks + | |
| ', _loop.get=' + loopVal + | |
| ', _loopEnd=' + transport._loopEnd + | |
| ', _loopStart=' + transport._loopStart + | |
| (wouldWrap ? ' >>> WRAPS!' : ''); | |
| log(msg, wouldWrap ? 'error' : ''); | |
| tickCount++; | |
| } | |
| return origProcessTick(tickTime, ticks); | |
| }; | |
| } | |
| function dumpLoopTimeline(prefix) { | |
| const transport = Tone.getTransport(); | |
| const entries = []; | |
| transport._loop._timeline._timeline.forEach(function(e) { | |
| entries.push(prefix + ' t=' + e.time.toFixed(6) + ' val=' + e.value); | |
| }); | |
| log(prefix + '_loop timeline (' + entries.length + ' entries):'); | |
| entries.forEach(function(e) { log(e); }); | |
| } | |
| function logTransportState(label) { | |
| const transport = Tone.getTransport(); | |
| const ctx = Tone.getContext(); | |
| log(label, 'section'); | |
| log(' now()=' + transport.now().toFixed(6) + | |
| ' (ctx.currentTime=' + ctx.currentTime.toFixed(6) + | |
| ' + lookAhead=' + ctx.lookAhead.toFixed(4) + ')'); | |
| log(' transport.state=' + transport.state); | |
| log(' transport.loop=' + transport.loop); | |
| log(' transport.loopStart=' + transport.loopStart + ' (' + transport._loopStart + ' ticks)'); | |
| log(' transport.loopEnd=' + transport.loopEnd + ' (' + transport._loopEnd + ' ticks)'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| log(' transport.bpm=' + transport.bpm.value + ', ppq=' + transport._ppq); | |
| log(' clock._lastUpdate=' + transport._clock._lastUpdate.toFixed(6)); | |
| dumpLoopTimeline(' '); | |
| } | |
| // One-click reproduction: inits Tone, warms up context, runs rapid cycles | |
| async function reproduceBug() { | |
| clearLog(); | |
| log('=== ONE-CLICK BUG REPRODUCTION ===', 'section'); | |
| log('Initializing Tone.js...\n'); | |
| await Tone.start(); | |
| log('Bug: after stop/start cycles, Clock._lastUpdate is stale.'); | |
| log('First tick batch generates "ghost ticks" from previous TickSource state.'); | |
| log('If ghost ticks >= _loopEnd AND _loop.get()=true → immediate wrap.'); | |
| log(''); | |
| log('Warming up AudioContext (3s) to create larger _lastUpdate gaps...'); | |
| // Warm-up: the bug requires a gap between _lastUpdate and now(). | |
| // With a fresh context, _lastUpdate stays close to now(). After the | |
| // context has been running, stop/start cycles create larger gaps. | |
| var transport = Tone.getTransport(); | |
| transport.start(Tone.now(), 0); | |
| await new Promise(function(r) { setTimeout(r, 1000); }); | |
| transport.stop(); | |
| await new Promise(function(r) { setTimeout(r, 1000); }); | |
| transport.start(Tone.now(), 0); | |
| await new Promise(function(r) { setTimeout(r, 500); }); | |
| transport.stop(); | |
| await new Promise(function(r) { setTimeout(r, 500); }); | |
| log('Warm-up done. context.currentTime=' + Tone.getContext().currentTime.toFixed(2) + 's\n'); | |
| // Set small loop region so stale ticks easily cross _loopEnd | |
| document.getElementById('loopStart').value = '0'; | |
| document.getElementById('loopEnd').value = '0.1'; | |
| document.getElementById('offset').value = '0.05'; | |
| log('Settings: loopStart=0, loopEnd=0.1s (~38 ticks), offset=0.05s'); | |
| log('Running 5 rapid stop/start cycles, then checking 6th play for ghost ticks...\n'); | |
| origProcessTick = null; | |
| await testRapidCycle(); | |
| } | |
| async function initTone() { | |
| await Tone.start(); | |
| log('Tone.js initialized', 'section'); | |
| logTransportState('Initial state:'); | |
| } | |
| function stopTransport() { | |
| const transport = Tone.getTransport(); | |
| transport.stop(); | |
| transport.loop = false; | |
| log('Transport stopped, loop disabled', 'warn'); | |
| } | |
| async function testLoopBeforeStart() { | |
| const inputs = getInputs(); | |
| const transport = Tone.getTransport(); | |
| log('\n=== TEST: loop=true BEFORE transport.start() ===', 'section'); | |
| if (transport.state !== 'stopped') { | |
| transport.stop(); | |
| log('Stopped transport first'); | |
| } | |
| patchProcessTick(); | |
| log('\nSetting loopStart=' + inputs.loopStart + ', loopEnd=' + inputs.loopEnd + '...'); | |
| var nowBefore = transport.now(); | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| log(' now() at set boundaries: ' + nowBefore.toFixed(6)); | |
| log(' _loopStart=' + transport._loopStart + ' ticks, _loopEnd=' + transport._loopEnd + ' ticks'); | |
| log('\nSetting loop=true...'); | |
| var nowAtLoop = transport.now(); | |
| transport.loop = true; | |
| log(' now() at loop=true: ' + nowAtLoop.toFixed(6)); | |
| logTransportState('State before start:'); | |
| log('\nCalling transport.start(now(), ' + inputs.offset + ')...'); | |
| var startNow = Tone.now(); | |
| transport.start(startNow, inputs.offset); | |
| log(' startTime (now())=' + startNow.toFixed(6)); | |
| log(' offset=' + inputs.offset + 's = ' + transport.toTicks(inputs.offset) + ' ticks'); | |
| logTransportState('State after start:'); | |
| log('\nWaiting for ticks...'); | |
| setTimeout(function() { | |
| log('\nAfter 200ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| log(' transport.state=' + transport.state); | |
| transport.stop(); | |
| transport.loop = false; | |
| log('Stopped.'); | |
| }, 200); | |
| } | |
| async function testLoopDeferred() { | |
| var inputs = getInputs(); | |
| var transport = Tone.getTransport(); | |
| log('\n=== TEST: loop=false before start, deferred loop=true ===', 'section'); | |
| if (transport.state !== 'stopped') { | |
| transport.stop(); | |
| log('Stopped transport first'); | |
| } | |
| patchProcessTick(); | |
| log('\nSetting loopStart=' + inputs.loopStart + ', loopEnd=' + inputs.loopEnd + ', loop=false...'); | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| var nowAtFalse = transport.now(); | |
| transport.loop = false; | |
| log(' now() at loop=false: ' + nowAtFalse.toFixed(6)); | |
| log(' _loopStart=' + transport._loopStart + ' ticks, _loopEnd=' + transport._loopEnd + ' ticks'); | |
| logTransportState('State before start:'); | |
| log('\nCalling transport.start(now(), ' + inputs.offset + ')...'); | |
| var startNow = Tone.now(); | |
| transport.start(startNow, inputs.offset); | |
| log(' startTime=' + startNow.toFixed(6)); | |
| logTransportState('State after start:'); | |
| log('\nDeferring loop=true via setTimeout(0)...'); | |
| setTimeout(function() { | |
| var nowAtDeferred = transport.now(); | |
| transport.loop = true; | |
| log('\nDeferred loop=true set at now()=' + nowAtDeferred.toFixed(6), 'warn'); | |
| dumpLoopTimeline(' '); | |
| setTimeout(function() { | |
| log('\nAfter 200ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| transport.stop(); | |
| transport.loop = false; | |
| log('Stopped.'); | |
| }, 200); | |
| }, 0); | |
| } | |
| // Track schedule IDs so we can clear them between tests | |
| var scheduleIds = []; | |
| function clearSchedules() { | |
| var transport = Tone.getTransport(); | |
| scheduleIds.forEach(function(id) { | |
| try { transport.clear(id); } catch(e) {} | |
| }); | |
| scheduleIds = []; | |
| } | |
| // Simulate what ToneTrack does: schedule permanent events for clips | |
| function addScheduleEvents() { | |
| var transport = Tone.getTransport(); | |
| clearSchedules(); | |
| // Simulate clips at various positions (like a real multi-clip track): | |
| // Clip A: 0s-3s, Clip B: 3s-6s, Clip C: 6s-9s | |
| var clipStarts = [0, 3, 6]; | |
| clipStarts.forEach(function(t) { | |
| var id = transport.schedule(function(time) { | |
| log(' [schedule] fired at transportTime=' + t + 's, audioCtxTime=' + time.toFixed(6), 'warn'); | |
| }, t); | |
| scheduleIds.push(id); | |
| log(' Scheduled event at ' + t + 's (id=' + id + ')'); | |
| }); | |
| } | |
| // Test 3a/3b: loop with Transport.schedule() events | |
| async function testWithSchedule(loopBeforeStart) { | |
| var inputs = getInputs(); | |
| var transport = Tone.getTransport(); | |
| var label = loopBeforeStart ? 'loop=true BEFORE start' : 'deferred loop=true'; | |
| log('\n=== TEST: ' + label + ' + Transport.schedule() ===', 'section'); | |
| if (transport.state !== 'stopped') { | |
| transport.stop(); | |
| log('Stopped transport first'); | |
| } | |
| patchProcessTick(); | |
| // Add schedule events (like ToneTrack constructor) | |
| log('\nAdding Transport.schedule() events...'); | |
| addScheduleEvents(); | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| if (loopBeforeStart) { | |
| transport.loop = true; | |
| log('Set loop=true BEFORE start'); | |
| } else { | |
| transport.loop = false; | |
| log('Set loop=false (will defer)'); | |
| } | |
| logTransportState('State before start:'); | |
| var startNow = Tone.now(); | |
| transport.start(startNow, inputs.offset); | |
| log('\nStarted at offset=' + inputs.offset + ', now=' + startNow.toFixed(6)); | |
| logTransportState('State after start:'); | |
| if (!loopBeforeStart) { | |
| setTimeout(function() { | |
| transport.loop = true; | |
| log('\nDeferred loop=true set', 'warn'); | |
| dumpLoopTimeline(' '); | |
| setTimeout(function() { | |
| log('\nAfter 200ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| transport.stop(); | |
| transport.loop = false; | |
| log('Stopped.'); | |
| }, 200); | |
| }, 0); | |
| } else { | |
| setTimeout(function() { | |
| log('\nAfter 200ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| transport.stop(); | |
| transport.loop = false; | |
| log('Stopped.'); | |
| }, 200); | |
| } | |
| } | |
| // Test 4: Full replica of TonePlayout.play() sequence | |
| async function testAppPattern() { | |
| var inputs = getInputs(); | |
| var transport = Tone.getTransport(); | |
| log('\n=== TEST: Full TonePlayout.play() replica ===', 'section'); | |
| // Step 1: Conditional stop (like TonePlayout.play lines 161-163) | |
| if (transport.state !== 'stopped') { | |
| transport.stop(); | |
| log('Step 1: transport.stop() (was running)'); | |
| } else { | |
| log('Step 1: transport already stopped'); | |
| } | |
| patchProcessTick(); | |
| // Step 2: Add schedule events (like ToneTrack constructor does) | |
| log('\nStep 2: Adding Transport.schedule() events...'); | |
| addScheduleEvents(); | |
| // Step 3: Also add a scheduleOnce for completion (like TonePlayout does) | |
| var completionId = transport.scheduleOnce(function() { | |
| log(' [scheduleOnce] completion event fired', 'warn'); | |
| }, inputs.loopEnd + 2); // beyond loop end | |
| log(' Added scheduleOnce completion at ' + (inputs.loopEnd + 2) + 's (id=' + completionId + ')'); | |
| // Step 4: Set loop boundaries BEFORE loop enable (like TonePlayout.play lines 173-175) | |
| log('\nStep 4: Setting loop boundaries...'); | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| transport.loop = false; | |
| log(' loopStart=' + inputs.loopStart + ', loopEnd=' + inputs.loopEnd + ', loop=false'); | |
| logTransportState('State before start:'); | |
| // Step 5: transport.start with offset (like TonePlayout.play lines 177-181) | |
| log('\nStep 5: transport.start(now(), ' + inputs.offset + ')...'); | |
| var startNow = Tone.now(); | |
| transport.start(startNow, inputs.offset); | |
| log(' startTime=' + startNow.toFixed(6)); | |
| logTransportState('State after start:'); | |
| // Step 6: Deferred loop enable (like TonePlayout.play lines 193-202) | |
| log('\nStep 6: Deferring loop=true via setTimeout(0)...'); | |
| setTimeout(function() { | |
| var nowAtDeferred = transport.now(); | |
| transport.loop = true; | |
| log('\nDeferred loop=true set at now()=' + nowAtDeferred.toFixed(6), 'warn'); | |
| dumpLoopTimeline(' '); | |
| // Step 7: Check state after 200ms | |
| setTimeout(function() { | |
| log('\nAfter 200ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| log(' transport.state=' + transport.state); | |
| transport.stop(); | |
| transport.loop = false; | |
| clearSchedules(); | |
| log('Stopped and cleared schedules.'); | |
| }, 200); | |
| }, 0); | |
| } | |
| // Test 5: Stop/start cycle — the most realistic reproduction attempt | |
| // Simulates: play with loop → stop → play with loop again | |
| async function testStopStartCycle() { | |
| var inputs = getInputs(); | |
| var transport = Tone.getTransport(); | |
| log('\n=== TEST 5: Stop/Start CYCLE with loop ===', 'section'); | |
| log('Simulates: play(offset=3, loop=true) → stop → play(offset=3, loop=true)'); | |
| // Clean slate | |
| if (transport.state !== 'stopped') { | |
| transport.stop(); | |
| } | |
| clearSchedules(); | |
| // Add scheduled events (like ToneTrack) | |
| addScheduleEvents(); | |
| // ---- FIRST PLAY (no deferred, like buggy code) ---- | |
| log('\n--- First play (loop=true before start) ---', 'section'); | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| transport.loop = true; | |
| var startNow1 = Tone.now(); | |
| transport.start(startNow1, inputs.offset); | |
| log('Started at offset=' + inputs.offset + ', now=' + startNow1.toFixed(6)); | |
| log(' ticks=' + transport.ticks + ', seconds=' + transport.seconds.toFixed(6)); | |
| dumpLoopTimeline(' '); | |
| // Let it play for 150ms | |
| await new Promise(function(resolve) { setTimeout(resolve, 150); }); | |
| log('\nAfter 150ms of playing:'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| // ---- STOP ---- | |
| log('\n--- Stopping transport ---', 'section'); | |
| transport.stop(); | |
| transport.loop = false; | |
| log('Stopped. transport.state=' + transport.state); | |
| log('Set loop=false'); | |
| dumpLoopTimeline(' '); | |
| // Brief pause (like user thinking) | |
| await new Promise(function(resolve) { setTimeout(resolve, 50); }); | |
| // ---- SECOND PLAY (the bug scenario) ---- | |
| log('\n--- Second play (loop=true before start — BUG?) ---', 'section'); | |
| patchProcessTick(); | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| transport.loop = true; | |
| logTransportState('State before second start:'); | |
| var startNow2 = Tone.now(); | |
| transport.start(startNow2, inputs.offset); | |
| log('\nStarted at offset=' + inputs.offset + ', now=' + startNow2.toFixed(6)); | |
| logTransportState('State after second start:'); | |
| log('\nWaiting for ticks...'); | |
| setTimeout(function() { | |
| log('\nAfter 200ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| log(' transport.state=' + transport.state); | |
| transport.stop(); | |
| transport.loop = false; | |
| clearSchedules(); | |
| log('Stopped and cleared.'); | |
| }, 200); | |
| } | |
| // Test 6: Rapid stop/start (stress test) | |
| async function testRapidCycle() { | |
| var inputs = getInputs(); | |
| var transport = Tone.getTransport(); | |
| log('\n=== TEST 6: RAPID stop/start cycles ===', 'section'); | |
| clearSchedules(); | |
| addScheduleEvents(); | |
| // Do 5 rapid start/stop cycles to accumulate state | |
| for (var i = 0; i < 5; i++) { | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| transport.loop = true; | |
| transport.start(Tone.now(), inputs.offset); | |
| await new Promise(function(resolve) { setTimeout(resolve, 20); }); | |
| transport.stop(); | |
| transport.loop = false; | |
| log('Cycle ' + (i+1) + ': start→stop (20ms)'); | |
| } | |
| dumpLoopTimeline('After 5 cycles: '); | |
| log('_loop timeline entries accumulated: check for stale values'); | |
| // Now the 6th play — does it break? | |
| log('\n--- 6th play (after 5 rapid cycles) ---', 'section'); | |
| patchProcessTick(); | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| transport.loop = true; | |
| logTransportState('State before start:'); | |
| var startNow = Tone.now(); | |
| transport.start(startNow, inputs.offset); | |
| log('\nStarted at offset=' + inputs.offset + ', now=' + startNow.toFixed(6)); | |
| logTransportState('State after start:'); | |
| setTimeout(function() { | |
| log('\nAfter 200ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| transport.stop(); | |
| transport.loop = false; | |
| clearSchedules(); | |
| log('Stopped and cleared.'); | |
| }, 200); | |
| } | |
| // Test 7: Play near loopEnd, stop, replay — should trigger stale-tick wrapping | |
| async function testNearLoopEnd() { | |
| var transport = Tone.getTransport(); | |
| log('\n=== TEST 7: Play near loopEnd → stop → replay ===', 'section'); | |
| log('Goal: stale ticks from _lastUpdate range >= _loopEnd → triggers wrap'); | |
| clearSchedules(); | |
| addScheduleEvents(); | |
| // Loop region 2s-5s. Play from 4.5s (near loop end) | |
| var loopStart = 2; | |
| var loopEnd = 5; | |
| var offset = 4.5; | |
| transport.loopStart = loopStart; | |
| transport.loopEnd = loopEnd; | |
| transport.loop = true; | |
| // First play: start near loopEnd, let it play 600ms to accumulate ticks past loopEnd | |
| var startNow1 = Tone.now(); | |
| transport.start(startNow1, offset); | |
| log('First play: offset=' + offset + 's, now=' + startNow1.toFixed(6)); | |
| log(' _loopEnd=' + transport._loopEnd + ' ticks (' + loopEnd + 's)'); | |
| log(' start ticks=' + transport.toTicks(offset) + ' (' + offset + 's)'); | |
| // Let it play long enough for ticks to reach/pass _loopEnd naturally (loop wraps back) | |
| await new Promise(function(resolve) { setTimeout(resolve, 600); }); | |
| log('\nAfter 600ms:'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| // Stop | |
| transport.stop(); | |
| transport.loop = false; | |
| log('\nStopped. State:'); | |
| log(' clock._lastUpdate=' + transport._clock._lastUpdate.toFixed(6)); | |
| dumpLoopTimeline(' '); | |
| // Brief pause | |
| await new Promise(function(resolve) { setTimeout(resolve, 50); }); | |
| // Second play — THIS should expose the stale-tick wrap | |
| log('\n--- Second play (loop=true before start) ---', 'section'); | |
| patchProcessTick(); | |
| transport.loopStart = loopStart; | |
| transport.loopEnd = loopEnd; | |
| transport.loop = true; | |
| logTransportState('Before second start:'); | |
| var startNow2 = Tone.now(); | |
| transport.start(startNow2, offset); | |
| log('\nStarted at offset=' + offset + ', now=' + startNow2.toFixed(6)); | |
| logTransportState('After second start:'); | |
| setTimeout(function() { | |
| log('\nAfter 300ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| transport.stop(); | |
| transport.loop = false; | |
| clearSchedules(); | |
| log('Stopped and cleared.'); | |
| }, 300); | |
| } | |
| // Test 8: Fresh AudioContext — mimics first play in real app | |
| // In the app: await engine.init() (Tone.start) → immediately engine.play() | |
| async function testFreshContext() { | |
| log('\n=== TEST 8: Fresh context → immediate loop+start ===', 'section'); | |
| log('Simulates first play in app: Tone.start() → loop → start with minimal gap'); | |
| // Dispose current context and create fresh one | |
| var oldCtx = Tone.getContext(); | |
| log('Old context currentTime=' + oldCtx.currentTime.toFixed(6) + ', state=' + oldCtx.state); | |
| // Create fresh Tone.js context (simulates app startup) | |
| log('\nCreating fresh context...'); | |
| var newCtx = new Tone.Context(); | |
| Tone.setContext(newCtx); | |
| log('New context state=' + newCtx.state + ', currentTime=' + newCtx.currentTime.toFixed(6)); | |
| // Resume context (like Tone.start() does) | |
| await Tone.start(); | |
| log('After Tone.start(): state=' + newCtx.state + ', currentTime=' + newCtx.currentTime.toFixed(6)); | |
| var transport = Tone.getTransport(); | |
| // Reset monkey-patch for new transport | |
| origProcessTick = null; | |
| patchProcessTick(); | |
| // Add schedule events | |
| clearSchedules(); | |
| addScheduleEvents(); | |
| // IMMEDIATELY set loop and start (minimal gap after Tone.start) | |
| var inputs = getInputs(); | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| transport.loop = true; | |
| log('\nImmediate: loop=true, boundaries set'); | |
| logTransportState('State before start:'); | |
| var startNow = Tone.now(); | |
| transport.start(startNow, inputs.offset); | |
| log('\ntransport.start(now=' + startNow.toFixed(6) + ', offset=' + inputs.offset + ')'); | |
| logTransportState('State after start:'); | |
| setTimeout(function() { | |
| log('\nAfter 300ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| transport.stop(); | |
| transport.loop = false; | |
| clearSchedules(); | |
| log('Stopped.'); | |
| }, 300); | |
| } | |
| // Test 9: Same as 8 but with deferred loop (the fix) | |
| async function testFreshContextDeferred() { | |
| log('\n=== TEST 9: Fresh context → deferred loop (fix) ===', 'section'); | |
| var newCtx = new Tone.Context(); | |
| Tone.setContext(newCtx); | |
| await Tone.start(); | |
| log('Fresh context, state=' + newCtx.state + ', currentTime=' + newCtx.currentTime.toFixed(6)); | |
| var transport = Tone.getTransport(); | |
| origProcessTick = null; | |
| patchProcessTick(); | |
| clearSchedules(); | |
| addScheduleEvents(); | |
| var inputs = getInputs(); | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| transport.loop = false; | |
| var startNow = Tone.now(); | |
| transport.start(startNow, inputs.offset); | |
| log('Started with loop=false, offset=' + inputs.offset); | |
| logTransportState('State after start:'); | |
| setTimeout(function() { | |
| transport.loop = true; | |
| log('\nDeferred loop=true set', 'warn'); | |
| dumpLoopTimeline(' '); | |
| setTimeout(function() { | |
| log('\nAfter 300ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| transport.stop(); | |
| transport.loop = false; | |
| clearSchedules(); | |
| log('Stopped.'); | |
| }, 300); | |
| }, 0); | |
| } | |
| // Test 10: Ghost ticks fire schedule callbacks at past positions | |
| // After a loop iteration resets ticks to ~0, ghost ticks include near-0 values. | |
| // Transport.schedule(cb, 0) fires during the ghost tick range, even though | |
| // the transport is starting at a later offset. This causes duplicate sources | |
| // when combined with startMidClipSources() (our manual mid-clip handler). | |
| async function testScheduleDoubleFire() { | |
| var transport = Tone.getTransport(); | |
| log('\n=== TEST 10: Schedule callback double-fire (ghost ticks) ===', 'section'); | |
| log('After loop play → stop → play at offset, schedule callbacks at time 0'); | |
| log('fire due to ghost ticks, even though the transport starts past them.\n'); | |
| clearSchedules(); | |
| // Track how many times each schedule callback fires per play cycle | |
| var fireCount = { clip0: 0, clip3: 0 }; | |
| var playGeneration = 0; | |
| var generationAtFire = { clip0: [], clip3: [] }; | |
| // Register schedule events at time 0 and time 3 (like real clips) | |
| var id0 = transport.schedule(function(time) { | |
| fireCount.clip0++; | |
| generationAtFire.clip0.push(playGeneration); | |
| var msg = ' [schedule t=0] fire #' + fireCount.clip0 + | |
| ' gen=' + playGeneration + | |
| ' ctxTime=' + time.toFixed(4) + | |
| ' transport.seconds=' + transport.seconds.toFixed(4); | |
| if (playGeneration > 1 && transport.seconds < 0.5) { | |
| log(msg + ' >>> GHOST TICK FIRE!', 'error'); | |
| } else { | |
| log(msg, 'warn'); | |
| } | |
| }, 0); | |
| scheduleIds.push(id0); | |
| var id3 = transport.schedule(function(time) { | |
| fireCount.clip3++; | |
| generationAtFire.clip3.push(playGeneration); | |
| log(' [schedule t=3] fire #' + fireCount.clip3 + | |
| ' gen=' + playGeneration + | |
| ' ctxTime=' + time.toFixed(4), 'warn'); | |
| }, 3); | |
| scheduleIds.push(id3); | |
| log('Registered schedule events at t=0 and t=3\n'); | |
| // --- Step 1: Play with loop from offset 0 --- | |
| log('--- Step 1: Play with loop (offset=0, loop 0-5s) ---', 'section'); | |
| playGeneration = 1; | |
| fireCount = { clip0: 0, clip3: 0 }; | |
| transport.loopStart = 0; | |
| transport.loopEnd = 5; | |
| transport.loop = false; | |
| transport.start(Tone.now(), 0); | |
| setTimeout(function() { transport.loop = true; }, 0); | |
| // Let it play past the loop boundary once (6s of playback) | |
| await new Promise(function(r) { setTimeout(r, 6000); }); | |
| log('\nAfter 6s: transport.seconds=' + transport.seconds.toFixed(4)); | |
| log(' clip0 fires: ' + fireCount.clip0 + ', clip3 fires: ' + fireCount.clip3); | |
| // --- Step 2: Stop --- | |
| log('\n--- Step 2: Stop ---', 'section'); | |
| transport.stop(); | |
| transport.loop = false; | |
| log(' Stopped. clock._lastUpdate=' + transport._clock._lastUpdate.toFixed(4)); | |
| await new Promise(function(r) { setTimeout(r, 100); }); | |
| // --- Step 3: Replay from offset 1.5 (past time=0 clip) --- | |
| log('\n--- Step 3: Play from offset 1.5s (past time-0 clip) ---', 'section'); | |
| log(' Expected: schedule callback at t=0 should NOT fire'); | |
| log(' Bug: ghost ticks include near-0 values → callback fires\n'); | |
| playGeneration = 2; | |
| var clip0Before = fireCount.clip0; | |
| transport.loopStart = 0; | |
| transport.loopEnd = 5; | |
| transport.loop = false; | |
| transport.start(Tone.now(), 1.5); | |
| setTimeout(function() { transport.loop = true; }, 0); | |
| // Wait just enough for the initial tick batch | |
| await new Promise(function(r) { setTimeout(r, 200); }); | |
| var clip0After = fireCount.clip0; | |
| var ghostFires = clip0After - clip0Before; | |
| log('\n--- Results ---', 'section'); | |
| log(' clip0 fires during step 3: ' + ghostFires); | |
| if (ghostFires > 0) { | |
| log(' BUG CONFIRMED: schedule callback at t=0 fired ' + ghostFires + | |
| ' time(s) when starting at offset 1.5s', 'error'); | |
| log(' This creates duplicate audio sources (double playback)', 'error'); | |
| } else { | |
| log(' No ghost fires — bug may not reproduce (try again or warm up context first)', 'warn'); | |
| } | |
| transport.stop(); | |
| transport.loop = false; | |
| clearSchedules(); | |
| log('\nStopped and cleared.'); | |
| } | |
| async function testLoopAfterStart() { | |
| var inputs = getInputs(); | |
| var transport = Tone.getTransport(); | |
| log('\n=== TEST: loop=true AFTER start (immediate, no defer) ===', 'section'); | |
| if (transport.state !== 'stopped') { | |
| transport.stop(); | |
| log('Stopped transport first'); | |
| } | |
| patchProcessTick(); | |
| transport.loopStart = inputs.loopStart; | |
| transport.loopEnd = inputs.loopEnd; | |
| transport.loop = false; | |
| var startNow = Tone.now(); | |
| transport.start(startNow, inputs.offset); | |
| log('Started at offset=' + inputs.offset + ', now=' + startNow.toFixed(6)); | |
| var nowAtTrue = transport.now(); | |
| transport.loop = true; | |
| log('Set loop=true at now()=' + nowAtTrue.toFixed(6)); | |
| logTransportState('State:'); | |
| setTimeout(function() { | |
| log('\nAfter 200ms:', 'warn'); | |
| log(' transport.seconds=' + transport.seconds.toFixed(6)); | |
| log(' transport.ticks=' + transport.ticks); | |
| transport.stop(); | |
| transport.loop = false; | |
| log('Stopped.'); | |
| }, 200); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment