gapless 4.2.3 → 4.4.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.2.3",
3
+ "version": "4.4.0",
4
4
  "description": "Gapless audio playback javascript plugin",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -20,16 +20,6 @@
20
20
  },
21
21
  "sideEffects": false,
22
22
  "private": false,
23
- "scripts": {
24
- "test": "vitest run",
25
- "build": "tsup",
26
- "build:demo": "pnpm --dir demo build",
27
- "dev": "pnpm --dir demo dev",
28
- "types": "tsc --noEmit",
29
- "test:watch": "vitest",
30
- "test:coverage": "vitest run --coverage",
31
- "prepublishOnly": "pnpm run build"
32
- },
33
23
  "repository": {
34
24
  "type": "git",
35
25
  "url": "git+https://github.com/RelistenNet/gapless.js.git"
@@ -47,8 +37,16 @@
47
37
  "typescript": "^6.0.3",
48
38
  "vitest": "^4.1.8"
49
39
  },
50
- "packageManager": "pnpm@10.30.2",
51
40
  "dependencies": {
52
41
  "xstate": "^5.32.0"
42
+ },
43
+ "scripts": {
44
+ "test": "vitest run",
45
+ "build": "tsup",
46
+ "build:demo": "pnpm --dir demo build",
47
+ "dev": "pnpm --dir demo dev",
48
+ "types": "tsc --noEmit",
49
+ "test:watch": "vitest",
50
+ "test:coverage": "vitest run --coverage"
53
51
  }
