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/dist/index.d.ts +11 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -13
- package/src/Queue.ts +86 -4
- package/src/Track.ts +119 -61
- package/src/machines/track.machine.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gapless",
|
|
3
|
-
"version": "4.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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):
|
|
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
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
},
|
|
641
|
-
} else {
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
this.
|
|
646
|
-
|
|
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):
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
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
|
-
|
|
699
|
-
fadeNode.gain.
|
|
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 =
|
|
710
|
-
this._waRefTrackTime =
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
//
|
|
714
|
-
|
|
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',
|