gapless 4.1.2 → 4.2.1

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.1",
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;
@@ -89,6 +149,17 @@ export class Track {
89
149
  // HTML5 Audio
90
150
  this.audio = new Audio();
91
151
  this.audio.preload = 'none';
152
+ // crossOrigin must be set BEFORE src for MediaElementAudioSourceNode to
153
+ // actually expose the element's audio to the AudioContext graph. Without
154
+ // it, even servers that DO send CORS headers result in the
155
+ // MediaElementSource being treated as cross-origin tainted, and the node
156
+ // outputs silence — manifesting as "HTML5 plays fine until WebAudio
157
+ // takes over, then suddenly audible". HTML5_ONLY mode skips this since
158
+ // it doesn't route through AudioContext; setting crossOrigin there
159
+ // would make non-CORS sources fail to load at all.
160
+ if (opts.queue.playbackMethod !== 'HTML5_ONLY') {
161
+ this.audio.crossOrigin = 'anonymous';
162
+ }
92
163
  this.audio.src = this._trackUrl;
93
164
  this.audio.volume = opts.queue.volume;
94
165
  this.audio.controls = false;
@@ -132,8 +203,18 @@ export class Track {
132
203
  resolveUrl: fromPromise(async ({ signal }) => {
133
204
  const res = await fetch(this._trackUrl, { method: 'HEAD', signal });
134
205
  if (res.redirected && res.url) {
206
+ // Cache the resolved URL so the WebAudio GET below uses it
207
+ // directly (avoiding a second redirect round-trip). Do NOT
208
+ // overwrite this.audio.src — the HTML5 element may currently
209
+ // be streaming, and assigning a new src aborts playback,
210
+ // resets audio.currentTime to 0, and reloads from the new
211
+ // URL. The browser already handles the original URL's
212
+ // redirect transparently for HTML5; updating src here causes
213
+ // a perceived jump back to the start mid-playback (which the
214
+ // user then notices later as a "skip" at crossover, since
215
+ // audio.currentTime captured at crossover time reflects the
216
+ // post-reset position rather than the user's actual progress).
135
217
  this._resolvedUrl = res.url;
136
- this.audio.src = res.url;
137
218
  return res.url;
138
219
  }
139
220
  return null;
@@ -149,7 +230,15 @@ export class Track {
149
230
  this._pendingArrayBuffer = null;
150
231
  if (!buf || !this.ctx) throw new Error('No ArrayBuffer or AudioContext');
151
232
  this.audioBuffer = await this.ctx.decodeAudioData(buf);
152
- queueMicrotask(() => this.queueRef.onTrackBufferReady(this));
233
+ this._maybeComputeBufferAlignment();
234
+ // NOTE: do NOT notify the queue from here. The fetchDecode child
235
+ // is about to sendParent('BUFFER_READY'), which the track machine
236
+ // handles by transitioning state (and, in html5, by performing
237
+ // the mid-stream crossover). If we notified the queue first, it
238
+ // would observe a stale playbackType ('HTML5') and incorrectly
239
+ // defer next-track preload via the HTML5 deferral guard. The
240
+ // notification is emitted from the BUFFER_READY transitions in
241
+ // track.machine, after the crossover/state update is in effect.
153
242
  }),
154
243
  },
155
244
  }),
