gapless 4.1.2 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gapless",
3
- "version": "4.1.2",
3
+ "version": "4.2.0",
4
4
  "description": "Gapless audio playback javascript plugin",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -37,18 +37,18 @@
37
37
  "author": "Daniel Saewitz",
38
38
  "license": "MIT",
39
39
  "devDependencies": {
40
- "@playwright/test": "^1.58.2",
41
- "@switz/eslint-config": "^12.5.2",
42
- "@vitest/coverage-v8": "^4.0.18",
43
- "eslint": "^9.25.1",
44
- "happy-dom": "^20.8.3",
45
- "playwright": "^1.58.2",
40
+ "@playwright/test": "^1.59.1",
41
+ "@switz/eslint-config": "^13.0.2",
42
+ "@vitest/coverage-v8": "^4.1.5",
43
+ "eslint": "^10.3.0",
44
+ "happy-dom": "^20.9.0",
45
+ "playwright": "^1.59.1",
46
46
  "tsup": "^8.5.1",
47
- "typescript": "^5.9.3",
48
- "vitest": "^4.0.18"
47
+ "typescript": "^6.0.3",
48
+ "vitest": "^4.1.5"
49
49
  },
50
50
  "packageManager": "pnpm@10.30.2",
51
51
  "dependencies": {
52
- "xstate": "^5.28.0"
52
+ "xstate": "^5.31.0"
53
53
  }
54
54
  }
