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/dist/index.d.ts +51 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -10
- package/src/Queue.ts +44 -8
- package/src/Track.ts +245 -20
- package/src/machines/queue.machine.ts +14 -3
- package/src/machines/track.machine.ts +63 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gapless",
|
|
3
|
-
"version": "4.1
|
|
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.
|
|
41
|
-
"@switz/eslint-config": "^
|
|
42
|
-
"@vitest/coverage-v8": "^4.
|
|
43
|
-
"eslint": "^
|
|
44
|
-
"happy-dom": "^20.
|
|
45
|
-
"playwright": "^1.
|
|
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": "^
|
|
48
|
-
"vitest": "^4.
|
|
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.
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|