Skip to content

Mid-stream HTML5 → WebAudio crossover with sample-accurate crossfade#19

Merged
switz merged 4 commits intomasterfrom
mid-stream-crossover
May 7, 2026
Merged

Mid-stream HTML5 → WebAudio crossover with sample-accurate crossfade#19
switz merged 4 commits intomasterfrom
mid-stream-crossover

Conversation

@switz
Copy link
Copy Markdown
Member

@switz switz commented May 7, 2026

Summary

  • Move the active track to Web Audio as soon as its buffer decodes, so every gapless transition runs on a single shared clock (AudioContext.currentTime) and splices sample-accurately rather than relying on an HTML5-clock end-time prediction.
  • Route the HTML5 element through MediaElementAudioSourceNode + a dedicated GainNode so the crossover can do a real sample-accurate crossfade (HTML5 1→0, WebAudio 0→1, both as AudioParam ramps on the same clock).
  • Bandwidth-contention gate + speculative-load throttle in _preloadAhead so we don't burn bandwidth on tracks the user may skip.
  • Several supporting bugs fixed along the way (HEAD-redirect overwriting audio.src, stale gapless schedule across crossover, disconnectGain permanently severing the master output edge under the new routing).

Test plan

  • npm test — 339/339 passing
  • npm run build — clean ESM + DTS
  • Demo: play preset, confirm playback: HTML5 flips to WEBAUDIO after decode finishes
  • Demo: "Skip to end -2s" → seamless gapless transition (no overlap, no gap, audible)
  • Demo: rapid play/pause/seek across the crossover boundary doesn't desync
  • Real device test on Bluetooth output (high HTML5 output latency) — crossover still smooth

🤖 Generated with Claude Code

switz and others added 3 commits May 7, 2026 14:19
Move the active track to Web Audio as soon as its buffer decodes, instead
of staying on HTML5 until 'ended'. From that point on every gapless
transition runs on a single shared clock (AudioContext.currentTime), so
back-to-back tracks splice together sample-accurately rather than relying
on an HTML5-clock end-time prediction.

Routing changes (src/Track.ts):
  - Eagerly create a graph at ctx setup:
        <audio> → MediaElementAudioSourceNode → _html5GainNode ┐
                                                               ├→ gainNode → destination
            AudioBufferSourceNode → fadeGain ──────────────────┘
  - Crossover schedules two AudioParam ramps on ctx.currentTime
    (HTML5 1→0, WebAudio 0→1) so the join is sample-accurate and the
    sample-level discontinuity at the cut is masked.
  - audio.volume goes to 1 when the routing is active; master volume
    lives entirely on gainNode (avoids v² double-application).
  - gainNode→destination connected once at ctx setup; per-start reconnects
    removed (they would have summed multiple edges).
  - disconnectGain action removed from PAUSE/CANCEL_GAPLESS/ACTIVATE/
    DEACTIVATE — under the new routing, a disconnect is permanent and
    caused silent gapless after seek-near-end + cancel-and-reschedule.

Behavior changes (src/Queue.ts, src/machines/queue.machine.ts):
  - Bandwidth-contention gate in _preloadAhead: defer next-track
    fetches while the current track's own buffer is still loading.
  - Speculative-load throttle: tracks beyond i = current+1 wait until
    the current track has played past min(duration*0.2, 15s).
  - TRACK_LOADED for the current track triggers cancelAndRescheduleGapless
    so any HTML5-clock prediction is replaced by the WebAudio end-time.
  - notifyBufferReady fires from the BUFFER_READY transitions (after the
    crossover state update), so the queue observes WEBAUDIO playbackType
    when deciding deferral.
  - HEAD probe in fetchDecode no longer overwrites audio.src (that was
    aborting HTML5 playback and resetting currentTime to 0).

New tests (tests/unit/crossover-flow.test.ts) cover end-to-end crossover,
the bandwidth gate, cancel-and-reschedule on crossover, the sample-accurate
crossfade, alignment-on-paused-track, and the seek-near-end regression.

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

cloudflare-workers-and-pages Bot commented May 7, 2026

Deploying gapless with  Cloudflare Pages  Cloudflare Pages

Latest commit: e7ef912
Status: ✅  Deploy successful!
Preview URL: https://752371c0.gapless-e1l.pages.dev
Branch Preview URL: https://mid-stream-crossover.gapless-e1l.pages.dev

View logs

@switz
Copy link
Copy Markdown
Member Author

switz commented May 7, 2026

@codex review this pr

CI's TypeScript pinned forward to 6.0.3, which now hard-errors on
deprecated compilerOptions. downlevelIteration only matters for ES5
targets (we ship ESNext), so it's removed. baseUrl is implicit from
tsup's bundling config — handle it via ignoreDeprecations: "6.0" until
the upstream toolchain catches up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

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

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: e605c4fbe8

ℹ️ 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
// legacy implementations or double-attach), fall back to the
// pre-routing behavior with audio.volume controlling HTML5 directly.
try {
this._mediaElementSource = context.createMediaElementSource(this.audio);
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 Avoid routing non-CORS HTML5 streams into Web Audio

For cross-origin tracks that the <audio> element can play but that do not send CORS headers, createMediaElementSource() does not reliably throw; browsers attach the element to the Web Audio graph and the node outputs silence. Because this now runs as soon as ctx is touched in default HYBRID mode and replaces the element's native output, those remote streams become muted instead of falling back to audible HTML5 playback while the fetch/decode path fails. Gate this routing on known CORS support (or set crossOrigin before assigning src and provide a real fallback) rather than unconditionally attaching the media element.

Useful? React with 👍 / 👎.

@switz switz merged commit 23cb9b2 into master May 7, 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