Skip to content

Fix all gapless overlap paths - #25

Merged
switz merged 7 commits into
masterfrom
fix/gapless-overlap-anchor
Sep 19, 2026
Merged

switz merged 7 commits into
masterfrom
fix/gapless-overlap-anchor

Conversation

@switz

@switz switz commented Sep 19, 2026

Copy link
Copy Markdown
Member

Summary

Comprehensive fix for audio overlap during gapless transitions, addressing every identified path where two audio sources could play simultaneously.

Root cause: anchor timing error

_startSourceNode used source.start(0) which dispatches to the audio render thread asynchronously. The δ between the main-thread currentTime read and the actual start (5–100ms depending on hardware) made playbackEndContextTime compute an end time δ too early — the next track started before the current one finished.

Fix: Use source.start(when) with an explicit future time (when = ctx.currentTime + lead). Lead = max(20ms, 2×baseLatency + 10ms). Buffer offset corrected by lead × rate to prevent position drift across pause/resume cycles.

Additional overlap paths fixed

  • previous() bypass: When currentTime > 8, previous() called seek(0) + play() directly on Track, leaving the gapless schedule stale. Now routes through SEEK event → cancelAndRescheduleGapless.
  • WEBAUDIO_ONLY pendingPlay leak: pendingPlay survived DEACTIVATE, causing a deactivated track to start its own source on BUFFER_READY. Now cleared on DEACTIVATE/ACTIVATE in idle and loading states.
  • HTML5-clock prediction overlap: When scheduling gapless from an HTML5 track, a stalled element could run past the predicted end. Now schedules a gain mute at endTime as a safety valve.
  • _scheduledNextIndex stale after dropped event: SCHEDULE_GAPLESS is only handled in idle/loading, but the queue set _scheduledNextIndex unconditionally. Now scheduleGaplessStart returns false if the state rejects it.
  • Platform late-start residual δ: sourceNode.stop(endTime) called after start() as a hard ceiling — any render-thread late start produces inaudible truncation instead of overlap.
  • Fallback crossover silence gap: HTML5 pause delayed until when in the no-MediaElementSource fallback path.
  • setPlaybackRate during lead: Anchor rewrite guarded with ctx.currentTime >= _waRefCtxTime to prevent corruption during the scheduling lead.

Test plan

  • All 348 existing tests pass
  • Library builds successfully
  • Manual: gapless transition on desktop — no overlap or gap
  • Manual: gapless transition with Bluetooth audio
  • Manual: press previous >8s into a track during gapless playback
  • Manual: pause/resume mid-track, verify clean transition at end
  • Manual: seek to last few seconds of a track on slow network
  • Manual: rapid next/prev before first decode completes (WEBAUDIO_ONLY)

🤖 Generated with Claude Code

switz and others added 5 commits September 18, 2026 17:51
Two bugs fixed:

1. _startSourceNode used source.start(0) which dispatches to the audio
   render thread asynchronously. The gap between the main-thread
   currentTime read and the actual render-thread start (δ of 5-100ms
   depending on hardware) made playbackEndContextTime compute an end
   time δ too early, causing the next gapless track's source to start
   before the current one finished — brief audible overlap.

   Fix: schedule the source at an explicit future time (start(when))
   with a lead of max(50ms, 2×baseLatency + 20ms). The anchor
   (_waRefCtxTime) now equals the actual start time, so end-time math
   is exact. The buffer offset is corrected by lead×rate so currentTime
   stays continuous and the crossover aligns both streams to the same
   position.

2. Queue.previous() when currentTime > 8 called ct.seek(0) + ct.play()
   directly on the Track, bypassing the queue state machine. This left
   _scheduledNextIndex set and the pre-scheduled next track's source
   node alive with a stale end time — full overlap when ctx.currentTime
   reached the old schedule.

   Fix: route through the queue machine via SEEK event, which triggers
   cancelAndRescheduleGapless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address review feedback: the corrected offset (advancing buffer start
by lead×rate) must apply to all paths, not just crossover. Without it,
position drift accumulates across pause/resume cycles — each start
adds lead×rate of lag to currentTime.

The skipped interval (≤20ms at 1× rate) is below audible threshold
and prevents the alternative: visible position drift in the progress
display after multiple pause/resume cycles.

Also reduces the minimum lead from 50ms to 20ms, which is still 2-4×
the typical hardware callback period on desktop systems.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When _html5GainNode is null (CORS blocked or legacy browser), the
fallback crossover path paused HTML5 immediately while the WebAudio
source was scheduled for the future `when`. This created a silence
gap of at least 20ms (the scheduling lead). Now delays the pause
until `when` so HTML5 covers the gap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
If setPlaybackRate() is called during the scheduling lead (before
ctx.currentTime reaches _waRefCtxTime), the anchor rewrite computed
a negative elapsed time, corrupting _waRefTrackTime and causing
playbackEndContextTime to schedule the next track early or late.

Now skips the anchor rewrite when ctx.currentTime < _waRefCtxTime.
The source node's rate is still updated — playbackEndContextTime
reads it live — so the end time stays correct without touching the
anchors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Finding 1: WEBAUDIO_ONLY pendingPlay survives DEACTIVATE
  - Clear pendingPlay on DEACTIVATE and ACTIVATE in both idle and
    loading states of the track machine. Without this, a deactivated
    track whose buffer arrives later would start its own source node
    while another track is current — sustained overlap.

Finding 2: HTML5-clock prediction overlap
  - Schedule a gain mute (5ms ramp to 0) on the current track's
    _html5GainNode at endTime when scheduling gapless from an HTML5
    track. If the HTML5 element stalls or runs late past the predicted
    end, it's silenced instead of overlapping the next track.
  - Cancel the mute in _cancelScheduledGapless.