54
- }
52
+ }
package/src/Queue.ts CHANGED
@@ -18,6 +18,43 @@ import type { GaplessOptions, AddTrackOptions, TrackInfo, TrackMetadata, Playbac
18
18
 
19
19
  const MAX_SCHEDULE_LOOKAHEAD = 5;
20
20
 
21
+ /**
22
+ * Lazy singleton Blob URL for a 10-second silent WAV.
23
+ * Chrome requires a media element with >= 5 s intrinsic duration to treat it
24
+ * as a "controllable" media session. Generated once at first use; 8 kHz mono
25
+ * 8-bit keeps it ~80 KB.
26
+ */
27
+ let _silentWavUrl: string | null = null;
28
+ function getSilentWavUrl(): string {
29
+ if (_silentWavUrl) return _silentWavUrl;
30
+ const sampleRate = 8000;
31
+ const numSamples = sampleRate * 10;
32
+ const fileSize = 44 + numSamples;
33
+ const buffer = new ArrayBuffer(fileSize);
34
+ const view = new DataView(buffer);
35
+
36
+ const writeStr = (off: number, s: string) => {
37
+ for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
38
+ };
39
+ writeStr(0, 'RIFF');
40
+ view.setUint32(4, fileSize - 8, true);
41
+ writeStr(8, 'WAVE');
42
+ writeStr(12, 'fmt ');
43
+ view.setUint32(16, 16, true); // fmt chunk size
44
+ view.setUint16(20, 1, true); // PCM
45
+ view.setUint16(22, 1, true); // mono
46
+ view.setUint32(24, sampleRate, true);
47
+ view.setUint32(28, sampleRate, true); // byte rate
48
+ view.setUint16(32, 1, true); // block align
49
+ view.setUint16(34, 8, true); // 8 bits per sample
50
+ writeStr(36, 'data');
51
+ view.setUint32(40, numSamples, true);
52
+ new Uint8Array(buffer, 44).fill(128); // 8-bit PCM silence = 128
53
+
54
+ _silentWavUrl = URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }));
55
+ return _silentWavUrl;
56
+ }
57
+
21
58
  export class Queue implements TrackQueueRef {
22
59
  private _tracks: Track[] = [];
23
60
  private readonly _actor;
@@ -37,6 +74,9 @@ export class Queue implements TrackQueueRef {
37
74
  private _preloadNumTracks: number;
38
75
  private _playbackRate: number;
39
76
 
77
+ /** Silent looping element that keeps the browser's MediaSession anchor alive. */
78
+ private _mediaSessionAnchor: HTMLAudioElement | null = null;
79
+
40
80
  /** Index of the next track with a pre-scheduled gapless start, or null. */
41
81
  private _scheduledNextIndex: number | null = null;
42
82
 
@@ -177,7 +217,13 @@ export class Queue implements TrackQueueRef {
177
217
  this._actor = createActor(machine);
178
218
 
179
219
  this._actor.subscribe((snapshot) => {
180
- updateMediaSessionPlaybackState(snapshot.value === 'playing');
220
+ const playing = snapshot.value === 'playing';
221
+ updateMediaSessionPlaybackState(playing);
222
+ if (playing) {
223
+ this._startMediaSessionAnchor();
224
+ } else {
225
+ this._stopMediaSessionAnchor();
226
+ }
181
227
  });
182
228
 
183
229
  this._actor.start();
@@ -225,8 +271,7 @@ export class Queue implements TrackQueueRef {
225
271
  previous(): void {
226
272
  const ct = this._currentTrack;
227
273
  if (ct && ct.currentTime > 8) {
228
- ct.seek(0);
229
- ct.play();
274
+ this._actor.send({ type: 'SEEK', time: 0 });
230
275
  return;
231
276
  }
232
277
 
@@ -297,6 +342,8 @@ export class Queue implements TrackQueueRef {
297
342
  }
298
343
 
299
344
  destroy(): void {
345
+ this._stopMediaSessionAnchor();
346
+ this._mediaSessionAnchor = null;
300
347
  for (const track of this._tracks) track.destroy();
301
348
  this._tracks = [];
302
349
  this._actor.stop();
@@ -482,6 +529,11 @@ export class Queue implements TrackQueueRef {
482
529
  track.cancelGaplessStart();
483
530
  this.onDebug(`_cancelScheduledGapless: cancelled track ${this._scheduledNextIndex}`);
484
531
  }
532
+ // Cancel any HTML5 gain mute that was scheduled on the current track,
533
+ // but only if it's still in HTML5 state. After crossover the gain node
534
+ // is managed by the crossfade ramp — cancelScheduledValues would wipe it.
535
+ const cur = this._trackAt(this._actor.getSnapshot().context.currentTrackIndex);
536
+ if (cur && cur.playbackType === 'HTML5') cur.cancelHtml5Mute();
485
537
  this._scheduledNextIndex = null;
486
538
  }
487
539
 
@@ -516,7 +568,18 @@ export class Queue implements TrackQueueRef {
516
568
  return;
517
569
  }
518
570
 
519
- next.scheduleGaplessStart(endTime);
571
+ if (!next.scheduleGaplessStart(endTime)) {
572
+ this.onDebug(
573
+ `_tryScheduleGapless: track ${nextIndex} rejected SCHEDULE_GAPLESS (state=${next.machineState})`
574
+ );
575
+ return;
576
+ }
577
+ // When scheduling from an HTML5-clock prediction, mute the HTML5 gain
578
+ // at endTime so a stalled/late HTML5 element is silenced rather than
579
+ // overlapping the next track.
580
+ if (current.playbackType === 'HTML5') {
581
+ current.scheduleHtml5Mute(endTime);
582
+ }
520
583
  this.onDebug(
521
584
  `_tryScheduleGapless: scheduled track ${nextIndex} at endTime=${endTime.toFixed(3)} (in ${(endTime - ctx.currentTime).toFixed(1)}s) curPlaybackType=${current.playbackType}`
522
585
  );
@@ -544,4 +607,23 @@ export class Queue implements TrackQueueRef {
544
607
  if (remaining <= 0) return null;
545
608
  return ctx.currentTime + remaining;
546
609
  }
610
+
611
+ private _startMediaSessionAnchor(): void {
612
+ if (typeof Audio === 'undefined') return;
613
+ if (!this._mediaSessionAnchor) {
614
+ this._mediaSessionAnchor = new Audio(getSilentWavUrl());
615
+ this._mediaSessionAnchor.loop = true;
616
+ // Volume stays at default (1) — Chrome/Safari may ignore a zero-volume
617
+ // element for media focus. The WAV content is already silence.
618
+ }
619
+ if (this._mediaSessionAnchor.paused) {
620
+ this._mediaSessionAnchor.play().catch(() => {});
621
+ }
622
+ }
623
+
624
+ private _stopMediaSessionAnchor(): void {
625
+ if (this._mediaSessionAnchor && !this._mediaSessionAnchor.paused) {
626
+ this._mediaSessionAnchor.pause();
627
+ }
628
+ }
547
629
  }
package/src/Track.ts CHANGED
@@ -273,6 +273,11 @@ export class Track {
273
273
  // half a second of silence/garbage. _bufferStartPaddingSec is
274
274
  // computed at decode time via _ensureBufferAlignment.
275
275
  this.sourceNode.start(when, this._bufferStartPaddingSec);
276
+ const bufRemaining = this.audioBuffer.duration - this._bufferStartPaddingSec;
277
+ const schedRate = this.sourceNode.playbackRate.value || 1;
278
+ if (bufRemaining > 0) {
279
+ this.sourceNode.stop(when + bufRemaining / schedRate);
280
+ }
276
281
  this._waRefCtxTime = when;
277
282
  this._waRefTrackTime = 0;
278
283
  this.queueRef.onDebug(
@@ -353,14 +358,33 @@ export class Track {
353
358
  }
354
359
 
355
360
  setPlaybackRate(rate: number): void {
356
- // Freeze current track position at the old rate before switching
357
- if (this.ctx && this.sourceNode && this._actor.getSnapshot().context.isPlaying) {
361
+ // Freeze current track position at the old rate before switching.
362
+ // Skip the anchor rewrite during the scheduling lead (when the source
363
+ // is scheduled but ctx.currentTime hasn't reached _waRefCtxTime yet) —
364
+ // the source hasn't started producing audio, so the anchors are still
365
+ // correct for the future start. Rewriting them with a negative elapsed
366
+ // time would corrupt playbackEndContextTime and cause overlap/gap.
367
+ if (this.ctx && this.sourceNode && this._actor.getSnapshot().context.isPlaying
368
+ && this.ctx.currentTime >= this._waRefCtxTime) {
358
369
  const oldRate = this.sourceNode.playbackRate.value;
359
370
  this._waRefTrackTime = this._waRefTrackTime + (this.ctx.currentTime - this._waRefCtxTime) * oldRate;
360
371
  this._waRefCtxTime = this.ctx.currentTime;
361
372
  }
362
373
  this.audio.playbackRate = rate;
363
- if (this.sourceNode) this.sourceNode.playbackRate.value = rate;
374
+ if (this.sourceNode) {
375
+ this.sourceNode.playbackRate.value = rate;
376
+ // Re-issue the hard stop ceiling at the new end time. The spec
377
+ // allows repeated stop() calls (latest wins), so this overwrites
378
+ // the stale stop scheduled at the old rate.
379
+ if (this.audioBuffer && this.ctx) {
380
+ const remaining = this.audioBuffer.duration - this._bufferStartPaddingSec - this._waRefTrackTime;
381
+ if (remaining > 0) {
382
+ try {
383
+ this.sourceNode.stop(this._waRefCtxTime + remaining / rate);
384
+ } catch { /* already stopped */ }
385
+ }
386
+ }
387
+ }
364
388
  }
365
389
 
366
390
  preload(): void {
@@ -411,15 +435,34 @@ export class Track {
411
435
  // Gapless scheduling (called by Queue)
412
436
  // --------------------------------------------------------------------------
413
437
 
438
+ /** Schedule the HTML5 gain node to mute at `when` — safety valve so an
439
+ * HTML5 element that runs past the predicted end is silenced instead of
440
+ * overlapping the next track. No-op if the HTML5 gain path isn't active. */
441
+ scheduleHtml5Mute(when: number): void {
442
+ if (!this._html5GainNode || !this.ctx) return;
443
+ this._html5GainNode.gain.setValueAtTime(1, when - 0.005);
444
+ this._html5GainNode.gain.linearRampToValueAtTime(0, when);
445
+ }
446
+
447
+ /** Cancel any scheduled HTML5 gain mute (called when gapless is cancelled). */
448
+ cancelHtml5Mute(): void {
449
+ if (!this._html5GainNode || !this.ctx) return;
450
+ this._html5GainNode.gain.cancelScheduledValues(this.ctx.currentTime);
451
+ this._html5GainNode.gain.setValueAtTime(1, this.ctx.currentTime);
452
+ }
453
+
414
454
  cancelGaplessStart(): void {
415
455
  const snap = this._actor.getSnapshot();
416
456
  if (snap.context.scheduledStartContextTime === null) return;
417
457
  this._actor.send({ type: 'CANCEL_GAPLESS' });
418
458
  }
419
459
 
420
- scheduleGaplessStart(when: number): void {
421
- if (!this.ctx || !this.audioBuffer || !this.gainNode) return;
460
+ scheduleGaplessStart(when: number): boolean {
461
+ if (!this.ctx || !this.audioBuffer || !this.gainNode) return false;
462
+ const state = this._actor.getSnapshot().value;
463
+ if (state !== 'idle' && state !== 'loading') return false;
422
464
  this._actor.send({ type: 'SCHEDULE_GAPLESS', when });
465
+ return true;
423
466
  }
424
467
 
425
468
  // --------------------------------------------------------------------------
@@ -605,51 +648,46 @@ export class Track {
605
648
  return;
606
649
  }
607
650
 
608
- // Sample-accurate crossfade. Both sides live on the AudioContext clock:
609
- // HTML5 path: mediaElementSource _html5GainNode gainNode destination
610
- // WebAudio path: source fadeGain gainNode destination
611
- //
612
- // We schedule a 1→0 ramp on _html5GainNode and a 0→1 ramp on the WebAudio
613
- // fadeGain, both over CROSSOVER_FADE_SEC and both starting at the same
614
- // ctx-clock time. The two streams overlap with constant-summed gain
615
- // through the fade window, smoothing out the sample-level discontinuity
616
- // that an instant cut would otherwise leave audible as a "blip".
617
- //
618
- // If _html5GainNode wasn't created (createMediaElementSource fell back —
619
- // e.g. CORS blocked or legacy browser), we degrade to immediate pause +
620
- // WebAudio fade-in only.
621
- const t0 = this.ctx.currentTime;
622
- const t1 = t0 + CROSSOVER_FADE_SEC;
623
-
624
- if (this._html5GainNode) {
625
- this._html5GainNode.gain.cancelScheduledValues(t0);
626
- this._html5GainNode.gain.setValueAtTime(1, t0);
627
- this._html5GainNode.gain.linearRampToValueAtTime(0, t1);
628
- // Pause the HTML5 element after the fade completes — it stops consuming
629
- // network/decoder resources and the gain is back to silent regardless.
630
- // Reset the gain to 1 afterwards so future plays through this element
631
- // (post-deactivate/reactivate) start at full level.
651
+ // Start the WebAudio source first to learn the exact context-clock time
652
+ // it will begin producing audio (the "when" returned by _startSourceNode).
653
+ // Then align the HTML5 fade-out to that same instant so both crossfade
654
+ // halves overlap at constant-summed gain.
655
+ const when = this._startSourceNode(this.pausedAtTrackTime, CROSSOVER_FADE_SEC);
656
+
657
+ if (when !== null && this._html5GainNode) {
658
+ this._html5GainNode.gain.cancelScheduledValues(when);
659
+ this._html5GainNode.gain.setValueAtTime(1, when);
660
+ this._html5GainNode.gain.linearRampToValueAtTime(0, when + CROSSOVER_FADE_SEC);
632
661
  const ctxRef = this.ctx;
633
662
  const html5GainRef = this._html5GainNode;
663
+ const delayMs = (when - this.ctx.currentTime + CROSSOVER_FADE_SEC) * 1000 + 5;
634
664
  setTimeout(() => {
635
665
  this.audio.pause();
636
666
  if (ctxRef && html5GainRef) {
637
667
  html5GainRef.gain.cancelScheduledValues(ctxRef.currentTime);
638
668
  html5GainRef.gain.setValueAtTime(1, ctxRef.currentTime);
639
669
  }
640
- }, CROSSOVER_FADE_SEC * 1000 + 5);
641
- } else {
642
- // Fallback: no MediaElementSource path. Silence HTML5 immediately.
643
- const savedVolume = this.audio.volume;
644
- this.audio.volume = 0;
645
- this.audio.pause();
646
- this.audio.volume = savedVolume;
670
+ }, delayMs);
671
+ } else if (!this._html5GainNode) {
672
+ // No MediaElementSource path (CORS blocked or legacy browser).
673
+ // Delay the HTML5 pause until the WebAudio source actually starts
674
+ // at `when` so there's no silence gap during the scheduling lead.
675
+ if (when !== null && this.ctx) {
676
+ const delayMs = (when - this.ctx.currentTime) * 1000;
677
+ setTimeout(() => {
678
+ this.audio.volume = 0;
679
+ this.audio.pause();
680
+ this.audio.volume = 1;
681
+ }, Math.max(0, delayMs));
682
+ } else {
683
+ this.audio.volume = 0;
684
+ this.audio.pause();
685
+ this.audio.volume = 1;
686
+ }
647
687
  }
648
688
 
649
- this._startSourceNode(this.pausedAtTrackTime, CROSSOVER_FADE_SEC);
650
-
651
689
  this.queueRef.onDebug(
652
- `crossoverHtml5ToWebAudio track=${this.index} offset=${this.pausedAtTrackTime.toFixed(3)} wasPlaying=true fade=${CROSSOVER_FADE_SEC}s mediaSource=${!!this._html5GainNode}`
690
+ `crossoverHtml5ToWebAudio track=${this.index} offset=${this.pausedAtTrackTime.toFixed(3)} wasPlaying=true fade=${CROSSOVER_FADE_SEC}s mediaSource=${!!this._html5GainNode} when=${when?.toFixed(3) ?? 'null'}`
653
691
  );
654
692
  }
655
693
 
@@ -671,16 +709,13 @@ export class Track {
671
709
  );
672
710
  }
673
711
 
674
- private _startSourceNode(offset: number, fadeInSec = 0): void {
675
- if (!this.ctx || !this.audioBuffer || !this.gainNode) return;
676
- // Re-check alignment in case audio.duration became available since the
677
- // last computation (e.g., preloaded track whose metadata load completed).
712
+ private _startSourceNode(offset: number, fadeInSec = 0): number | null {
713
+ if (!this.ctx || !this.audioBuffer || !this.gainNode) return null;
678
714
  this._maybeComputeBufferAlignment();
679
715
  this._stopSourceNode();
680
716
 
681
- // Ensure the AudioContext is running (it may have been suspended after
682
- // the previous track's source was disconnected).
683
- if (this.ctx.state === 'suspended') {
717
+ const wasSuspended = this.ctx.state === 'suspended';
718
+ if (wasSuspended) {
684
719
  this.ctx.resume();
685
720
  }
686
721
 
@@ -688,30 +723,53 @@ export class Track {
688
723
  this.sourceNode.buffer = this.audioBuffer;
689
724
  this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
690
725
 
691
- // When fadeInSec > 0 (crossover path), insert a per-source fade gain
692
- // between the source and the master gainNode. This gain ramps 0 → 1 over
693
- // fadeInSec so the new Web Audio output rises in step with the HTML5
694
- // element's audio-pipeline tail decaying. The master gainNode (which
695
- // represents user volume) is unaffected.
726
+ // Schedule the source at an explicit future time rather than start(0).
727
+ // start(0) dispatches to the audio render thread asynchronously the
728
+ // actual start lags the main-thread currentTime read by up to one
729
+ // hardware callback period (5-100 ms depending on device). That δ makes
730
+ // playbackEndContextTime compute an end time δ too early, causing the
731
+ // next gapless track to overlap briefly. Using start(when) with an
732
+ // explicit when that matches _waRefCtxTime eliminates the mismatch.
733
+ const lead = wasSuspended
734
+ ? 0.15
735
+ : Math.max(0.02, 2 * ((this.ctx as unknown as { baseLatency?: number }).baseLatency || 0) + 0.01);
736
+ const when = this.ctx.currentTime + lead;
737
+
738
+ // Advance the buffer start position by the lead so the source begins
739
+ // from where playback will actually be at `when`, not where it was at
740
+ // the moment we read currentTime. This keeps the currentTime formula
741
+ // (waRefTrackTime + (ctx.currentTime - waRefCtxTime) * rate) equal to
742
+ // `offset` immediately after the call without accumulating drift across
743
+ // pause/resume cycles. During crossover it also aligns the WebAudio
744
+ // source to where the HTML5 element will be at the fade point.
745
+ // The skipped interval (≤20 ms at 1× rate) is below audible threshold.
746
+ const rate = this.queueRef.playbackRate;
747
+ const maxOffset = this.audioBuffer.duration - this._bufferStartPaddingSec;
748
+ const effectiveOffset = Math.min(offset + lead * rate, maxOffset);
749
+
696
750
  if (fadeInSec > 0) {
697
751
  const fadeNode = this.ctx.createGain();
698
- const t0 = this.ctx.currentTime;
699
- fadeNode.gain.setValueAtTime(0, t0);
700
- fadeNode.gain.linearRampToValueAtTime(1, t0 + fadeInSec);
752
+ fadeNode.gain.setValueAtTime(0, when);
753
+ fadeNode.gain.linearRampToValueAtTime(1, when + fadeInSec);
701
754
  this.sourceNode.connect(fadeNode);
702
755
  fadeNode.connect(this.gainNode);
703
756
  } else {
704
757
  this.sourceNode.connect(this.gainNode);
705
758
  }
706
- // gainNode → destination connected once at ctx setup; do not re-connect.
707
759
  this.sourceNode.onended = this._handleWebAudioEnded;
708
760
 
709
- this._waRefCtxTime = this.ctx.currentTime;
710
- this._waRefTrackTime = offset;
711
- // Buffer-internal offset = music-time offset + decoded-buffer start padding.
712
- // See _bufferStartPaddingSec docs for why this is necessary on MP3 files
713
- // with ID3v2 tags or encoder priming samples.
714
- this.sourceNode.start(0, offset + this._bufferStartPaddingSec);
761
+ this._waRefCtxTime = when;
762
+ this._waRefTrackTime = effectiveOffset;
763
+ this.sourceNode.start(when, effectiveOffset + this._bufferStartPaddingSec);
764
+
765
+ // Hard ceiling: stop the source at its computed end time so any
766
+ // platform-level late start (render thread couldn't honor `when`)
767
+ // produces an inaudible truncation instead of overlap with the next track.
768
+ const bufRemaining = this.audioBuffer.duration - this._bufferStartPaddingSec - effectiveOffset;
769
+ if (bufRemaining > 0) {
770
+ this.sourceNode.stop(when + bufRemaining / rate);
771
+ }
772
+ return when;
715
773
  }
716
774
 
717
775
  private _stopSourceNode(): void {
@@ -187,6 +187,7 @@ export function createTrackMachine(initialContext: TrackContext) {
187
187
  'resetTiming',
188
188
  'stopProgressLoop',
189
189
  'clearScheduleAndLookahead',
190
+ 'clearPendingPlay',
190
191
  ],
191
192
  },
192
193
  ACTIVATE: {
@@ -194,6 +195,7 @@ export function createTrackMachine(initialContext: TrackContext) {
194
195
  'resetTiming',
195
196
  'resetHtml5Element',
196
197
  'clearScheduleAndLookahead',
198
+ 'clearPendingPlay',
197
199
  ],
198
200
  },
199
201
  PLAY: [
@@ -400,13 +402,14 @@ export function createTrackMachine(initialContext: TrackContext) {
400
402
  target: 'idle',
401
403
  actions: [
402
404
  'clearPlayingAndSchedule',
405
+ 'clearPendingPlay',
403
406
  'resetTiming',
404
407
  'resetHtml5Element',
405
408
  ],
406
409
  },
407
410
  DEACTIVATE: {
408
411
  target: 'idle',
409
- actions: ['clearIsPlaying', 'resetTiming'],
412
+ actions: ['clearIsPlaying', 'clearPendingPlay', 'resetTiming'],
410
413
  },
411
414
  URL_RESOLVED: {
412
415
  actions: 'setResolvedUrl',