gapless 4.0.12 → 4.1.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/src/Queue.ts CHANGED
@@ -14,10 +14,9 @@ import { createQueueMachine } from './machines/queue.machine';
14
14
  import { Track } from './Track';
15
15
  import type { TrackQueueRef } from './Track';
16
16
  import { throttle } from './utils/throttle';
17
- import type { GaplessOptions, AddTrackOptions, TrackInfo, TrackMetadata } from './types';
17
+ import type { GaplessOptions, AddTrackOptions, TrackInfo, TrackMetadata, PlaybackMethod } from './types';
18
18
 
19
- /** Maximum number of tracks to preload ahead of the current track. */
20
- const PRELOAD_AHEAD = 2;
19
+ const MAX_SCHEDULE_LOOKAHEAD = 5;
21
20
 
22
21
  export class Queue implements TrackQueueRef {
23
22
  private _tracks: Track[] = [];
@@ -32,16 +31,18 @@ export class Queue implements TrackQueueRef {
32
31
  private readonly _onPlayBlocked?: () => void;
33
32
  private readonly _onDebug?: (msg: string) => void;
34
33
 
35
- readonly webAudioIsDisabled: boolean;
34
+ readonly playbackMethod: PlaybackMethod;
36
35
 
37
36
  private _volume: number;
37
+ private _preloadNumTracks: number;
38
+ private _playbackRate: number;
38
39
 
39
40
  /** Index of the next track with a pre-scheduled gapless start, or null. */
40
41
  private _scheduledNextIndex: number | null = null;
41
42
 
42
43
  private _throttledUpdatePositionState = throttle(
43
- (duration: number, currentTime: number) =>
44
- updateMediaSessionPositionState(duration, currentTime),
44
+ (duration: number, currentTime: number, playbackRate: number) =>
45
+ updateMediaSessionPositionState(duration, currentTime, playbackRate),
45
46
  1000,
46
47
  );
47
48
 
@@ -56,13 +57,17 @@ export class Queue implements TrackQueueRef {
56
57
  onError,
57
58
  onPlayBlocked,
58
59
  onDebug,
59
- webAudioIsDisabled = false,
60
+ playbackMethod = 'HYBRID',
60
61
  trackMetadata = [],
61
62
  volume: initialVolume = 1,
63
+ preloadNumTracks = 2,
64
+ playbackRate: initialPlaybackRate = 1,
62
65
  } = options;
63
66
 
64
67
  this._volume = Math.min(1, Math.max(0, initialVolume));
65
- this.webAudioIsDisabled = webAudioIsDisabled;
68
+ this._preloadNumTracks = Math.max(0, preloadNumTracks);
69
+ this._playbackRate = Math.min(4, Math.max(0.25, initialPlaybackRate));
70
+ this.playbackMethod = playbackMethod;
66
71
  this._onProgress = onProgress;
67
72
  this._onEnded = onEnded;
68
73
  this._onPlayNextTrack = onPlayNextTrack;
@@ -246,6 +251,17 @@ export class Queue implements TrackQueueRef {
246
251
  for (const track of this._tracks) track.setVolume(clamped);
247
252
  }
248
253
 
254
+ setPlaybackRate(rate: number): void {
255
+ const clamped = Math.min(4, Math.max(0.25, rate));
256
+ this._playbackRate = clamped;
257
+ this._currentTrack?.setPlaybackRate(clamped);
258
+ this._cancelScheduledGapless();
259
+ const snap = this._actor.getSnapshot();
260
+ if (snap.value === 'playing') {
261
+ this._tryScheduleGapless(snap.context.currentTrackIndex);
262
+ }
263
+ }
264
+
249
265
  addTrack(url: string, options: AddTrackOptions = {}): void {
250
266
  const index = this._tracks.length;
251
267
  const metadata = options.metadata ?? ({} as TrackMetadata);
@@ -314,6 +330,22 @@ export class Queue implements TrackQueueRef {
314
330
  return this._volume;
315
331
  }
316
332
 
333
+ get preloadNumTracks(): number {
334
+ return this._preloadNumTracks;
335
+ }
336
+
337
+ set preloadNumTracks(value: number) {
338
+ this._preloadNumTracks = Math.max(0, value);
339
+ const snap = this._actor.getSnapshot();
340
+ if (snap.value === 'playing') {
341
+ this._preloadAhead(snap.context.currentTrackIndex);
342
+ }
343
+ }
344
+
345
+ get playbackRate(): number {
346
+ return this._playbackRate;
347
+ }
348
+
317
349
  /** Snapshot of the queue state machine (state name + context). For debugging. */
318
350
  get queueSnapshot(): { state: string; context: { currentTrackIndex: number; trackCount: number } } {
319
351
  const snap = this._actor.getSnapshot();
@@ -347,7 +379,7 @@ export class Queue implements TrackQueueRef {
347
379
  onProgress(info: TrackInfo): void {
348
380
  if (info.index !== this._actor.getSnapshot().context.currentTrackIndex) return;
349
381
  if (!isNaN(info.duration)) {
350
- this._throttledUpdatePositionState(info.duration, info.currentTime);
382
+ this._throttledUpdatePositionState(info.duration, info.currentTime, this._playbackRate);
351
383
  }
352
384
  this._onProgress?.(info);
353
385
  }
@@ -393,7 +425,7 @@ export class Queue implements TrackQueueRef {
393
425
  return;
394
426
  }
395
427
  }
396
- const limit = fromIndex + PRELOAD_AHEAD + 1;
428
+ const limit = fromIndex + this._preloadNumTracks + 1;
397
429
  this.onDebug(`_preloadAhead(${fromIndex}) limit=${limit} trackCount=${this._tracks.length}`);
398
430
  for (let i = fromIndex + 1; i < this._tracks.length && i < limit; i++) {
399
431
  const t = this._tracks[i];
@@ -419,7 +451,7 @@ export class Queue implements TrackQueueRef {
419
451
 
420
452
  private _tryScheduleGapless(curIndex: number): void {
421
453
  const ctx = getAudioContext();
422
- if (!ctx || this.webAudioIsDisabled) return;
454
+ if (!ctx || this.playbackMethod === 'HTML5_ONLY') return;
423
455
 
424
456
  const nextIndex = curIndex + 1;
425
457
  if (nextIndex >= this._tracks.length) return;
@@ -440,7 +472,18 @@ export class Queue implements TrackQueueRef {
440
472
 
441
473
  if (endTime < ctx.currentTime + 0.01) return;
442
474
 
475
+ const timeUntilEnd = endTime - ctx.currentTime;
476
+ if (current.playbackType === 'HTML5' && timeUntilEnd > MAX_SCHEDULE_LOOKAHEAD) {
477
+ this.onDebug(
478
+ `_tryScheduleGapless: deferring — HTML5 track ${curIndex} has ${timeUntilEnd.toFixed(1)}s remaining (max lookahead=${MAX_SCHEDULE_LOOKAHEAD}s)`
479
+ );
480
+ return;
481
+ }
482
+
443
483
  next.scheduleGaplessStart(endTime);
484
+ this.onDebug(
485
+ `_tryScheduleGapless: scheduled track ${nextIndex} at endTime=${endTime.toFixed(3)} (in ${(endTime - ctx.currentTime).toFixed(1)}s) curPlaybackType=${current.playbackType}`
486
+ );
444
487
  this._scheduledNextIndex = nextIndex;
445
488
  }
446
489
 
@@ -451,10 +494,10 @@ export class Queue implements TrackQueueRef {
451
494
  if (isNaN(duration)) return null;
452
495
 
453
496
  if (track.scheduledStartContextTime !== null) {
454
- return track.scheduledStartContextTime + duration;
497
+ return track.scheduledStartContextTime + duration / this._playbackRate;
455
498
  }
456
499
 
457
- const remaining = duration - track.currentTime;
500
+ const remaining = (duration - track.currentTime) / this._playbackRate;
458
501
  if (remaining <= 0) return null;
459
502
  return ctx.currentTime + remaining;
460
503
  }
package/src/Track.ts CHANGED
@@ -7,7 +7,7 @@ import { getAudioContext, resumeAudioContext } from './utils/audioContext';
7
7
  import { createTrackMachine } from './machines/track.machine';
8
8
  import { fetchDecodeMachine } from './machines/fetchDecode.machine';
9
9
  import type { TrackContext } from './machines/track.machine';
10
- import type { TrackInfo, TrackMetadata, WebAudioLoadingState, PlaybackType } from './types';
10
+ import type { TrackInfo, TrackMetadata, WebAudioLoadingState, PlaybackType, PlaybackMethod } from './types';
11
11
 
12
12
  export interface TrackQueueRef {
13
13
  onTrackEnded(track: Track): void;
@@ -18,7 +18,8 @@ export interface TrackQueueRef {
18
18
  onPlayBlocked(): void;
19
19
  onDebug(msg: string): void;
20
20
  readonly volume: number;
21
- readonly webAudioIsDisabled: boolean;
21
+ readonly playbackMethod: PlaybackMethod;
22
+ readonly playbackRate: number;
22
23
  readonly currentTrackIndex: number;
23
24
  }
24
25
 
@@ -42,10 +43,10 @@ export class Track {
42
43
  readonly audio: HTMLAudioElement;
43
44
 
44
45
  // ---- Web Audio nodes -----------------------------------------------------
45
- private readonly _webAudioDisabled: boolean;
46
+ private readonly _playbackMethod: PlaybackMethod;
46
47
 
47
48
  private get ctx(): AudioContext | null {
48
- if (this._webAudioDisabled) return null;
49
+ if (this._playbackMethod === 'HTML5_ONLY') return null;
49
50
  const context = getAudioContext();
50
51
  if (context && !this.gainNode) {
51
52
  this.gainNode = context.createGain();
@@ -57,8 +58,10 @@ export class Track {
57
58
  private gainNode: GainNode | null = null;
58
59
  private sourceNode: AudioBufferSourceNode | null = null;
59
60
  audioBuffer: AudioBuffer | null = null;
60
- /** AudioContext.currentTime when the current source node was started. */
61
- private webAudioStartedAt = 0;
61
+ /** AudioContext.currentTime at the start of the current playback segment. */
62
+ private _waRefCtxTime = 0;
63
+ /** Track position (seconds) at the start of the current playback segment. */
64
+ private _waRefTrackTime = 0;
62
65
  /** Track-time (seconds) frozen at the moment of the most recent pause. */
63
66
  private pausedAtTrackTime = 0;
64
67
  // ---- FSM -----------------------------------------------------------------
@@ -104,7 +107,7 @@ export class Track {
104
107
  this._actor.send({ type: 'HTML5_ENDED' });
105
108
  };
106
109
 
107
- this._webAudioDisabled = opts.queue.webAudioIsDisabled;
110
+ this._playbackMethod = opts.queue.playbackMethod;
108
111
 
109
112
  const initialContext: TrackContext = {
110
113
  trackUrl: this._trackUrl,
@@ -116,10 +119,12 @@ export class Track {
116
119
  scheduledStartContextTime: null,
117
120
  notifiedLookahead: false,
118
121
  fetchStarted: false,
122
+ pendingPlay: false,
119
123
  };
120
124
  const machine = createTrackMachine(initialContext).provide({
121
125
  guards: {
122
126
  canPlayWebAudio: () => !!(this.ctx && this.audioBuffer && this.gainNode),
127
+ isWebAudioOnly: () => this._playbackMethod === 'WEBAUDIO_ONLY',
123
128
  },
124
129
  actors: {
125
130
  fetchDecode: fetchDecodeMachine.provide({
@@ -150,6 +155,10 @@ export class Track {
150
155
  }),
151
156
  },
152
157
  actions: {
158
+ triggerFetchForPendingPlay: () => {
159
+ this.preload();
160
+ resumeAudioContext();
161
+ },
153
162
  playHtml5: () => this._playHtml5(),
154
163
  startSourceNode: () => {
155
164
  this._startSourceNode(this.pausedAtTrackTime);
@@ -160,11 +169,13 @@ export class Track {
160
169
  this._stopSourceNode();
161
170
  this.sourceNode = this.ctx.createBufferSource();
162
171
  this.sourceNode.buffer = this.audioBuffer;
172
+ this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
163
173
  this.sourceNode.connect(this.gainNode);
164
174
  this.gainNode.connect(this.ctx.destination);
165
175
  this.sourceNode.onended = this._handleWebAudioEnded;
166
176
  this.sourceNode.start(when, 0);
167
- this.webAudioStartedAt = when;
177
+ this._waRefCtxTime = when;
178
+ this._waRefTrackTime = 0;
168
179
  this.queueRef.onDebug(
169
180
  `startScheduledSourceNode track=${this.index} when=${when.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(3)} delta=${(when - this.ctx.currentTime).toFixed(3)}s`
170
181
  );
@@ -187,7 +198,8 @@ export class Track {
187
198
  this.audio.currentTime = 0;
188
199
  },
189
200
  resetTiming: () => {
190
- this.webAudioStartedAt = 0;
201
+ this._waRefCtxTime = 0;
202
+ this._waRefTrackTime = 0;
191
203
  this.pausedAtTrackTime = 0;
192
204
  },
193
205
  notifyTrackEnded: () => {
@@ -228,6 +240,17 @@ export class Track {
228
240
  this._actor.send({ type: 'SET_VOLUME', volume: vol });
229
241
  }
230
242
 
243
+ setPlaybackRate(rate: number): void {
244
+ // Freeze current track position at the old rate before switching
245
+ if (this.ctx && this.sourceNode && this._actor.getSnapshot().context.isPlaying) {
246
+ const oldRate = this.sourceNode.playbackRate.value;
247
+ this._waRefTrackTime = this._waRefTrackTime + (this.ctx.currentTime - this._waRefCtxTime) * oldRate;
248
+ this._waRefCtxTime = this.ctx.currentTime;
249
+ }
250
+ this.audio.playbackRate = rate;
251
+ if (this.sourceNode) this.sourceNode.playbackRate.value = rate;
252
+ }
253
+
231
254
  preload(): void {
232
255
  this.queueRef.onDebug(
233
256
  `preload() track=${this.index} state=${this._actor.getSnapshot().value} hasBuffer=${!!this.audioBuffer} hasCtx=${!!this.ctx}`
@@ -296,7 +319,7 @@ export class Track {
296
319
  if (snap.value === 'webaudio') {
297
320
  if (!snap.context.isPlaying) return this.pausedAtTrackTime;
298
321
  if (!this.ctx) return 0;
299
- return Math.max(0, this.ctx.currentTime - this.webAudioStartedAt);
322
+ return Math.max(0, this._waRefTrackTime + (this.ctx.currentTime - this._waRefCtxTime) * this.queueRef.playbackRate);
300
323
  }
301
324
  return this.audio.currentTime;
302
325
  }
@@ -356,6 +379,7 @@ export class Track {
356
379
  playbackType: this.playbackType,
357
380
  webAudioLoadingState: this.webAudioLoadingState,
358
381
  metadata: this.metadata,
382
+ playbackRate: this.queueRef.playbackRate,
359
383
  machineState: this.machineState,
360
384
  };
361
385
  }
@@ -366,6 +390,7 @@ export class Track {
366
390
 
367
391
  private _playHtml5(): void {
368
392
  if (this.audio.preload !== 'auto') this.audio.preload = 'auto';
393
+ this.audio.playbackRate = this.queueRef.playbackRate;
369
394
  const promise = this.audio.play();
370
395
  if (promise) {
371
396
  promise.catch((err: unknown) => {
@@ -414,11 +439,13 @@ export class Track {
414
439
 
415
440
  this.sourceNode = this.ctx.createBufferSource();
416
441
  this.sourceNode.buffer = this.audioBuffer;
442
+ this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
417
443
  this.sourceNode.connect(this.gainNode);
418
444
  this.gainNode.connect(this.ctx.destination);
419
445
  this.sourceNode.onended = this._handleWebAudioEnded;
420
446
 
421
- this.webAudioStartedAt = this.ctx.currentTime - offset;
447
+ this._waRefCtxTime = this.ctx.currentTime;
448
+ this._waRefTrackTime = offset;
422
449
  this.sourceNode.start(0, offset);
423
450
  }
424
451
 
@@ -39,6 +39,8 @@ export interface TrackContext {
39
39
  scheduledStartContextTime: number | null;
40
40
  notifiedLookahead: boolean;
41
41
  fetchStarted: boolean;
42
+ /** True when PLAY was received in webAudioOnly mode before buffer is ready. */
43
+ pendingPlay: boolean;
42
44
  }
43
45
 
44
46
  // ---- Events ----------------------------------------------------------------
@@ -76,6 +78,7 @@ export function createTrackMachine(initialContext: TrackContext) {
76
78
  },
77
79
  guards: {
78
80
  canPlayWebAudio: () => false,
81
+ isWebAudioOnly: () => false,
79
82
  canStartFetch: ({ context }) => context.webAudioLoadingState === 'NONE' && !context.fetchStarted,
80
83
  },
81
84
  actions: {
@@ -94,6 +97,9 @@ export function createTrackMachine(initialContext: TrackContext) {
94
97
  resetHtml5Element: () => {},
95
98
  resetTiming: () => {},
96
99
  notifyTrackEnded: () => {},
100
+ triggerFetchForPendingPlay: () => {},
101
+ setPendingPlay: assign({ pendingPlay: () => true }),
102
+ clearPendingPlay: assign({ pendingPlay: () => false }),
97
103
  setIsPlaying: assign({ isPlaying: () => true }),
98
104
  clearIsPlaying: assign({ isPlaying: () => false }),
99
105
  setLoadingState: assign({ webAudioLoadingState: () => 'LOADING' as WebAudioLoadingState }),
@@ -163,6 +169,7 @@ export function createTrackMachine(initialContext: TrackContext) {
163
169
  },
164
170
  DEACTIVATE: {
165
171
  actions: [
172
+ 'pauseHtml5',
166
173
  'resetHtml5Element',
167
174
  'resetTiming',
168
175
  'stopProgressLoop',
@@ -186,6 +193,10 @@ export function createTrackMachine(initialContext: TrackContext) {
186
193
  'startProgressLoop',
187
194
  ],
188
195
  },
196
+ {
197
+ guard: 'isWebAudioOnly',
198
+ actions: ['setPendingPlay', 'triggerFetchForPendingPlay'],
199
+ },
189
200
  {
190
201
  target: 'html5',
191
202
  actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
@@ -210,11 +221,18 @@ export function createTrackMachine(initialContext: TrackContext) {
210
221
  BUFFER_LOADING: {
211
222
  actions: 'setLoadingState',
212
223
  },
213
- BUFFER_READY: {
214
- actions: 'setLoadedState',
215
- },
224
+ BUFFER_READY: [
225
+ {
226
+ guard: ({ context }: { context: TrackContext }) => context.pendingPlay,
227
+ target: 'webaudio',
228
+ actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop'],
229
+ },
230
+ {
231
+ actions: 'setLoadedState',
232
+ },
233
+ ],
216
234
  BUFFER_ERROR: {
217
- actions: 'setErrorState',
235
+ actions: ['setErrorState', 'clearPendingPlay'],
218
236
  },
219
237
  URL_RESOLVED: {
220
238
  actions: 'setResolvedUrl',
@@ -289,13 +307,20 @@ export function createTrackMachine(initialContext: TrackContext) {
289
307
  BUFFER_LOADING: {
290
308
  actions: 'setLoadingState',
291
309
  },
292
- BUFFER_READY: {
293
- target: 'idle',
294
- actions: 'setLoadedState',
295
- },
310
+ BUFFER_READY: [
311
+ {
312
+ guard: ({ context }: { context: TrackContext }) => context.pendingPlay,
313
+ target: 'webaudio',
314
+ actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop'],
315
+ },
316
+ {
317
+ target: 'idle',
318
+ actions: 'setLoadedState',
319
+ },
320
+ ],
296
321
  BUFFER_ERROR: {
297
322
  target: 'idle',
298
- actions: 'setErrorState',
323
+ actions: ['setErrorState', 'clearPendingPlay'],
299
324
  },
300
325
  PLAY: [
301
326
  {
@@ -307,6 +332,10 @@ export function createTrackMachine(initialContext: TrackContext) {
307
332
  'startProgressLoop',
308
333
  ],
309
334
  },
335
+ {
336
+ guard: 'isWebAudioOnly',
337
+ actions: ['setPendingPlay', 'triggerFetchForPendingPlay'],
338
+ },
310
339
  {
311
340
  target: 'html5',
312
341
  actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
package/src/types.ts CHANGED
@@ -4,6 +4,14 @@
4
4
 
5
5
  export type PlaybackType = 'HTML5' | 'WEBAUDIO';
6
6
 
7
+ /**
8
+ * Controls how audio is rendered.
9
+ * - `'HYBRID'` (default): Starts with HTML5 audio, then switches to Web Audio after decode. Best for remote files.
10
+ * - `'HTML5_ONLY'`: Uses HTML5 audio exclusively. Gapless playback is not available.
11
+ * - `'WEBAUDIO_ONLY'`: Uses Web Audio API exclusively. Audio must fully buffer before playing — only use for very small or local files.
12
+ */
13
+ export type PlaybackMethod = 'HYBRID' | 'HTML5_ONLY' | 'WEBAUDIO_ONLY';
14
+
7
15
  export type WebAudioLoadingState = 'NONE' | 'LOADING' | 'LOADED' | 'ERROR';
8
16
 
9
17
  /** Metadata attached to a track (arbitrary user data). */
@@ -36,14 +44,25 @@ export interface GaplessOptions {
36
44
  /** Called when autoplay is blocked by the browser. */
37
45
  onPlayBlocked?: () => void;
38
46
  /**
39
- * Set true to disable Web Audio API entirely and use HTML5 audio only.
40
- * Gapless playback will not be available in this mode.
47
+ * Controls how audio is rendered.
48
+ * - `'HYBRID'` (default): Starts with HTML5 audio, switches to Web Audio after decode. Best for remote files.
49
+ * - `'HTML5_ONLY'`: HTML5 audio only. Gapless playback is not available.
50
+ * - `'WEBAUDIO_ONLY'`: Web Audio API only. Audio must fully buffer before playing.
41
51
  */
42
- webAudioIsDisabled?: boolean;
52
+ playbackMethod?: PlaybackMethod;
43
53
  /** Per-track metadata (aligned to the tracks array by index). */
44
54
  trackMetadata?: TrackMetadata[];
45
55
  /** Initial volume, 0.0–1.0. Defaults to 1. */
46
56
  volume?: number;
57
+ /**
58
+ * Number of tracks to preload ahead of the current track.
59
+ * Defaults to 2. Set to 0 to disable preloading.
60
+ */
61
+ preloadNumTracks?: number;
62
+ /**
63
+ * Initial playback rate, 0.25–4.0. Defaults to 1.
64
+ */
65
+ playbackRate?: number;
47
66
  }
48
67
 
49
68
  /** Options for dynamically adding a track. */
@@ -82,6 +101,8 @@ export interface TrackInfo {
82
101
  webAudioLoadingState: WebAudioLoadingState;
83
102
  /** Arbitrary metadata supplied when the track was added. */
84
103
  metadata?: TrackMetadata;
104
+ /** Current playback rate. */
105
+ playbackRate: number;
85
106
  /** Current xstate machine state for this track (e.g. 'idle', 'html5', 'webaudio'). */
86
107
  machineState: string;
87
108
  }