Finding 3: _scheduledNextIndex set when SCHEDULE_GAPLESS is dropped
  - scheduleGaplessStart now returns false if the track isn't in
    idle/loading state. _tryScheduleGapless only sets
    _scheduledNextIndex on success.

Finding 4: Residual δ from platform late-start
  - Call sourceNode.stop(endTime) after start() in both
    _startSourceNode and startScheduledSourceNode. This provides a
    hard ceiling: any platform-level late start produces an inaudible
    truncation instead of overlap with the next track.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-19T18:09:04.893997Z a7906b3 Manual request
🔒 Security Review ✅ Completed 2026-09-19T17:54:22.293037Z 05b757b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 05b757b63c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Track.ts
// produces an inaudible truncation instead of overlap with the next track.
const bufRemaining = this.audioBuffer.duration - this._bufferStartPaddingSec - effectiveOffset;
if (bufRemaining > 0) {
this.sourceNode.stop(when + bufRemaining / rate);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reschedule hard stops when playback rate changes

When the user lowers the playback rate after this source starts, the absolute stop remains based on the original rate. setPlaybackRate() updates the source rate and playback anchor—and the queue schedules the next track from that updated end—but it never updates this stop time, so the source is cut off at its former wall-clock end, truncating the track and potentially leaving silence before the rescheduled successor. The same issue applies to the newly added stop in startScheduledSourceNode.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Now re-issues sourceNode.stop() at the corrected end time in setPlaybackRate(). The spec allows repeated stop() calls (latest wins). Addressed in 8b8c1fe.

Comment thread src/Track.ts
// The skipped interval (≤20 ms at 1× rate) is below audible threshold.
const rate = this.queueRef.playbackRate;
const maxOffset = this.audioBuffer.duration - this._bufferStartPaddingSec;
const effectiveOffset = Math.min(offset + lead * rate, maxOffset);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep ordinary starts at the requested buffer offset

When _startSourceNode handles an initial Web Audio play, resume, or seek, no other source covers the scheduling lead, so advancing the buffer offset by lead * rate permanently discards that audio. Every such operation skips at least 20 ms, and devices reporting higher baseLatency can lose substantially more; this correction is only appropriate during the HTML5 crossover, where HTML5 continues playing through the lead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional trade-off — without the universal correction, currentTime drift accumulates across pause/resume cycles: each _startSourceNode call adds lead × rate to pausedAtTrackTime via the frozen-time capture. After N cycles the total drift is N × lead × rate, which becomes visible in the progress display.

With 20ms lead at 1× rate, the skipped audio per start is ≤20ms — below audible threshold and well below a single rAF frame. The alternative (drift accumulation) is worse for the user experience.

setPlaybackRate now re-issues sourceNode.stop() at the corrected end
time after updating the rate. Without this, the stop remained based on
the original rate — lowering the rate would truncate the track early,
and raising it would leave the stale stop past the actual end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Deploying gapless with  Cloudflare Pages  Cloudflare Pages

Latest commit: a7906b3
Status: ✅  Deploy successful!
Preview URL: https://4fa259ff.gapless-e1l.pages.dev
Branch Preview URL: https://fix-gapless-overlap-anchor.gapless-e1l.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b8c1feb58

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Track.ts
Comment on lines +450 to +451
this._html5GainNode.gain.cancelScheduledValues(this.ctx.currentTime);
this._html5GainNode.gain.setValueAtTime(1, this.ctx.currentTime);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the crossover fade when canceling the end mute

When a buffered current track crosses from HTML5 while an HTML5-predicted next start is already scheduled, the crossover first programs the HTML5 gain's 1→0 ramp, then TRACK_LOADED runs cancelAndRescheduleGapless, which reaches this method. cancelScheduledValues(currentTime) removes the newly programmed crossover ramp as well as the end mute and pins the HTML5 path at full gain, while Web Audio still fades from 0→1; the handoff therefore produces a brief amplified overlap followed by an abrupt cut instead of the intended crossfade. Track whether an end mute was scheduled and cancel only that automation without erasing the crossover ramp.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Now only calls cancelHtml5Mute() when the current track is still in HTML5 state — after crossover the gain node is owned by the crossfade ramp. Addressed in a7906b3.

After crossover, the HTML5 gain node's ramp (1→0) is managing the
fade-out. Calling cancelScheduledValues there would wipe both the
mute and the crossover ramp, pinning gain at 1 and producing an
amplified overlap. Now guards the cancel with a playbackType check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@switz

switz commented Sep 19, 2026

Copy link
Copy Markdown
Member Author

@codex review all commits

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a7906b3f82

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Track.ts
Comment on lines +663 to 665
const delayMs = (when - this.ctx.currentTime + CROSSOVER_FADE_SEC) * 1000 + 5;
setTimeout(() => {
this.audio.pause();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel the delayed handoff when playback is interrupted

If the user pauses, seeks, skips, or changes tracks during the scheduling lead, the track machine has already transitioned to webaudio, but the HTML5 element remains audible until this timer fires. The webaudio PAUSE/SEEK/DEACTIVATE handlers only stop or replace the buffer source and do not pause HTML5 or cancel its pending gain automation, so a skipped track can overlap the replacement for the remainder of the lead; a quick pause/resume can also let the stale fade mute HTML5 before the replacement source starts, creating a gap. Track and cancel this pending crossover, and explicitly silence/pause HTML5 when the handoff is interrupted.

Useful? React with 👍 / 👎.

@switz
switz merged commit d6d8d0d into master Sep 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant