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
|
@@ -3,24 +3,30 @@
|
|
|
3
3
|
//
|
|
4
4
|
// States:
|
|
5
5
|
// idle Initial state. Audio nodes not yet initialised.
|
|
6
|
-
// html5 HTML5 Audio is playing. Web Audio fetch+decode
|
|
6
|
+
// html5 HTML5 Audio is playing. Web Audio fetch+decode is in progress.
|
|
7
7
|
// loading Track is preloaded (not yet playing). Decode in progress.
|
|
8
8
|
// webaudio AudioBufferSourceNode is the active output.
|
|
9
9
|
//
|
|
10
|
-
// Design invariant — "Web Audio always wins
|
|
11
|
-
// When a track's buffer finishes decoding (BUFFER_READY)
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
10
|
+
// Design invariant — "Web Audio always wins as soon as the buffer is ready":
|
|
11
|
+
// When a track's buffer finishes decoding (BUFFER_READY) while the track is
|
|
12
|
+
// playing via HTML5, we hand playback off to Web Audio mid-stream. The
|
|
13
|
+
// HTML5 element is paused, and an AudioBufferSourceNode is started at the
|
|
14
|
+
// exact offset the HTML5 element was at. From that instant on, the track
|
|
15
|
+
// (and every gapless transition that follows it) lives on one clock
|
|
16
|
+
// (AudioContext.currentTime).
|
|
16
17
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
18
|
+
// This is fundamental to gapless correctness: we cannot predict when an
|
|
19
|
+
// HTML5 element will fire 'ended' from the AudioContext clock — the two
|
|
20
|
+
// run on independent clocks with independent jitter (buffering stalls,
|
|
21
|
+
// codec padding differences, long-session drift). Any gapless scheduling
|
|
22
|
+
// that crosses those clocks is a prediction, and predictions are how
|
|
23
|
+
// overlap bugs happen. By moving the current track to Web Audio as soon
|
|
24
|
+
// as we can, all future gapless transitions are WebAudio→WebAudio and
|
|
25
|
+
// sample-accurate by construction.
|
|
19
26
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// #4: Removed dead error state (webAudioLoadingState: 'ERROR' is sufficient)
|
|
27
|
+
// DEACTIVATE transitions from every playing state land in idle, so a
|
|
28
|
+
// deactivated track with a loaded buffer is always idle+LOADED — ready
|
|
29
|
+
// for Web Audio on re-play.
|
|
24
30
|
// ---------------------------------------------------------------------------
|
|
25
31
|
|
|
26
32
|
import { setup, assign, spawnChild } from 'xstate';
|
|
@@ -84,12 +90,13 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
84
90
|
actions: {
|
|
85
91
|
playHtml5: () => {},
|
|
86
92
|
startSourceNode: () => {},
|
|
93
|
+
crossoverHtml5ToWebAudio: () => {},
|
|
94
|
+
notifyBufferReady: () => {},
|
|
87
95
|
startScheduledSourceNode: () => {},
|
|
88
96
|
startProgressLoop: () => {},
|
|
89
97
|
pauseHtml5: () => {},
|
|
90
98
|
freezePausedTime: () => {},
|
|
91
99
|
stopSourceNode: () => {},
|
|
92
|
-
disconnectGain: () => {},
|
|
93
100
|
stopProgressLoop: () => {},
|
|
94
101
|
reportProgress: () => {},
|
|
95
102
|
seekHtml5: () => {},
|
|
@@ -132,6 +139,12 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
132
139
|
isPlaying: () => true,
|
|
133
140
|
playbackType: () => 'WEBAUDIO' as PlaybackType,
|
|
134
141
|
}),
|
|
142
|
+
setPlaybackTypeWebAudio: assign({
|
|
143
|
+
playbackType: () => 'WEBAUDIO' as PlaybackType,
|
|
144
|
+
}),
|
|
145
|
+
clearNotifiedLookahead: assign({
|
|
146
|
+
notifiedLookahead: () => false,
|
|
147
|
+
}),
|
|
135
148
|
},
|
|
136
149
|
}).createMachine({
|
|
137
150
|
id: 'track',
|
|
@@ -199,7 +212,13 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
199
212
|
},
|
|
200
213
|
{
|
|
201
214
|
target: 'html5',
|
|
202
|
-
|
|
215
|
+
// triggerFetchForPendingPlay also kicks off fetch+decode for the
|
|
216
|
+
// CURRENT track, not just the next one. That way BUFFER_READY
|
|
217
|
+
// fires while we're in html5, and crossoverHtml5ToWebAudio can
|
|
218
|
+
// hand the active track over to Web Audio mid-stream. The
|
|
219
|
+
// canStartFetch guard inside START_FETCH makes this a no-op if
|
|
220
|
+
// a fetch is already in flight (e.g. PLAY from loading state).
|
|
221
|
+
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop', 'triggerFetchForPendingPlay'],
|
|
203
222
|
},
|
|
204
223
|
],
|
|
205
224
|
PLAY_WEBAUDIO: {
|
|
@@ -225,10 +244,10 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
225
244
|
{
|
|
226
245
|
guard: ({ context }: { context: TrackContext }) => context.pendingPlay,
|
|
227
246
|
target: 'webaudio',
|
|
228
|
-
actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop'],
|
|
247
|
+
actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop', 'notifyBufferReady'],
|
|
229
248
|
},
|
|
230
249
|
{
|
|
231
|
-
actions: 'setLoadedState',
|
|
250
|
+
actions: ['setLoadedState', 'notifyBufferReady'],
|
|
232
251
|
},
|
|
233
252
|
],
|
|
234
253
|
BUFFER_ERROR: {
|
|
@@ -258,10 +277,25 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
258
277
|
target: 'webaudio',
|
|
259
278
|
actions: 'setPlayingWebAudio',
|
|
260
279
|
},
|
|
261
|
-
//
|
|
262
|
-
//
|
|
280
|
+
// Mid-stream crossover to Web Audio. Running `crossoverHtml5ToWebAudio`
|
|
281
|
+
// synchronously captures audio.currentTime, pauses the HTML5 element,
|
|
282
|
+
// and starts a Web Audio source node at that exact offset. After the
|
|
283
|
+
// transition completes we're in webaudio state with isPlaying preserved
|
|
284
|
+
// (true if the track was playing, false if paused), so a subsequent
|
|
285
|
+
// PLAY in webaudio state will resume from pausedAtTrackTime.
|
|
286
|
+
//
|
|
287
|
+
// clearNotifiedLookahead resets the gapless lookahead flag so the
|
|
288
|
+
// webaudio progress loop can re-trigger scheduling with the accurate
|
|
289
|
+
// shared-clock end time, replacing any stale HTML5-clock prediction.
|
|
263
290
|
BUFFER_READY: {
|
|
264
|
-
|
|
291
|
+
target: 'webaudio',
|
|
292
|
+
actions: [
|
|
293
|
+
'setLoadedState',
|
|
294
|
+
'crossoverHtml5ToWebAudio',
|
|
295
|
+
'setPlaybackTypeWebAudio',
|
|
296
|
+
'clearNotifiedLookahead',
|
|
297
|
+
'notifyBufferReady',
|
|
298
|
+
],
|
|
265
299
|
},
|
|
266
300
|
BUFFER_ERROR: {
|
|
267
301
|
actions: 'setErrorState',
|
|
@@ -311,11 +345,11 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
311
345
|
{
|
|
312
346
|
guard: ({ context }: { context: TrackContext }) => context.pendingPlay,
|
|
313
347
|
target: 'webaudio',
|
|
314
|
-
actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop'],
|
|
348
|
+
actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop', 'notifyBufferReady'],
|
|
315
349
|
},
|
|
316
350
|
{
|
|
317
351
|
target: 'idle',
|
|
318
|
-
actions: 'setLoadedState',
|
|
352
|
+
actions: ['setLoadedState', 'notifyBufferReady'],
|
|
319
353
|
},
|
|
320
354
|
],
|
|
321
355
|
BUFFER_ERROR: {
|
|
@@ -338,7 +372,13 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
338
372
|
},
|
|
339
373
|
{
|
|
340
374
|
target: 'html5',
|
|
341
|
-
|
|
375
|
+
// triggerFetchForPendingPlay also kicks off fetch+decode for the
|
|
376
|
+
// CURRENT track, not just the next one. That way BUFFER_READY
|
|
377
|
+
// fires while we're in html5, and crossoverHtml5ToWebAudio can
|
|
378
|
+
// hand the active track over to Web Audio mid-stream. The
|
|
379
|
+
// canStartFetch guard inside START_FETCH makes this a no-op if
|
|
380
|
+
// a fetch is already in flight (e.g. PLAY from loading state).
|
|
381
|
+
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop', 'triggerFetchForPendingPlay'],
|
|
342
382
|
},
|
|
343
383
|
],
|
|
344
384
|
PLAY_WEBAUDIO: {
|
|
@@ -384,7 +424,6 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
384
424
|
'clearIsPlaying',
|
|
385
425
|
'freezePausedTime',
|
|
386
426
|
'stopSourceNode',
|
|
387
|
-
'disconnectGain',
|
|
388
427
|
'stopProgressLoop',
|
|
389
428
|
'reportProgress',
|
|
390
429
|
],
|
|
@@ -414,7 +453,6 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
414
453
|
actions: [
|
|
415
454
|
'clearPlayingAndSchedule',
|
|
416
455
|
'stopSourceNode',
|
|
417
|
-
'disconnectGain',
|
|
418
456
|
'stopProgressLoop',
|
|
419
457
|
'resetTiming',
|
|
420
458
|
],
|
|
@@ -431,7 +469,6 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
431
469
|
actions: [
|
|
432
470
|
'clearPlayingAndSchedule',
|
|
433
471
|
'stopSourceNode',
|
|
434
|
-
'disconnectGain',
|
|
435
472
|
'stopProgressLoop',
|
|
436
473
|
'resetTiming',
|
|
437
474
|
'resetHtml5Element',
|
|
@@ -443,7 +480,6 @@ export function createTrackMachine(initialContext: TrackContext) {
|
|
|
443
480
|
actions: [
|
|
444
481
|
'clearPlayingAndSchedule',
|
|
445
482
|
'stopSourceNode',
|
|
446
|
-
'disconnectGain',
|
|
447
483
|
'resetTiming',
|
|
448
484
|
'resetHtml5Element',
|
|
449
485
|
'stopProgressLoop',
|