package/src/Queue.ts CHANGED
@@ -418,21 +418,57 @@ export class Queue implements TrackQueueRef {
418
418
 
419
419
  private _preloadAhead(fromIndex: number): void {
420
420
  const cur = this._trackAt(fromIndex);
421
- if (cur && cur.playbackType === 'HTML5' && cur.isPlaying) {
422
- const threshold = isNaN(cur.duration) ? 15 : Math.min(cur.duration * 0.2, 15);
423
- if (cur.currentTime < threshold) {
424
- this.onDebug(`_preloadAhead: deferring HTML5 track ${fromIndex} at ${cur.currentTime.toFixed(1)}s (threshold=${threshold.toFixed(1)}s)`);
425
- return;
426
- }
421
+
422
+ // Bandwidth-contention gate: while the current track is playing via HTML5
423
+ // and its own Web Audio buffer is still being fetched+decoded, holding
424
+ // off on every next-track preload prevents two concurrent large MP3
425
+ // downloads from delaying the current track's crossover (and therefore
426
+ // the moment from which all future gapless transitions are
427
+ // sample-accurate). When the current track's BUFFER_READY fires,
428
+ // notifyBufferReady → TRACK_LOADED → preloadAhead re-runs, this gate
429
+ // is no longer engaged, and next-track fetches proceed sequentially
430
+ // as buffers complete.
431
+ //
432
+ // This lives here (not in the queue machine) because preloadAhead is
433
+ // invoked as a transition action from ~7 different events, and the
434
+ // gate's data source — the current track's machine state — isn't
435
+ // owned by the queue machine. Pushing the check into every caller
436
+ // would just duplicate the same read.
437
+ if (
438
+ cur != null &&
439
+ cur.isPlaying &&
440
+ cur.playbackType === 'HTML5' &&
441
+ cur.webAudioLoadingState === 'LOADING'
442
+ ) {
443
+ this.onDebug(`_preloadAhead(${fromIndex}): deferring all — current track buffer still loading`);
444
+ return;
427
445
  }
446
+
447
+ // Speculative-load throttle: the immediate next track is always allowed
448
+ // (it must be ready for gapless scheduling), but tracks beyond that are
449
+ // deferred until the current track has played past min(duration*0.2, 15s)
450
+ // — the point at which we have evidence the user is committing to
451
+ // listening, not skipping.
452
+ const curBelowThreshold =
453
+ cur != null &&
454
+ cur.isPlaying &&
455
+ (() => {
456
+ const threshold = isNaN(cur.duration) ? 15 : Math.min(cur.duration * 0.2, 15);
457
+ return cur.currentTime < threshold;
458
+ })();
459
+
428
460
  const limit = fromIndex + this._preloadNumTracks + 1;
429
- this.onDebug(`_preloadAhead(${fromIndex}) limit=${limit} trackCount=${this._tracks.length}`);
461
+ this.onDebug(`_preloadAhead(${fromIndex}) limit=${limit} trackCount=${this._tracks.length} belowThreshold=${curBelowThreshold}`);
430
462
  for (let i = fromIndex + 1; i < this._tracks.length && i < limit; i++) {
431
463
  const t = this._tracks[i];
464
+ if (i > fromIndex + 1 && curBelowThreshold) {
465
+ this.onDebug(`_preloadAhead: deferring track ${i} (current at ${cur!.currentTime.toFixed(1)}s, below threshold)`);
466
+ return;
467
+ }
432
468
  if (!t.isBufferLoaded) {
433
469
  this.onDebug(`_preloadAhead: starting preload for track ${i}`);
434
470
  t.preload();
435
- break;
471
+ return;
436
472
  } else {
437
473
  this.onDebug(`_preloadAhead: track ${i} already loaded`);
438
474
  }
package/src/Track.ts CHANGED
@@ -29,6 +29,17 @@ const GAPLESS_SCHEDULE_LOOKAHEAD = 5;
29
29
  /** How many seconds into HTML5 playback before we preload the next track. */
30
30
  const PRELOAD_DELAY = 15;
31
31
 
32
+ /**
33
+ * Crossfade duration (seconds) for the mid-stream HTML5 → Web Audio handoff.
34
+ * Both sides of the crossfade run on AudioContext-clock gain ramps
35
+ * (the HTML5 side via a GainNode after MediaElementAudioSourceNode), so
36
+ * the join is sample-accurate. 30 ms is short enough to be inaudible as a
37
+ * fade and long enough to mask any sample-level discontinuity between the
38
+ * two streams.
39
+ */
40
+ const CROSSOVER_FADE_SEC = 0.03;
41
+
42
+
32
43
  export class Track {
33
44
  readonly index: number;
34
45
  readonly metadata: TrackMetadata;
@@ -51,14 +62,63 @@ export class Track {
51
62
  if (context && !this.gainNode) {
52
63
  this.gainNode = context.createGain();
53
64
  this.gainNode.gain.value = this.audio.volume;
65
+ this.gainNode.connect(context.destination);
66
+
67
+ // Route the HTML5 element through the AudioContext via a
68
+ // MediaElementAudioSourceNode + dedicated GainNode. With this routing,
69
+ // the HTML5 side has its own AudioContext-clock gain we can ramp
70
+ // sample-accurately during crossover, eliminating the click that an
71
+ // abrupt audio.pause() leaves at the cut point. Volume control moves
72
+ // entirely onto gainNode (master); we set audio.volume = 1 so the
73
+ // browser-side and AudioContext-side gains don't multiply.
74
+ //
75
+ // createMediaElementSource severs the element's default audio output
76
+ // for the rest of its life, so once this runs, all HTML5 playback
77
+ // flows through the AudioContext. CORS: cross-origin audio without
78
+ // Access-Control-Allow-Origin will produce silence through the
79
+ // MediaElementAudioSourceNode path. If the call throws (rare; some
80
+ // legacy implementations or double-attach), fall back to the
81
+ // pre-routing behavior with audio.volume controlling HTML5 directly.
82
+ try {
83
+ this._mediaElementSource = context.createMediaElementSource(this.audio);
84
+ this._html5GainNode = context.createGain();
85
+ this._html5GainNode.gain.value = 1;
86
+ this._mediaElementSource.connect(this._html5GainNode);
87
+ this._html5GainNode.connect(this.gainNode);
88
+ this.audio.volume = 1;
89
+ } catch {
90
+ this._mediaElementSource = null;
91
+ this._html5GainNode = null;
92
+ }
54
93
  }
55
94
  return context;
56
95
  }
57
96
 
58
97
  private gainNode: GainNode | null = null;
98
+ private _mediaElementSource: MediaElementAudioSourceNode | null = null;
99
+ private _html5GainNode: GainNode | null = null;
59
100
  private sourceNode: AudioBufferSourceNode | null = null;
60
101
  audioBuffer: AudioBuffer | null = null;
61
102
  /** AudioContext.currentTime at the start of the current playback segment. */
103
+ /**
104
+ * Offset (in seconds) between the decoded buffer's "offset 0" and the music's
105
+ * "offset 0". Some MP3 files include ID3v2 metadata, encoder priming samples,
106
+ * or container padding at the start; HTML5 audio elements skip past these
107
+ * natively (audio.currentTime=0 means music start), but `decodeAudioData` in
108
+ * some browsers includes them in the decoded buffer (buffer offset 0 = file
109
+ * start, music actually starts at offset _bufferStartPaddingSec).
110
+ *
111
+ * Without this shift, calling source.start(when, audio.currentTime) plays
112
+ * `audio.currentTime` seconds AHEAD of what HTML5 was just outputting,
113
+ * sounding like a backward skip at crossover. We compute this once both
114
+ * the buffer and audio.duration are known, and apply it as
115
+ * source.start(when, trackTime + _bufferStartPaddingSec)
116
+ * everywhere we read from the buffer. User-facing time (currentTime/duration
117
+ * getters) continues to be reported in music-time, not buffer-time.
118
+ */
119
+ private _bufferStartPaddingSec = 0;
120
+ private _bufferAlignmentMeasured = false;
121
+
62
122
  private _waRefCtxTime = 0;
63
123
  /** Track position (seconds) at the start of the current playback segment. */
64
124
  private _waRefTrackTime = 0;
@@ -132,8 +192,18 @@ export class Track {
132
192
  resolveUrl: fromPromise(async ({ signal }) => {
133
193
  const res = await fetch(this._trackUrl, { method: 'HEAD', signal });
134
194
  if (res.redirected && res.url) {
195
+ // Cache the resolved URL so the WebAudio GET below uses it
196
+ // directly (avoiding a second redirect round-trip). Do NOT
197
+ // overwrite this.audio.src — the HTML5 element may currently
198
+ // be streaming, and assigning a new src aborts playback,
199
+ // resets audio.currentTime to 0, and reloads from the new
200
+ // URL. The browser already handles the original URL's
201
+ // redirect transparently for HTML5; updating src here causes
202
+ // a perceived jump back to the start mid-playback (which the
203
+ // user then notices later as a "skip" at crossover, since
204
+ // audio.currentTime captured at crossover time reflects the
205
+ // post-reset position rather than the user's actual progress).
135
206
  this._resolvedUrl = res.url;
136
- this.audio.src = res.url;
137
207
  return res.url;
138
208
  }
139
209
  return null;
@@ -149,7 +219,15 @@ export class Track {
149
219
  this._pendingArrayBuffer = null;
150
220
  if (!buf || !this.ctx) throw new Error('No ArrayBuffer or AudioContext');
151
221
  this.audioBuffer = await this.ctx.decodeAudioData(buf);
152
- queueMicrotask(() => this.queueRef.onTrackBufferReady(this));
222
+ this._maybeComputeBufferAlignment();
223
+ // NOTE: do NOT notify the queue from here. The fetchDecode child
224
+ // is about to sendParent('BUFFER_READY'), which the track machine
225
+ // handles by transitioning state (and, in html5, by performing
226
+ // the mid-stream crossover). If we notified the queue first, it
227
+ // would observe a stale playbackType ('HTML5') and incorrectly
228
+ // defer next-track preload via the HTML5 deferral guard. The
229
+ // notification is emitted from the BUFFER_READY transitions in
230
+ // track.machine, after the crossover/state update is in effect.
153
231
  }),
154
232
  },
155
233
  }),
@@ -163,21 +241,31 @@ export class Track {
163
241
  startSourceNode: () => {
164
242
  this._startSourceNode(this.pausedAtTrackTime);
165
243
  },
244
+ crossoverHtml5ToWebAudio: ({ context }: { context: TrackContext }) => {
245
+ this._crossoverHtml5ToWebAudio(context.isPlaying);
246
+ },
166
247
  startScheduledSourceNode: ({ context }: { context: TrackContext }) => {
167
248
  const when = context.scheduledStartContextTime;
168
249
  if (when === null || !this.ctx || !this.audioBuffer || !this.gainNode) return;
250
+ this._maybeComputeBufferAlignment();
169
251
  this._stopSourceNode();
170
252
  this.sourceNode = this.ctx.createBufferSource();
171
253
  this.sourceNode.buffer = this.audioBuffer;
172
254
  this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
173
255
  this.sourceNode.connect(this.gainNode);
174
- this.gainNode.connect(this.ctx.destination);
256
+ // gainNode → destination connected once at ctx setup; do not re-connect
257
+ // here or the gainNode's output is duplicated (Web Audio sums multiple
258
+ // edges between the same pair of nodes).
175
259
  this.sourceNode.onended = this._handleWebAudioEnded;
176
- this.sourceNode.start(when, 0);
260
+ // Skip past any decoded-buffer start padding (ID3 tags, encoder
261
+ // priming) so the gapless transition begins at music-time 0, not at
262
+ // half a second of silence/garbage. _bufferStartPaddingSec is
263
+ // computed at decode time via _ensureBufferAlignment.
264
+ this.sourceNode.start(when, this._bufferStartPaddingSec);
177
265
  this._waRefCtxTime = when;
178
266
  this._waRefTrackTime = 0;
179
267
  this.queueRef.onDebug(
180
- `startScheduledSourceNode track=${this.index} when=${when.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(3)} delta=${(when - this.ctx.currentTime).toFixed(3)}s`
268
+ `startScheduledSourceNode track=${this.index} when=${when.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(3)} delta=${(when - this.ctx.currentTime).toFixed(3)}s padding=${this._bufferStartPaddingSec.toFixed(3)}s`
181
269
  );
182
270
  },
183
271
  startProgressLoop: () => this.startProgressLoop(),
@@ -187,7 +275,6 @@ export class Track {
187
275
  this.pausedAtTrackTime = isFinite(t) ? t : 0;
188
276
  },
189
277
  stopSourceNode: () => this._stopSourceNode(),
190
- disconnectGain: () => this._disconnectGain(),
191
278
  stopProgressLoop: () => this._stopProgressLoop(),
192
279
  reportProgress: () => {
193
280
  queueMicrotask(() => this.queueRef.onProgress(this.toInfo()));
@@ -205,6 +292,12 @@ export class Track {
205
292
  notifyTrackEnded: () => {
206
293
  queueMicrotask(() => this.queueRef.onTrackEnded(this));
207
294
  },
295
+ notifyBufferReady: () => {
296
+ // Emitted from BUFFER_READY transitions so the queue observes the
297
+ // post-transition state (e.g. WEBAUDIO after a crossover) rather
298
+ // than the pre-transition state.
299
+ queueMicrotask(() => this.queueRef.onTrackBufferReady(this));
300
+ },
208
301
  },
209
302
  });
210
303
  this._actor = createActor(machine);
@@ -235,7 +328,15 @@ export class Track {
235
328
 
236
329
  setVolume(v: number): void {
237
330
  const vol = Math.min(1, Math.max(0, v));
238
- this.audio.volume = vol;
331
+ // When the HTML5 element is routed through MediaElementAudioSourceNode,
332
+ // master volume lives entirely on gainNode — keep audio.volume at 1 so the
333
+ // browser-side and AudioContext-side gains don't multiply (giving v²).
334
+ // Otherwise (HTML5_ONLY mode, or pre-ctx), audio.volume is the master.
335
+ if (this._mediaElementSource) {
336
+ this.audio.volume = 1;
337
+ } else {
338
+ this.audio.volume = vol;
339
+ }
239
340
  if (this.gainNode) this.gainNode.gain.value = vol;
240
341
  this._actor.send({ type: 'SET_VOLUME', volume: vol });
241
342
  }
@@ -435,8 +536,112 @@ export class Track {
435
536
  // Private: Web Audio helpers
436
537
  // --------------------------------------------------------------------------
437
538
 
438
- private _startSourceNode(offset: number): void {
539
+ /**
540
+ * Mid-stream crossover: switch an actively-playing HTML5 track to Web Audio.
541
+ *
542
+ * Why this exists: we cannot reliably predict when an HTML5 <audio> element
543
+ * will fire 'ended' from within the AudioContext clock. Any prediction is
544
+ * at the mercy of the browser's audio pipeline (buffering stalls, codec
545
+ * padding differences, clock drift between audio.currentTime and
546
+ * ctx.currentTime over long sessions). Scheduling the next gapless track
547
+ * against that prediction is how overlap bugs happen.
548
+ *
549
+ * Instead, as soon as the buffer is decoded, we hand playback off to Web
550
+ * Audio while the track is still mid-song. From that point on, the track
551
+ * and all subsequent gapless transitions live on a single clock
552
+ * (AudioContext.currentTime), so scheduling is sample-accurate by
553
+ * construction — no prediction involved.
554
+ *
555
+ * Ordering: pause the HTML5 element FIRST, then start the source node at
556
+ * the captured offset. Pausing first ensures audio.currentTime is frozen
557
+ * before we read it as the Web Audio start offset, so there's no brief
558
+ * double-audio window at the crossover point.
559
+ */
560
+ private _crossoverHtml5ToWebAudio(wasPlaying: boolean): void {
439
561
  if (!this.ctx || !this.audioBuffer || !this.gainNode) return;
562
+
563
+ const offset = this.audio.currentTime;
564
+ this.pausedAtTrackTime = isFinite(offset) ? offset : 0;
565
+
566
+ if (!wasPlaying) {
567
+ this.audio.pause();
568
+ this.queueRef.onDebug(
569
+ `crossoverHtml5ToWebAudio track=${this.index} offset=${this.pausedAtTrackTime.toFixed(3)} wasPlaying=false`
570
+ );
571
+ return;
572
+ }
573
+
574
+ // Sample-accurate crossfade. Both sides live on the AudioContext clock:
575
+ // • HTML5 path: mediaElementSource → _html5GainNode → gainNode → destination
576
+ // • WebAudio path: source → fadeGain → gainNode → destination
577
+ //
578
+ // We schedule a 1→0 ramp on _html5GainNode and a 0→1 ramp on the WebAudio
579
+ // fadeGain, both over CROSSOVER_FADE_SEC and both starting at the same
580
+ // ctx-clock time. The two streams overlap with constant-summed gain
581
+ // through the fade window, smoothing out the sample-level discontinuity
582
+ // that an instant cut would otherwise leave audible as a "blip".
583
+ //
584
+ // If _html5GainNode wasn't created (createMediaElementSource fell back —
585
+ // e.g. CORS blocked or legacy browser), we degrade to immediate pause +
586
+ // WebAudio fade-in only.
587
+ const t0 = this.ctx.currentTime;
588
+ const t1 = t0 + CROSSOVER_FADE_SEC;
589
+
590
+ if (this._html5GainNode) {
591
+ this._html5GainNode.gain.cancelScheduledValues(t0);
592
+ this._html5GainNode.gain.setValueAtTime(1, t0);
593
+ this._html5GainNode.gain.linearRampToValueAtTime(0, t1);
594
+ // Pause the HTML5 element after the fade completes — it stops consuming
595
+ // network/decoder resources and the gain is back to silent regardless.
596
+ // Reset the gain to 1 afterwards so future plays through this element
597
+ // (post-deactivate/reactivate) start at full level.
598
+ const ctxRef = this.ctx;
599
+ const html5GainRef = this._html5GainNode;
600
+ setTimeout(() => {
601
+ this.audio.pause();
602
+ if (ctxRef && html5GainRef) {
603
+ html5GainRef.gain.cancelScheduledValues(ctxRef.currentTime);
604
+ html5GainRef.gain.setValueAtTime(1, ctxRef.currentTime);
605
+ }
606
+ }, CROSSOVER_FADE_SEC * 1000 + 5);
607
+ } else {
608
+ // Fallback: no MediaElementSource path. Silence HTML5 immediately.
609
+ const savedVolume = this.audio.volume;
610
+ this.audio.volume = 0;
611
+ this.audio.pause();
612
+ this.audio.volume = savedVolume;
613
+ }
614
+
615
+ this._startSourceNode(this.pausedAtTrackTime, CROSSOVER_FADE_SEC);
616
+
617
+ this.queueRef.onDebug(
618
+ `crossoverHtml5ToWebAudio track=${this.index} offset=${this.pausedAtTrackTime.toFixed(3)} wasPlaying=true fade=${CROSSOVER_FADE_SEC}s mediaSource=${!!this._html5GainNode}`
619
+ );
620
+ }
621
+
622
+ /**
623
+ * Reverted: alignment-based fixes (duration-delta and buffer-silence
624
+ * scanning) reduced the perceived skip on archive.org files but did not
625
+ * eliminate it, suggesting the residual gap isn't a buffer/timeline
626
+ * alignment problem at all. Leaving _bufferStartPaddingSec at 0 (no shift)
627
+ * until we have a confirmed root cause; the field and call sites are kept
628
+ * so we can re-introduce a fix without churning the source-start code.
629
+ */
630
+ private _maybeComputeBufferAlignment(): void {
631
+ if (!this.audioBuffer) return;
632
+ if (this._bufferAlignmentMeasured) return;
633
+ this._bufferStartPaddingSec = 0;
634
+ this._bufferAlignmentMeasured = true;
635
+ this.queueRef.onDebug(
636
+ `_maybeComputeBufferAlignment track=${this.index} bufferDur=${this.audioBuffer.duration.toFixed(3)}s html5Dur=${isNaN(this.audio.duration) ? 'NaN' : this.audio.duration.toFixed(3) + 's'} (alignment shift disabled — see comment)`
637
+ );
638
+ }
639
+
640
+ private _startSourceNode(offset: number, fadeInSec = 0): void {
641
+ if (!this.ctx || !this.audioBuffer || !this.gainNode) return;
642
+ // Re-check alignment in case audio.duration became available since the
643
+ // last computation (e.g., preloaded track whose metadata load completed).
644
+ this._maybeComputeBufferAlignment();
440
645
  this._stopSourceNode();
441
646
 
442
647
  // Ensure the AudioContext is running (it may have been suspended after
@@ -448,13 +653,31 @@ export class Track {
448
653
  this.sourceNode = this.ctx.createBufferSource();
449
654
  this.sourceNode.buffer = this.audioBuffer;
450
655
  this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
451
- this.sourceNode.connect(this.gainNode);
452
- this.gainNode.connect(this.ctx.destination);
656
+
657
+ // When fadeInSec > 0 (crossover path), insert a per-source fade gain
658
+ // between the source and the master gainNode. This gain ramps 0 → 1 over
659
+ // fadeInSec so the new Web Audio output rises in step with the HTML5
660
+ // element's audio-pipeline tail decaying. The master gainNode (which
661
+ // represents user volume) is unaffected.
662
+ if (fadeInSec > 0) {
663
+ const fadeNode = this.ctx.createGain();
664
+ const t0 = this.ctx.currentTime;
665
+ fadeNode.gain.setValueAtTime(0, t0);
666
+ fadeNode.gain.linearRampToValueAtTime(1, t0 + fadeInSec);
667
+ this.sourceNode.connect(fadeNode);
668
+ fadeNode.connect(this.gainNode);
669
+ } else {
670
+ this.sourceNode.connect(this.gainNode);
671
+ }
672
+ // gainNode → destination connected once at ctx setup; do not re-connect.
453
673
  this.sourceNode.onended = this._handleWebAudioEnded;
454
674
 
455
675
  this._waRefCtxTime = this.ctx.currentTime;
456
676
  this._waRefTrackTime = offset;
457
- this.sourceNode.start(0, offset);
677
+ // Buffer-internal offset = music-time offset + decoded-buffer start padding.
678
+ // See _bufferStartPaddingSec docs for why this is necessary on MP3 files
679
+ // with ID3v2 tags or encoder priming samples.
680
+ this.sourceNode.start(0, offset + this._bufferStartPaddingSec);
458
681
  }
459
682
 
460
683
  private _stopSourceNode(): void {
@@ -473,15 +696,6 @@ export class Track {
473
696
  this.sourceNode = null;
474
697
  }
475
698
 
476
- private _disconnectGain(): void {
477
- if (!this.gainNode || !this.ctx) return;
478
- try {
479
- this.gainNode.disconnect(this.ctx.destination);
480
- } catch {
481
- /* already disconnected */
482
- }
483
- }
484
-
485
699
  private _seekWebAudio(): void {
486
700
  const snap = this._actor.getSnapshot();
487
701
  const wasPlaying = snap.context.isPlaying;
@@ -277,9 +277,20 @@ export function createQueueMachine(initialContext: QueueContext) {
277
277
  actions: ['deactivateEndedTrack', 'notifyEnded'],
278
278
  },
279
279
  ],
280
- TRACK_LOADED: {
281
- actions: ['scheduleGapless', 'preloadAhead'],
282
- },
280
+ TRACK_LOADED: [
281
+ {
282
+ // When the *current* track's buffer becomes ready while we are
283
+ // already playing, that's a mid-stream HTML5 → Web Audio
284
+ // crossover. Any existing gapless schedule was based on the old
285
+ // HTML5-clock end-time prediction; cancel and re-schedule using
286
+ // the now-authoritative WebAudio end time.
287
+ guard: ({ context, event }) => event.index === context.currentTrackIndex,
288
+ actions: ['cancelAndRescheduleGapless', 'preloadAhead'],
289
+ },
290
+ {
291
+ actions: ['scheduleGapless', 'preloadAhead'],
292
+ },
293
+ ],
283
294
  },
284
295
  },
285
296