@@ -163,21 +252,31 @@ export class Track {
163
252
  startSourceNode: () => {
164
253
  this._startSourceNode(this.pausedAtTrackTime);
165
254
  },
255
+ crossoverHtml5ToWebAudio: ({ context }: { context: TrackContext }) => {
256
+ this._crossoverHtml5ToWebAudio(context.isPlaying);
257
+ },
166
258
  startScheduledSourceNode: ({ context }: { context: TrackContext }) => {
167
259
  const when = context.scheduledStartContextTime;
168
260
  if (when === null || !this.ctx || !this.audioBuffer || !this.gainNode) return;
261
+ this._maybeComputeBufferAlignment();
169
262
  this._stopSourceNode();
170
263
  this.sourceNode = this.ctx.createBufferSource();
171
264
  this.sourceNode.buffer = this.audioBuffer;
172
265
  this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
173
266
  this.sourceNode.connect(this.gainNode);
174
- this.gainNode.connect(this.ctx.destination);
267
+ // gainNode → destination connected once at ctx setup; do not re-connect
268
+ // here or the gainNode's output is duplicated (Web Audio sums multiple
269
+ // edges between the same pair of nodes).
175
270
  this.sourceNode.onended = this._handleWebAudioEnded;
176
- this.sourceNode.start(when, 0);
271
+ // Skip past any decoded-buffer start padding (ID3 tags, encoder
272
+ // priming) so the gapless transition begins at music-time 0, not at
273
+ // half a second of silence/garbage. _bufferStartPaddingSec is
274
+ // computed at decode time via _ensureBufferAlignment.
275
+ this.sourceNode.start(when, this._bufferStartPaddingSec);
177
276
  this._waRefCtxTime = when;
178
277
  this._waRefTrackTime = 0;
179
278
  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`
279
+ `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
280
  );
182
281
  },
183
282
  startProgressLoop: () => this.startProgressLoop(),
@@ -187,7 +286,6 @@ export class Track {
187
286
  this.pausedAtTrackTime = isFinite(t) ? t : 0;
188
287
  },
189
288
  stopSourceNode: () => this._stopSourceNode(),
190
- disconnectGain: () => this._disconnectGain(),
191
289
  stopProgressLoop: () => this._stopProgressLoop(),
192
290
  reportProgress: () => {
193
291
  queueMicrotask(() => this.queueRef.onProgress(this.toInfo()));
@@ -205,6 +303,12 @@ export class Track {
205
303
  notifyTrackEnded: () => {
206
304
  queueMicrotask(() => this.queueRef.onTrackEnded(this));
207
305
  },
306
+ notifyBufferReady: () => {
307
+ // Emitted from BUFFER_READY transitions so the queue observes the
308
+ // post-transition state (e.g. WEBAUDIO after a crossover) rather
309
+ // than the pre-transition state.
310
+ queueMicrotask(() => this.queueRef.onTrackBufferReady(this));
311
+ },
208
312
  },
209
313
  });
210
314
  this._actor = createActor(machine);
