gapless 4.0.11 → 4.1.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/src/Queue.ts CHANGED
@@ -14,10 +14,7 @@ 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';
18
-
19
- /** Maximum number of tracks to preload ahead of the current track. */
20
- const PRELOAD_AHEAD = 2;
17
+ import type { GaplessOptions, AddTrackOptions, TrackInfo, TrackMetadata, PlaybackMethod } from './types';
21
18
 
22
19
  export class Queue implements TrackQueueRef {
23
20
  private _tracks: Track[] = [];
@@ -32,16 +29,18 @@ export class Queue implements TrackQueueRef {
32
29
  private readonly _onPlayBlocked?: () => void;
33
30
  private readonly _onDebug?: (msg: string) => void;
34
31
 
35
- readonly webAudioIsDisabled: boolean;
32
+ readonly playbackMethod: PlaybackMethod;
36
33
 
37
34
  private _volume: number;
35
+ private _preloadNumTracks: number;
36
+ private _playbackRate: number;
38
37
 
39
38
  /** Index of the next track with a pre-scheduled gapless start, or null. */
40
39
  private _scheduledNextIndex: number | null = null;
41
40
 
42
41
  private _throttledUpdatePositionState = throttle(
43
- (duration: number, currentTime: number) =>
44
- updateMediaSessionPositionState(duration, currentTime),
42
+ (duration: number, currentTime: number, playbackRate: number) =>
43
+ updateMediaSessionPositionState(duration, currentTime, playbackRate),
45
44
  1000,
46
45
  );
47
46
 
@@ -56,13 +55,17 @@ export class Queue implements TrackQueueRef {
56
55
  onError,
57
56
  onPlayBlocked,
58
57
  onDebug,
59
- webAudioIsDisabled = false,
58
+ playbackMethod = 'HYBRID',
60
59
  trackMetadata = [],
61
60
  volume: initialVolume = 1,
61
+ preloadNumTracks = 2,
62
+ playbackRate: initialPlaybackRate = 1,
62
63
  } = options;
63
64
 
64
65
  this._volume = Math.min(1, Math.max(0, initialVolume));
65
- this.webAudioIsDisabled = webAudioIsDisabled;
66
+ this._preloadNumTracks = Math.max(0, preloadNumTracks);
67
+ this._playbackRate = Math.min(4, Math.max(0.25, initialPlaybackRate));
68
+ this.playbackMethod = playbackMethod;
66
69
  this._onProgress = onProgress;
67
70
  this._onEnded = onEnded;
68
71
  this._onPlayNextTrack = onPlayNextTrack;
@@ -246,6 +249,17 @@ export class Queue implements TrackQueueRef {
246
249
  for (const track of this._tracks) track.setVolume(clamped);
247
250
  }
248
251
 
252
+ setPlaybackRate(rate: number): void {
253
+ const clamped = Math.min(4, Math.max(0.25, rate));
254
+ this._playbackRate = clamped;
255
+ this._currentTrack?.setPlaybackRate(clamped);
256
+ this._cancelScheduledGapless();
257
+ const snap = this._actor.getSnapshot();
258
+ if (snap.value === 'playing') {
259
+ this._tryScheduleGapless(snap.context.currentTrackIndex);
260
+ }
261
+ }
262
+
249
263
  addTrack(url: string, options: AddTrackOptions = {}): void {
250
264
  const index = this._tracks.length;
251
265
  const metadata = options.metadata ?? ({} as TrackMetadata);
@@ -314,6 +328,22 @@ export class Queue implements TrackQueueRef {
314
328
  return this._volume;
315
329
  }
316
330
 
331
+ get preloadNumTracks(): number {
332
+ return this._preloadNumTracks;
333
+ }
334
+
335
+ set preloadNumTracks(value: number) {
336
+ this._preloadNumTracks = Math.max(0, value);
337
+ const snap = this._actor.getSnapshot();
338
+ if (snap.value === 'playing') {
339
+ this._preloadAhead(snap.context.currentTrackIndex);
340
+ }
341
+ }
342
+
343
+ get playbackRate(): number {
344
+ return this._playbackRate;
345
+ }
346
+
317
347
  /** Snapshot of the queue state machine (state name + context). For debugging. */
318
348
  get queueSnapshot(): { state: string; context: { currentTrackIndex: number; trackCount: number } } {
319
349
  const snap = this._actor.getSnapshot();
@@ -347,7 +377,7 @@ export class Queue implements TrackQueueRef {
347
377
  onProgress(info: TrackInfo): void {
348
378
  if (info.index !== this._actor.getSnapshot().context.currentTrackIndex) return;
349
379
  if (!isNaN(info.duration)) {
350
- this._throttledUpdatePositionState(info.duration, info.currentTime);
380
+ this._throttledUpdatePositionState(info.duration, info.currentTime, this._playbackRate);
351
381
  }
352
382
  this._onProgress?.(info);
353
383
  }
@@ -357,6 +387,7 @@ export class Queue implements TrackQueueRef {
357
387
  }
358
388
 
359
389
  onPlayBlocked(): void {
390
+ this._actor.send({ type: 'PAUSE' });
360
391
  this._onPlayBlocked?.();
361
392
  }
362
393
 
@@ -392,7 +423,7 @@ export class Queue implements TrackQueueRef {
392
423
  return;
393
424
  }
394
425
  }
395
- const limit = fromIndex + PRELOAD_AHEAD + 1;
426
+ const limit = fromIndex + this._preloadNumTracks + 1;
396
427
  this.onDebug(`_preloadAhead(${fromIndex}) limit=${limit} trackCount=${this._tracks.length}`);
397
428
  for (let i = fromIndex + 1; i < this._tracks.length && i < limit; i++) {
398
429
  const t = this._tracks[i];
@@ -418,7 +449,7 @@ export class Queue implements TrackQueueRef {
418
449
 
419
450
  private _tryScheduleGapless(curIndex: number): void {
420
451
  const ctx = getAudioContext();
421
- if (!ctx || this.webAudioIsDisabled) return;
452
+ if (!ctx || this.playbackMethod === 'HTML5_ONLY') return;
422
453
 
423
454
  const nextIndex = curIndex + 1;
424
455
  if (nextIndex >= this._tracks.length) return;
@@ -450,10 +481,10 @@ export class Queue implements TrackQueueRef {
450
481
  if (isNaN(duration)) return null;
451
482
 
452
483
  if (track.scheduledStartContextTime !== null) {
453
- return track.scheduledStartContextTime + duration;
484
+ return track.scheduledStartContextTime + duration / this._playbackRate;
454
485
  }
455
486
 
456
- const remaining = duration - track.currentTime;
487
+ const remaining = (duration - track.currentTime) / this._playbackRate;
457
488
  if (remaining <= 0) return null;
458
489
  return ctx.currentTime + remaining;
459
490
  }
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
  );
@@ -172,7 +183,8 @@ export class Track {
172
183
  startProgressLoop: () => this.startProgressLoop(),
173
184
  pauseHtml5: () => this.audio.pause(),
174
185
  freezePausedTime: () => {
175
- this.pausedAtTrackTime = this.currentTime;
186
+ const t = this.currentTime;
187
+ this.pausedAtTrackTime = isFinite(t) ? t : 0;
176
188
  },
177
189
  stopSourceNode: () => this._stopSourceNode(),
178
190
  disconnectGain: () => this._disconnectGain(),
@@ -186,7 +198,8 @@ export class Track {
186
198
  this.audio.currentTime = 0;
187
199
  },
188
200
  resetTiming: () => {
189
- this.webAudioStartedAt = 0;
201
+ this._waRefCtxTime = 0;
202
+ this._waRefTrackTime = 0;
190
203
  this.pausedAtTrackTime = 0;
191
204
  },
192
205
  notifyTrackEnded: () => {
@@ -214,6 +227,7 @@ export class Track {
214
227
  }
215
228
 
216
229
  seek(time: number): void {
230
+ if (!isFinite(time)) return;
217
231
  const clamped = Math.max(0, isNaN(this.duration) ? time : Math.min(time, this.duration));
218
232
  this.pausedAtTrackTime = clamped;
219
233
  this._actor.send({ type: 'SEEK', time: clamped });
@@ -226,6 +240,17 @@ export class Track {
226
240
  this._actor.send({ type: 'SET_VOLUME', volume: vol });
227
241
  }
228
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
+
229
254
  preload(): void {
230
255
  this.queueRef.onDebug(
231
256
  `preload() track=${this.index} state=${this._actor.getSnapshot().value} hasBuffer=${!!this.audioBuffer} hasCtx=${!!this.ctx}`
@@ -294,7 +319,7 @@ export class Track {
294
319
  if (snap.value === 'webaudio') {
295
320
  if (!snap.context.isPlaying) return this.pausedAtTrackTime;
296
321
  if (!this.ctx) return 0;
297
- return Math.max(0, this.ctx.currentTime - this.webAudioStartedAt);
322
+ return Math.max(0, this._waRefTrackTime + (this.ctx.currentTime - this._waRefCtxTime) * this.queueRef.playbackRate);
298
323
  }
299
324
  return this.audio.currentTime;
300
325
  }
@@ -354,6 +379,7 @@ export class Track {
354
379
  playbackType: this.playbackType,
355
380
  webAudioLoadingState: this.webAudioLoadingState,
356
381
  metadata: this.metadata,
382
+ playbackRate: this.queueRef.playbackRate,
357
383
  machineState: this.machineState,
358
384
  };
359
385
  }
@@ -364,6 +390,7 @@ export class Track {
364
390
 
365
391
  private _playHtml5(): void {
366
392
  if (this.audio.preload !== 'auto') this.audio.preload = 'auto';
393
+ this.audio.playbackRate = this.queueRef.playbackRate;
367
394
  const promise = this.audio.play();
368
395
  if (promise) {
369
396
  promise.catch((err: unknown) => {
@@ -380,6 +407,7 @@ export class Track {
380
407
 
381
408
  private _seekHtml5(): void {
382
409
  const clamped = this.pausedAtTrackTime;
410
+ if (!isFinite(clamped)) return;
383
411
  if (this.audio.preload !== 'auto') this.audio.preload = 'auto';
384
412
  if (this.audio.readyState >= HTMLMediaElement.HAVE_METADATA) {
385
413
  this.audio.currentTime = clamped;
@@ -411,11 +439,13 @@ export class Track {
411
439
 
412
440
  this.sourceNode = this.ctx.createBufferSource();
413
441
  this.sourceNode.buffer = this.audioBuffer;
442
+ this.sourceNode.playbackRate.value = this.queueRef.playbackRate;
414
443
  this.sourceNode.connect(this.gainNode);
415
444
  this.gainNode.connect(this.ctx.destination);
416
445
  this.sourceNode.onended = this._handleWebAudioEnded;
417
446
 
418
- this.webAudioStartedAt = this.ctx.currentTime - offset;
447
+ this._waRefCtxTime = this.ctx.currentTime;
448
+ this._waRefTrackTime = offset;
419
449
  this.sourceNode.start(0, offset);
420
450
  }
421
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 }),
@@ -186,6 +192,10 @@ export function createTrackMachine(initialContext: TrackContext) {
186
192
  'startProgressLoop',
187
193
  ],
188
194
  },
195
+ {
196
+ guard: 'isWebAudioOnly',
197
+ actions: ['setPendingPlay', 'triggerFetchForPendingPlay'],
198
+ },
189
199
  {
190
200
  target: 'html5',
191
201
  actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
@@ -210,11 +220,18 @@ export function createTrackMachine(initialContext: TrackContext) {
210
220
  BUFFER_LOADING: {
211
221
  actions: 'setLoadingState',
212
222
  },
213
- BUFFER_READY: {
214
- actions: 'setLoadedState',
215
- },
223
+ BUFFER_READY: [
224
+ {
225
+ guard: ({ context }: { context: TrackContext }) => context.pendingPlay,
226
+ target: 'webaudio',
227
+ actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop'],
228
+ },
229
+ {
230
+ actions: 'setLoadedState',
231
+ },
232
+ ],
216
233
  BUFFER_ERROR: {
217
- actions: 'setErrorState',
234
+ actions: ['setErrorState', 'clearPendingPlay'],
218
235
  },
219
236
  URL_RESOLVED: {
220
237
  actions: 'setResolvedUrl',
@@ -289,13 +306,20 @@ export function createTrackMachine(initialContext: TrackContext) {
289
306
  BUFFER_LOADING: {
290
307
  actions: 'setLoadingState',
291
308
  },
292
- BUFFER_READY: {
293
- target: 'idle',
294
- actions: 'setLoadedState',
295
- },
309
+ BUFFER_READY: [
310
+ {
311
+ guard: ({ context }: { context: TrackContext }) => context.pendingPlay,
312
+ target: 'webaudio',
313
+ actions: ['clearPendingPlay', 'setPlayingWebAudio', 'startSourceNode', 'startProgressLoop'],
314
+ },
315
+ {
316
+ target: 'idle',
317
+ actions: 'setLoadedState',
318
+ },
319
+ ],
296
320
  BUFFER_ERROR: {
297
321
  target: 'idle',
298
- actions: 'setErrorState',
322
+ actions: ['setErrorState', 'clearPendingPlay'],
299
323
  },
300
324
  PLAY: [
301
325
  {
@@ -307,6 +331,10 @@ export function createTrackMachine(initialContext: TrackContext) {
307
331
  'startProgressLoop',
308
332
  ],
309
333
  },
334
+ {
335
+ guard: 'isWebAudioOnly',
336
+ actions: ['setPendingPlay', 'triggerFetchForPendingPlay'],
337
+ },
310
338
  {
311
339
  target: 'html5',
312
340
  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
  }