Summary
When a Player is synced to Transport via player.sync().start(0, offset, duration), stopping and restarting the Transport from a non-zero offset causes the clip at time 0 to replay as a phantom audio source. This creates audible layering — the clip at time 0 plays simultaneously with clips at the correct Transport offset.
Root Cause
Two issues in Source.ts combine to create this bug:
1. _syncedStart skips offset=0 entirely (line 321)
this._syncedStart = (time, offset) => {
if (GT(offset, 0)) { // ← clips at offset 0 are NEVER handled here
const stateEvent = this._state.get(offset);
// ...
}
};
The GT(offset, 0) check means clips synced at Transport time 0 are never started via the _syncedStart handler. This makes the transport.schedule() callback (issue 2) the only start path for these clips.
2. Unconditional transport.schedule() callback (line 230-231)
const sched = this.context.transport.schedule((t) => {
this._start(t, offset, duration); // ← always fires, no state check
}, computedTime);
When computedTime is 0, this callback fires whenever Transport's internal tick counter crosses 0. After a stop→start cycle, floating-point drift in TickSource (~1e-16) causes tick 0 to re-trigger, calling _start() unconditionally. This creates a new BufferSourceNode even when the Transport is starting from a later offset (e.g., transport.start(now(), 5)).
Reproduction
import { Player, getTransport } from 'tone';
const player = new Player(buffer);
player.toDestination();
// Sync player to start at Transport time 0
player.sync().start(0, 0, buffer.duration);
const transport = getTransport();
// First play — works correctly
transport.start(Tone.now(), 0);
// Stop after some playback
setTimeout(() => {
transport.stop();
// Start from offset 5 seconds — clip at time 0 should NOT play
// BUG: clip at time 0 plays anyway (phantom replay)
transport.start(Tone.now(), 5);
}, 2000);
Expected: Starting Transport from offset 5 should not trigger a clip synced at time 0.
Actual: The transport.schedule() callback fires at tick 0 due to TickSource drift, creating a phantom BufferSourceNode that plays the clip from the beginning.
Impact
This affects any application using player.sync().start(0, ...) with Transport stop→start cycles — common in DAW-like applications with play/pause/seek controls. Each stop→start cycle creates an additional phantom audio source, causing:
- Audible audio layering (multiple copies of the clip playing simultaneously)
- Accumulating
BufferSourceNode instances (memory/resource leak)
The issue is more pronounced with Transport-native looping (Transport.loop = true), where each loop iteration is a stop→start cycle.
Suggested Fix
The transport.schedule() callback at line 230 should check the Source's state before calling _start(), similar to how _syncedStart checks this._state.get(offset). Alternatively, the GT(offset, 0) guard in _syncedStart could be changed to GTE(offset, 0) so that offset=0 is handled by the normal synced-start path rather than relying solely on the unconditional callback.
Workaround
We currently replace the transport.schedule() callback with a guarded version that tracks the intended Transport start offset:
if (absTransportTime === 0) {
const scheduled = (player as any)._scheduled as number[];
const playerTransport = (player as any).context.transport;
if (scheduled.length >= 1) {
playerTransport.clear(scheduled[0]);
const guardedId = playerTransport.schedule((t: number) => {
if (this._transportStartOffset < 0.01) {
(player as any)._start(t, clipInfo.offset, clipInfo.duration);
}
}, 0);
scheduled[0] = guardedId;
}
}
This accesses private internals (_scheduled, _start, context.transport), which is fragile.
Environment
- Tone.js version: 15.1.22 (also verified on current
dev branch — Tone/source/Source.ts line 321)
- Browsers: Chrome, Firefox, Safari (all affected)
Summary
When a
Playeris synced to Transport viaplayer.sync().start(0, offset, duration), stopping and restarting the Transport from a non-zero offset causes the clip at time 0 to replay as a phantom audio source. This creates audible layering — the clip at time 0 plays simultaneously with clips at the correct Transport offset.Root Cause
Two issues in
Source.tscombine to create this bug:1.
_syncedStartskips offset=0 entirely (line 321)The
GT(offset, 0)check means clips synced at Transport time 0 are never started via the_syncedStarthandler. This makes thetransport.schedule()callback (issue 2) the only start path for these clips.2. Unconditional
transport.schedule()callback (line 230-231)When
computedTimeis 0, this callback fires whenever Transport's internal tick counter crosses 0. After a stop→start cycle, floating-point drift inTickSource(~1e-16) causes tick 0 to re-trigger, calling_start()unconditionally. This creates a newBufferSourceNodeeven when the Transport is starting from a later offset (e.g.,transport.start(now(), 5)).Reproduction
Expected: Starting Transport from offset 5 should not trigger a clip synced at time 0.
Actual: The
transport.schedule()callback fires at tick 0 due to TickSource drift, creating a phantomBufferSourceNodethat plays the clip from the beginning.Impact
This affects any application using
player.sync().start(0, ...)with Transport stop→start cycles — common in DAW-like applications with play/pause/seek controls. Each stop→start cycle creates an additional phantom audio source, causing:BufferSourceNodeinstances (memory/resource leak)The issue is more pronounced with Transport-native looping (
Transport.loop = true), where each loop iteration is a stop→start cycle.Suggested Fix
The
transport.schedule()callback at line 230 should check the Source's state before calling_start(), similar to how_syncedStartchecksthis._state.get(offset). Alternatively, theGT(offset, 0)guard in_syncedStartcould be changed toGTE(offset, 0)so that offset=0 is handled by the normal synced-start path rather than relying solely on the unconditional callback.Workaround
We currently replace the
transport.schedule()callback with a guarded version that tracks the intended Transport start offset:This accesses private internals (
_scheduled,_start,context.transport), which is fragile.Environment
devbranch —Tone/source/Source.tsline 321)