@@ -235,7 +339,15 @@ export class Track {
235
339
 
236
340
  setVolume(v: number): void {
237
341
  const vol = Math.min(1, Math.max(0, v));
238
- this.audio.volume = vol;
342
+ // When the HTML5 element is routed through MediaElementAudioSourceNode,
343
+ // master volume lives entirely on gainNode — keep audio.volume at 1 so the
344
+ // browser-side and AudioContext-side gains don't multiply (giving v²).
345
+ // Otherwise (HTML5_ONLY mode, or pre-ctx), audio.volume is the master.
346
+ if (this._mediaElementSource) {
347
+ this.audio.volume = 1;
348
+ } else {
349
+ this.audio.volume = vol;
350
+ }
239
351
  if (this.gainNode) this.gainNode.gain.value = vol;
240
352
  this._actor.send({ type: 'SET_VOLUME', volume: vol });
241
353
  }
@@ -435,8 +547,112 @@ export class Track {
435
547
  // Private: Web Audio helpers
436
548
  // --------------------------------------------------------------------------
437
549
 
438
- private _startSourceNode(offset: number): void {
550
+ /**
551
+ * Mid-stream crossover: switch an actively-playing HTML5 track to Web Audio.
552
+ *
553
+ * Why this exists: we cannot reliably predict when an HTML5 <audio> element
554
+ * will fire 'ended' from within the AudioContext clock. Any prediction is
555
+ * at the mercy of the browser's audio pipeline (buffering stalls, codec
556
+ * padding differences, clock drift between audio.currentTime and
557
+ * ctx.currentTime over long sessions). Scheduling the next gapless track
558
+ * against that prediction is how overlap bugs happen.
559
+ *
560
+ * Instead, as soon as the buffer is decoded, we hand playback off to Web
561
+ * Audio while the track is still mid-song. From that point on, the track
562
+ * and all subsequent gapless transitions live on a single clock
563
+ * (AudioContext.currentTime), so scheduling is sample-accurate by
564
+ * construction — no prediction involved.
565
+ *
566
+ * Ordering: pause the HTML5 element FIRST, then start the source node at
567
+ * the captured offset. Pausing first ensures audio.currentTime is frozen
568
+ * before we read it as the Web Audio start offset, so there's no brief
569
+ * double-audio window at the crossover point.
570
+ */
571
+ private _crossoverHtml5ToWebAudio(wasPlaying: boolean): void {
572
+ if (!this.ctx || !this.audioBuffer || !this.gainNode) return;
573
+
574
+ const offset = this.audio.currentTime;
575
+ this.pausedAtTrackTime = isFinite(offset) ? offset : 0;
576
+
577
+ if (!wasPlaying) {
578
+ this.audio.pause();
579
+ this.queueRef.onDebug(
580
+ `crossoverHtml5ToWebAudio track=${this.index} offset=${this.pausedAtTrackTime.toFixed(3)} wasPlaying=false`
581
+ );
582
+ return;
583
+ }
584
+
585
+ // Sample-accurate crossfade. Both sides live on the AudioContext clock:
586
+ // • HTML5 path: mediaElementSource → _html5GainNode → gainNode → destination
587
+ // • WebAudio path: source → fadeGain → gainNode → destination
588
+ //
589
+ // We schedule a 1→0 ramp on _html5GainNode and a 0→1 ramp on the WebAudio
590
+ // fadeGain, both over CROSSOVER_FADE_SEC and both starting at the same
591
+ // ctx-clock time. The two streams overlap with constant-summed gain
592
+ // through the fade window, smoothing out the sample-level discontinuity
593
+ // that an instant cut would otherwise leave audible as a "blip".
594
+ //
595
+ // If _html5GainNode wasn't created (createMediaElementSource fell back —
596
+ // e.g. CORS blocked or legacy browser), we degrade to immediate pause +
597
+ // WebAudio fade-in only.
598
+ const t0 = this.ctx.currentTime;
599
+ const t1 = t0 + CROSSOVER_FADE_SEC;
600
+
601
+ if (this._html5GainNode) {
602
+ this._html5GainNode.gain.cancelScheduledValues(t0);
603
+ this._html5GainNode.gain.setValueAtTime(1, t0);
604
+ this._html5GainNode.gain.linearRampToValueAtTime(0, t1);
605
+ // Pause the HTML5 element after the fade completes — it stops consuming
606
+ // network/decoder resources and the gain is back to silent regardless.
607
+ // Reset the gain to 1 afterwards so future plays through this element
608
+ // (post-deactivate/reactivate) start at full level.
609
+ const ctxRef = this.ctx;
610
+ const html5GainRef = this._html5GainNode;
611
+ setTimeout(() => {
612
+ this.audio.pause();
613
+ if (ctxRef && html5GainRef) {
614
+ html5GainRef.gain.cancelScheduledValues(ctxRef.currentTime);
615
+ html5GainRef.gain.setValueAtTime(1, ctxRef.currentTime);
616
+ }
617
+ }, CROSSOVER_FADE_SEC * 1000 + 5);
618
+ } else {
619
+ // Fallback: no MediaElementSource path. Silence HTML5 immediately.
620
+ const savedVolume = this.audio.volume;
621
+ this.audio.volume = 0;
622
+ this.audio.pause();
623
+ this.audio.volume = savedVolume;
624
+ }
625
+
626
+ this._startSourceNode(this.pausedAtTrackTime, CROSSOVER_FADE_SEC);
627
+
628
+ this.queueRef.onDebug(
629
+ `crossoverHtml5ToWebAudio track=${this.index} offset=${this.pausedAtTrackTime.toFixed(3)} wasPlaying=true fade=${CROSSOVER_FADE_SEC}s mediaSource=${!!this._html5GainNode}`
630
+ );
631
+ }
632
+
633
+ /**
634
+ * Reverted: alignment-based fixes (duration-delta and buffer-silence
635
+ * scanning) reduced the perceived skip on archive.org files but did not
636
+ * eliminate it, suggesting the residual gap isn't a buffer/timeline
637
+ * alignment problem at all. Leaving _bufferStartPaddingSec at 0 (no shift)
638
+ * until we have a confirmed root cause; the field and call sites are kept
639
+ * so we can re-introduce a fix without churning the source-start code.
640
+ */
641
+ private _maybeComputeBufferAlignment(): void {
642
+ if (!this.audioBuffer) return;
643
+ if (this._bufferAlignmentMeasured) return;
644
+ this._bufferStartPaddingSec = 0;
645
+ this._bufferAlignmentMeasured = true;
646
+ this.queueRef.onDebug(
647
+ `_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)`
648
+ );
649
+ }
650
+
651
+ private _startSourceNode(offset: number, fadeInSec = 0): void {
439
652
  if (!this.ctx || !this.audioBuffer || !this.gainNode) return;
653
+ // Re-check alignment in case audio.duration became available since the
654
+ // last computation (e.g., preloaded track whose metadata load completed).
655
+ this._maybeComputeBufferAlignment();
440
656
  this._stopSourceNode();
441
657
 
442
658
  // Ensure the AudioContext is running (it may have been suspended after
@@ -448,13 +664,31 @@ export class Track {
448
664
  this.sourceNode = this.ctx.createBufferSource();
449
665
  this.sourceNode.buffer = this.audioBuffer;
450
666
  this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
451
- this.sourceNode.connect(this.gainNode);
452
- this.gainNode.connect(this.ctx.destination);
667
+
668
+ // When fadeInSec > 0 (crossover path), insert a per-source fade gain
669
+ // between the source and the master gainNode. This gain ramps 0 → 1 over
670
+ // fadeInSec so the new Web Audio output rises in step with the HTML5
671
+ // element's audio-pipeline tail decaying. The master gainNode (which
672
+ // represents user volume) is unaffected.
673
+ if (fadeInSec > 0) {
674
+ const fadeNode = this.ctx.createGain();
675
+ const t0 = this.ctx.currentTime;
676
+ fadeNode.gain.setValueAtTime(0, t0);
677
+ fadeNode.gain.linearRampToValueAtTime(1, t0 + fadeInSec);
678
+ this.sourceNode.connect(fadeNode);
679
+ fadeNode.connect(this.gainNode);
680
+ } else {
681
+ this.sourceNode.connect(this.gainNode);
682
+ }
683
+ // gainNode → destination connected once at ctx setup; do not re-connect.
453
684
  this.sourceNode.onended = this._handleWebAudioEnded;
454
685
 
455
686
  this._waRefCtxTime = this.ctx.currentTime;
456
687
  this._waRefTrackTime = offset;
457
- this.sourceNode.start(0, offset);
688
+ // Buffer-internal offset = music-time offset + decoded-buffer start padding.
689
+ // See _bufferStartPaddingSec docs for why this is necessary on MP3 files
690
+ // with ID3v2 tags or encoder priming samples.
691
+ this.sourceNode.start(0, offset + this._bufferStartPaddingSec);
458
692
  }
459
693
 
460
694
  private _stopSourceNode(): void {
@@ -473,15 +707,6 @@ export class Track {
473
707
  this.sourceNode = null;
474
708
  }
475
709
 
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
710
  private _seekWebAudio(): void {
486
711
  const snap = this._actor.getSnapshot();
487
712
  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