gapless 4.1.2 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +234 -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.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Gapless audio playback javascript plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
"author": "Daniel Saewitz",
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@playwright/test": "^1.
|
|
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;
|
|
@@ -132,8 +192,18 @@ export class Track {
|
|
|
132
192
|
resolveUrl: fromPromise(async ({ signal }) => {
|
|
133
193
|
const res = await fetch(this._trackUrl, { method: 'HEAD', signal });
|
|
134
194
|
if (res.redirected && res.url) {
|
|
195
|
+
// Cache the resolved URL so the WebAudio GET below uses it
|
|
196
|
+
// directly (avoiding a second redirect round-trip). Do NOT
|
|
197
|
+
// overwrite this.audio.src — the HTML5 element may currently
|
|
198
|
+
// be streaming, and assigning a new src aborts playback,
|
|
199
|
+
// resets audio.currentTime to 0, and reloads from the new
|
|
200
|
+
// URL. The browser already handles the original URL's
|
|
201
|
+
// redirect transparently for HTML5; updating src here causes
|
|
202
|
+
// a perceived jump back to the start mid-playback (which the
|
|
203
|
+
// user then notices later as a "skip" at crossover, since
|
|
204
|
+
// audio.currentTime captured at crossover time reflects the
|
|
205
|
+
// post-reset position rather than the user's actual progress).
|
|
135
206
|
this._resolvedUrl = res.url;
|
|
136
|
-
this.audio.src = res.url;
|
|
137
207
|
return res.url;
|
|
138
208
|
}
|
|
139
209
|
return null;
|
|
@@ -149,7 +219,15 @@ export class Track {
|
|
|
149
219
|
this._pendingArrayBuffer = null;
|
|
150
220
|
if (!buf || !this.ctx) throw new Error('No ArrayBuffer or AudioContext');
|
|
151
221
|
this.audioBuffer = await this.ctx.decodeAudioData(buf);
|
|
152
|
-
|
|
222
|
+
this._maybeComputeBufferAlignment();
|
|
223
|
+
// NOTE: do NOT notify the queue from here. The fetchDecode child
|
|
224
|
+
// is about to sendParent('BUFFER_READY'), which the track machine
|
|
225
|
+
// handles by transitioning state (and, in html5, by performing
|
|
226
|
+
// the mid-stream crossover). If we notified the queue first, it
|
|
227
|
+
// would observe a stale playbackType ('HTML5') and incorrectly
|
|
228
|
+
// defer next-track preload via the HTML5 deferral guard. The
|
|
229
|
+
// notification is emitted from the BUFFER_READY transitions in
|
|
230
|
+
// track.machine, after the crossover/state update is in effect.
|
|
153
231
|
}),
|
|
154
232
|
},
|
|
155
233
|
}),
|
|
@@ -163,21 +241,31 @@ export class Track {
|
|
|
163
241
|
startSourceNode: () => {
|
|
164
242
|
this._startSourceNode(this.pausedAtTrackTime);
|
|
165
243
|
},
|
|
244
|
+
crossoverHtml5ToWebAudio: ({ context }: { context: TrackContext }) => {
|
|
245
|
+
this._crossoverHtml5ToWebAudio(context.isPlaying);
|
|
246
|
+
},
|
|
166
247
|
startScheduledSourceNode: ({ context }: { context: TrackContext }) => {
|
|
167
248
|
const when = context.scheduledStartContextTime;
|
|
168
249
|
if (when === null || !this.ctx || !this.audioBuffer || !this.gainNode) return;
|
|
250
|
+
this._maybeComputeBufferAlignment();
|
|
169
251
|
this._stopSourceNode();
|
|
170
252
|
this.sourceNode = this.ctx.createBufferSource();
|
|
171
253
|
this.sourceNode.buffer = this.audioBuffer;
|
|
172
254
|
this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
|
|
173
255
|
this.sourceNode.connect(this.gainNode);
|
|
174
|
-
|
|
256
|
+
// gainNode → destination connected once at ctx setup; do not re-connect
|
|
257
|
+
// here or the gainNode's output is duplicated (Web Audio sums multiple
|
|
258
|
+
// edges between the same pair of nodes).
|
|
175
259
|
this.sourceNode.onended = this._handleWebAudioEnded;
|
|
176
|
-
|
|
260
|
+
// Skip past any decoded-buffer start padding (ID3 tags, encoder
|
|
261
|
+
// priming) so the gapless transition begins at music-time 0, not at
|
|
262
|
+
// half a second of silence/garbage. _bufferStartPaddingSec is
|
|
263
|
+
// computed at decode time via _ensureBufferAlignment.
|
|
264
|
+
this.sourceNode.start(when, this._bufferStartPaddingSec);
|
|
177
265
|
this._waRefCtxTime = when;
|
|
178
266
|
this._waRefTrackTime = 0;
|
|
179
267
|
this.queueRef.onDebug(
|
|
180
|
-
`startScheduledSourceNode track=${this.index} when=${when.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(3)} delta=${(when - this.ctx.currentTime).toFixed(3)}s`
|
|
268
|
+
`startScheduledSourceNode track=${this.index} when=${when.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(3)} delta=${(when - this.ctx.currentTime).toFixed(3)}s padding=${this._bufferStartPaddingSec.toFixed(3)}s`
|
|
181
269
|
);
|
|
182
270
|
},
|
|
183
271
|
startProgressLoop: () => this.startProgressLoop(),
|
|
@@ -187,7 +275,6 @@ export class Track {
|
|
|
187
275
|
this.pausedAtTrackTime = isFinite(t) ? t : 0;
|
|
188
276
|
},
|
|
189
277
|
stopSourceNode: () => this._stopSourceNode(),
|
|
190
|
-
disconnectGain: () => this._disconnectGain(),
|
|
191
278
|
stopProgressLoop: () => this._stopProgressLoop(),
|
|
192
279
|
reportProgress: () => {
|
|
193
280
|
queueMicrotask(() => this.queueRef.onProgress(this.toInfo()));
|
|
@@ -205,6 +292,12 @@ export class Track {
|
|
|
205
292
|
notifyTrackEnded: () => {
|
|
206
293
|
queueMicrotask(() => this.queueRef.onTrackEnded(this));
|
|
207
294
|
},
|
|
295
|
+
notifyBufferReady: () => {
|
|
296
|
+
// Emitted from BUFFER_READY transitions so the queue observes the
|
|
297
|
+
// post-transition state (e.g. WEBAUDIO after a crossover) rather
|
|
298
|
+
// than the pre-transition state.
|
|
299
|
+
queueMicrotask(() => this.queueRef.onTrackBufferReady(this));
|
|
300
|
+
},
|
|
208
301
|
},
|
|
209
302
|
});
|
|
210
303
|
this._actor = createActor(machine);
|
|
@@ -235,7 +328,15 @@ export class Track {
|
|
|
235
328
|
|
|
236
329
|
setVolume(v: number): void {
|
|
237
330
|
const vol = Math.min(1, Math.max(0, v));
|
|
238
|
-
|
|
331
|
+
// When the HTML5 element is routed through MediaElementAudioSourceNode,
|
|
332
|
+
// master volume lives entirely on gainNode — keep audio.volume at 1 so the
|
|
333
|
+
// browser-side and AudioContext-side gains don't multiply (giving v²).
|
|
334
|
+
// Otherwise (HTML5_ONLY mode, or pre-ctx), audio.volume is the master.
|
|
335
|
+
if (this._mediaElementSource) {
|
|
336
|
+
this.audio.volume = 1;
|
|
337
|
+
} else {
|
|
338
|
+
this.audio.volume = vol;
|
|
339
|
+
}
|
|
239
340
|
if (this.gainNode) this.gainNode.gain.value = vol;
|
|
240
341
|
this._actor.send({ type: 'SET_VOLUME', volume: vol });
|
|
241
342
|
}
|
|
@@ -435,8 +536,112 @@ export class Track {
|
|
|
435
536
|
// Private: Web Audio helpers
|
|
436
537
|
// --------------------------------------------------------------------------
|
|
437
538
|
|
|
438
|
-
|
|
539
|
+
/**
|
|
540
|
+
* Mid-stream crossover: switch an actively-playing HTML5 track to Web Audio.
|
|
541
|
+
*
|
|
542
|
+
* Why this exists: we cannot reliably predict when an HTML5 <audio> element
|
|
543
|
+
* will fire 'ended' from within the AudioContext clock. Any prediction is
|
|
544
|
+
* at the mercy of the browser's audio pipeline (buffering stalls, codec
|
|
545
|
+
* padding differences, clock drift between audio.currentTime and
|
|
546
|
+
* ctx.currentTime over long sessions). Scheduling the next gapless track
|
|
547
|
+
* against that prediction is how overlap bugs happen.
|
|
548
|
+
*
|
|
549
|
+
* Instead, as soon as the buffer is decoded, we hand playback off to Web
|
|
550
|
+
* Audio while the track is still mid-song. From that point on, the track
|
|
551
|
+
* and all subsequent gapless transitions live on a single clock
|
|
552
|
+
* (AudioContext.currentTime), so scheduling is sample-accurate by
|
|
553
|
+
* construction — no prediction involved.
|
|
554
|
+
*
|
|
555
|
+
* Ordering: pause the HTML5 element FIRST, then start the source node at
|
|
556
|
+
* the captured offset. Pausing first ensures audio.currentTime is frozen
|
|
557
|
+
* before we read it as the Web Audio start offset, so there's no brief
|
|
558
|
+
* double-audio window at the crossover point.
|
|
559
|
+
*/
|
|
560
|
+
private _crossoverHtml5ToWebAudio(wasPlaying: boolean): void {
|
|
439
561
|
if (!this.ctx || !this.audioBuffer || !this.gainNode) return;
|
|
562
|
+
|
|
563
|
+
const offset = this.audio.currentTime;
|
|
564
|
+
this.pausedAtTrackTime = isFinite(offset) ? offset : 0;
|
|
565
|
+
|
|
566
|
+
if (!wasPlaying) {
|
|
567
|
+
this.audio.pause();
|
|
568
|
+
this.queueRef.onDebug(
|
|
569
|
+
`crossoverHtml5ToWebAudio track=${this.index} offset=${this.pausedAtTrackTime.toFixed(3)} wasPlaying=false`
|
|
570
|
+
);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Sample-accurate crossfade. Both sides live on the AudioContext clock:
|
|
575
|
+
// • HTML5 path: mediaElementSource → _html5GainNode → gainNode → destination
|
|
576
|
+
// • WebAudio path: source → fadeGain → gainNode → destination
|
|
577
|
+
//
|
|
578
|
+
// We schedule a 1→0 ramp on _html5GainNode and a 0→1 ramp on the WebAudio
|
|
579
|
+
// fadeGain, both over CROSSOVER_FADE_SEC and both starting at the same
|
|
580
|
+
// ctx-clock time. The two streams overlap with constant-summed gain
|
|
581
|
+
// through the fade window, smoothing out the sample-level discontinuity
|
|
582
|
+
// that an instant cut would otherwise leave audible as a "blip".
|
|
583
|
+
//
|
|
584
|
+
// If _html5GainNode wasn't created (createMediaElementSource fell back —
|
|
585
|
+
// e.g. CORS blocked or legacy browser), we degrade to immediate pause +
|
|
586
|
+
// WebAudio fade-in only.
|
|
587
|
+
const t0 = this.ctx.currentTime;
|
|
588
|
+
const t1 = t0 + CROSSOVER_FADE_SEC;
|
|
589
|
+
|
|
590
|
+
if (this._html5GainNode) {
|
|
591
|
+
this._html5GainNode.gain.cancelScheduledValues(t0);
|
|
592
|
+
this._html5GainNode.gain.setValueAtTime(1, t0);
|
|
593
|
+
this._html5GainNode.gain.linearRampToValueAtTime(0, t1);
|
|
594
|
+
// Pause the HTML5 element after the fade completes — it stops consuming
|
|
595
|
+
// network/decoder resources and the gain is back to silent regardless.
|
|
596
|
+
// Reset the gain to 1 afterwards so future plays through this element
|
|
597
|
+
// (post-deactivate/reactivate) start at full level.
|
|
598
|
+
const ctxRef = this.ctx;
|
|
599
|
+
const html5GainRef = this._html5GainNode;
|
|
600
|
+
setTimeout(() => {
|
|
601
|
+
this.audio.pause();
|
|
602
|
+
if (ctxRef && html5GainRef) {
|
|
603
|
+
html5GainRef.gain.cancelScheduledValues(ctxRef.currentTime);
|
|
604
|
+
html5GainRef.gain.setValueAtTime(1, ctxRef.currentTime);
|
|
605
|
+
}
|
|
606
|
+
}, CROSSOVER_FADE_SEC * 1000 + 5);
|
|
607
|
+
} else {
|
|
608
|
+
// Fallback: no MediaElementSource path. Silence HTML5 immediately.
|
|
609
|
+
const savedVolume = this.audio.volume;
|
|
610
|
+
this.audio.volume = 0;
|
|
611
|
+
this.audio.pause();
|
|
612
|
+
this.audio.volume = savedVolume;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
this._startSourceNode(this.pausedAtTrackTime, CROSSOVER_FADE_SEC);
|
|
616
|
+
|
|
617
|
+
this.queueRef.onDebug(
|
|
618
|
+
`crossoverHtml5ToWebAudio track=${this.index} offset=${this.pausedAtTrackTime.toFixed(3)} wasPlaying=true fade=${CROSSOVER_FADE_SEC}s mediaSource=${!!this._html5GainNode}`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Reverted: alignment-based fixes (duration-delta and buffer-silence
|
|
624
|
+
* scanning) reduced the perceived skip on archive.org files but did not
|
|
625
|
+
* eliminate it, suggesting the residual gap isn't a buffer/timeline
|
|
626
|
+
* alignment problem at all. Leaving _bufferStartPaddingSec at 0 (no shift)
|
|
627
|
+
* until we have a confirmed root cause; the field and call sites are kept
|
|
628
|
+
* so we can re-introduce a fix without churning the source-start code.
|
|
629
|
+
*/
|
|
630
|
+
private _maybeComputeBufferAlignment(): void {
|
|
631
|
+
if (!this.audioBuffer) return;
|
|
632
|
+
if (this._bufferAlignmentMeasured) return;
|
|
633
|
+
this._bufferStartPaddingSec = 0;
|
|
634
|
+
this._bufferAlignmentMeasured = true;
|
|
635
|
+
this.queueRef.onDebug(
|
|
636
|
+
`_maybeComputeBufferAlignment track=${this.index} bufferDur=${this.audioBuffer.duration.toFixed(3)}s html5Dur=${isNaN(this.audio.duration) ? 'NaN' : this.audio.duration.toFixed(3) + 's'} (alignment shift disabled — see comment)`
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private _startSourceNode(offset: number, fadeInSec = 0): void {
|
|
641
|
+
if (!this.ctx || !this.audioBuffer || !this.gainNode) return;
|
|
642
|
+
// Re-check alignment in case audio.duration became available since the
|
|
643
|
+
// last computation (e.g., preloaded track whose metadata load completed).
|
|
644
|
+
this._maybeComputeBufferAlignment();
|
|
440
645
|
this._stopSourceNode();
|
|
441
646
|
|
|
442
647
|
// Ensure the AudioContext is running (it may have been suspended after
|
|
@@ -448,13 +653,31 @@ export class Track {
|
|
|
448
653
|
this.sourceNode = this.ctx.createBufferSource();
|
|
449
654
|
this.sourceNode.buffer = this.audioBuffer;
|
|
450
655
|
this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
|
|
451
|
-
|
|
452
|
-
|
|
656
|
+
|
|
657
|
+
// When fadeInSec > 0 (crossover path), insert a per-source fade gain
|
|
658
|
+
// between the source and the master gainNode. This gain ramps 0 → 1 over
|
|
659
|
+
// fadeInSec so the new Web Audio output rises in step with the HTML5
|
|
660
|
+
// element's audio-pipeline tail decaying. The master gainNode (which
|
|
661
|
+
// represents user volume) is unaffected.
|
|
662
|
+
if (fadeInSec > 0) {
|
|
663
|
+
const fadeNode = this.ctx.createGain();
|
|
664
|
+
const t0 = this.ctx.currentTime;
|
|
665
|
+
fadeNode.gain.setValueAtTime(0, t0);
|
|
666
|
+
fadeNode.gain.linearRampToValueAtTime(1, t0 + fadeInSec);
|
|
667
|
+
this.sourceNode.connect(fadeNode);
|
|
668
|
+
fadeNode.connect(this.gainNode);
|
|
669
|
+
} else {
|
|
670
|
+
this.sourceNode.connect(this.gainNode);
|
|
671
|
+
}
|
|
672
|
+
// gainNode → destination connected once at ctx setup; do not re-connect.
|
|
453
673
|
this.sourceNode.onended = this._handleWebAudioEnded;
|
|
454
674
|
|
|
455
675
|
this._waRefCtxTime = this.ctx.currentTime;
|
|
456
676
|
this._waRefTrackTime = offset;
|
|
457
|
-
|
|
677
|
+
// Buffer-internal offset = music-time offset + decoded-buffer start padding.
|
|
678
|
+
// See _bufferStartPaddingSec docs for why this is necessary on MP3 files
|
|
679
|
+
// with ID3v2 tags or encoder priming samples.
|
|
680
|
+
this.sourceNode.start(0, offset + this._bufferStartPaddingSec);
|
|
458
681
|
}
|
|
459
682
|
|
|
460
683
|
private _stopSourceNode(): void {
|
|
@@ -473,15 +696,6 @@ export class Track {
|
|
|
473
696
|
this.sourceNode = null;
|
|
474
697
|
}
|
|
475
698
|
|
|
476
|
-
private _disconnectGain(): void {
|
|
477
|
-
if (!this.gainNode || !this.ctx) return;
|
|
478
|
-
try {
|
|
479
|
-
this.gainNode.disconnect(this.ctx.destination);
|
|
480
|
-
} catch {
|
|
481
|
-
/* already disconnected */
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
699
|
private _seekWebAudio(): void {
|
|
486
700
|
const snap = this._actor.getSnapshot();
|
|
487
701
|
const wasPlaying = snap.context.isPlaying;
|
|
@@ -277,9 +277,20 @@ export function createQueueMachine(initialContext: QueueContext) {
|
|
|
277
277
|
actions: ['deactivateEndedTrack', 'notifyEnded'],
|
|
278
278
|
},
|
|
279
279
|
],
|
|
280
|
-
TRACK_LOADED:
|
|
281
|
-
|
|
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
|
|