Skip to content

Synced Player at Transport time 0: phantom replays after stop→start cycles #1417

@naomiaro

Description

@naomiaro

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)

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