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.
@@ -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 may be in progress.
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 eventually":
11
- // When a track's buffer finishes decoding (BUFFER_READY), webAudioLoadingState
12
- // is set to 'LOADED' regardless of what state the machine is in (html5, loading,
13
- // or idle). We intentionally do NOT switch mid-stream the track stays in html5
14
- // until the next play(). But every state handles BUFFER_READY, so the flag is
15
- // never lost, and the next play() will see the buffer and use Web Audio.
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
- // All DEACTIVATE transitions land in idle (not loading), so a deactivated track
18
- // with a loaded buffer is always in idle+LOADEDready for Web Audio on re-play.
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
- // Bug fixes in this rewrite:
21
- // #2: BUFFER_READY in html5 stays in html5 (no longer auto-transitions to webaudio)
22
- // #3: DEACTIVATE from webaudio → idle (was staying in webaudio)
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
- actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
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
- // Bug #2 fix: BUFFER_READY in html5 stays in html5, only updates loading state.
262
- // The actual switchover to webaudio only happens via explicit PLAY_WEBAUDIO.
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
- actions: 'setLoadedState',
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
- actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
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',