Mid-stream HTML5 → WebAudio crossover with sample-accurate crossfade#19
Mid-stream HTML5 → WebAudio crossover with sample-accurate crossfade#19
Conversation
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>
Deploying gapless with
|
| 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 |
|
@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>
There was a problem hiding this comment.
💡 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".
| // 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); |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
AudioContext.currentTime) and splices sample-accurately rather than relying on an HTML5-clock end-time prediction.MediaElementAudioSourceNode+ a dedicatedGainNodeso the crossover can do a real sample-accurate crossfade (HTML5 1→0, WebAudio 0→1, both asAudioParamramps on the same clock)._preloadAheadso we don't burn bandwidth on tracks the user may skip.audio.src, stale gapless schedule across crossover,disconnectGainpermanently severing the master output edge under the new routing).Test plan
npm test— 339/339 passingnpm run build— clean ESM + DTSplayback: HTML5flips toWEBAUDIOafter decode finishes🤖 Generated with Claude Code