gapless 4.0.12 → 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
  }
@@ -393,7 +423,7 @@ export class Queue implements TrackQueueRef {
393
423
  return;
394
424
  }
395
425
  }
396
- const limit = fromIndex + PRELOAD_AHEAD + 1;
426
+ const limit = fromIndex + this._preloadNumTracks + 1;
397
427
  this.onDebug(`_preloadAhead(${fromIndex}) limit=${limit} trackCount=${this._tracks.length}`);
398
428
  for (let i = fromIndex + 1; i < this._tracks.length && i < limit; i++) {
399
429
  const t = this._tracks[i];
@@ -419,7 +449,7 @@ export class Queue implements TrackQueueRef {
419
449
 
420
450
  private _tryScheduleGapless(curIndex: number): void {
421
451
  const ctx = getAudioContext();
422
- if (!ctx || this.webAudioIsDisabled) return;
452
+ if (!ctx || this.playbackMethod === 'HTML5_ONLY') return;
423
453
 
424
454
  const nextIndex = curIndex + 1;
425
455
  if (nextIndex >= this._tracks.length) return;
@@ -451,10 +481,10 @@ export class Queue implements TrackQueueRef {
451
481
  if (isNaN(duration)) return null;
452
482
 
453
483
  if (track.scheduledStartContextTime !== null) {
454
- return track.scheduledStartContextTime + duration;
484
+ return track.scheduledStartContextTime + duration / this._playbackRate;
455
485
  }
456
486
 
457
- const remaining = duration - track.currentTime;
487
+ const remaining = (duration - track.currentTime) / this._playbackRate;
458
488
  if (remaining <= 0) return null;
459
489
  return ctx.currentTime + remaining;
460
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
  );
@@ -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 }),
@@ -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
  }