gapless 4.0.5

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/Track.ts ADDED
@@ -0,0 +1,490 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Track — owns one audio track's Web Audio nodes and drives TrackMachine
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { createActor, fromPromise } from 'xstate';
6
+ import { getAudioContext } from './utils/audioContext';
7
+ import { createTrackMachine } from './machines/track.machine';
8
+ import { fetchDecodeMachine } from './machines/fetchDecode.machine';
9
+ import type { TrackContext } from './machines/track.machine';
10
+ import type { TrackInfo, TrackMetadata, WebAudioLoadingState, PlaybackType } from './types';
11
+
12
+ export interface TrackQueueRef {
13
+ onTrackEnded(track: Track): void;
14
+ onTrackBufferReady(track: Track): void;
15
+ onProgress(info: TrackInfo): void;
16
+ onError(error: Error): void;
17
+ onPlayBlocked(): void;
18
+ onDebug(msg: string): void;
19
+ readonly volume: number;
20
+ readonly webAudioIsDisabled: boolean;
21
+ readonly currentTrackIndex: number;
22
+ }
23
+
24
+ /** How close to the end (in seconds) before we attempt gapless scheduling. */
25
+ const GAPLESS_SCHEDULE_LOOKAHEAD = 5;
26
+
27
+ export class Track {
28
+ readonly index: number;
29
+ readonly metadata: TrackMetadata;
30
+
31
+ private _trackUrl: string;
32
+ private _resolvedUrl: string;
33
+ private readonly skipHEAD: boolean;
34
+ /** Temporary holder between fetch and decode steps (unserializable — stays on Track class). */
35
+ private _pendingArrayBuffer: ArrayBuffer | null = null;
36
+
37
+ // ---- HTML5 Audio ---------------------------------------------------------
38
+ readonly audio: HTMLAudioElement;
39
+
40
+ // ---- Web Audio nodes -----------------------------------------------------
41
+ private readonly _webAudioDisabled: boolean;
42
+
43
+ private get ctx(): AudioContext | null {
44
+ if (this._webAudioDisabled) return null;
45
+ const context = getAudioContext();
46
+ if (context && !this.gainNode) {
47
+ this.gainNode = context.createGain();
48
+ this.gainNode.gain.value = this.audio.volume;
49
+ }
50
+ return context;
51
+ }
52
+
53
+ private gainNode: GainNode | null = null;
54
+ private sourceNode: AudioBufferSourceNode | null = null;
55
+ audioBuffer: AudioBuffer | null = null;
56
+ /** AudioContext.currentTime when the current source node was started. */
57
+ private webAudioStartedAt = 0;
58
+ /** Track-time (seconds) frozen at the moment of the most recent pause. */
59
+ private pausedAtTrackTime = 0;
60
+ // ---- FSM -----------------------------------------------------------------
61
+ private readonly _actor;
62
+
63
+ // ---- Callbacks -----------------------------------------------------------
64
+ private readonly queueRef: TrackQueueRef;
65
+ private rafId: number | null = null;
66
+
67
+ constructor(opts: {
68
+ trackUrl: string;
69
+ index: number;
70
+ queue: TrackQueueRef;
71
+ skipHEAD?: boolean;
72
+ metadata?: TrackMetadata;
73
+ }) {
74
+ this.index = opts.index;
75
+ this._trackUrl = opts.trackUrl;
76
+ this._resolvedUrl = opts.trackUrl;
77
+ this.skipHEAD = opts.skipHEAD ?? false;
78
+ this.metadata = opts.metadata ?? {};
79
+ this.queueRef = opts.queue;
80
+
81
+ // HTML5 Audio
82
+ this.audio = new Audio();
83
+ this.audio.preload = 'none';
84
+ this.audio.src = this._trackUrl;
85
+ this.audio.volume = opts.queue.volume;
86
+ this.audio.controls = false;
87
+ this.audio.onerror = () => {
88
+ const code = this.audio.error?.code;
89
+ if (code === 1) return;
90
+ const msg = this.audio.error?.message ?? 'unknown';
91
+ this.queueRef.onError(
92
+ new Error(`HTML5 audio error on track ${this.index} (code ${code}): ${msg}`)
93
+ );
94
+ };
95
+ this.audio.onended = () => {
96
+ this.queueRef.onDebug(
97
+ `audio.onended track=${this.index} machineState=${this._actor.getSnapshot().value} queueIdx=${this.queueRef.currentTrackIndex}`
98
+ );
99
+ this._actor.send({ type: 'HTML5_ENDED' });
100
+ };
101
+
102
+ this._webAudioDisabled = opts.queue.webAudioIsDisabled;
103
+
104
+ const initialContext: TrackContext = {
105
+ trackUrl: this._trackUrl,
106
+ resolvedUrl: this._trackUrl,
107
+ skipHEAD: this.skipHEAD,
108
+ playbackType: 'HTML5',
109
+ webAudioLoadingState: 'NONE',
110
+ isPlaying: false,
111
+ scheduledStartContextTime: null,
112
+ notifiedLookahead: false,
113
+ fetchDecodeRef: null,
114
+ };
115
+ const machine = createTrackMachine(initialContext).provide({
116
+ guards: {
117
+ canPlayWebAudio: () => !!(this.ctx && this.audioBuffer && this.gainNode),
118
+ },
119
+ actors: {
120
+ fetchDecode: fetchDecodeMachine.provide({
121
+ actors: {
122
+ resolveUrl: fromPromise(async ({ signal }) => {
123
+ const res = await fetch(this._trackUrl, { method: 'HEAD', signal });
124
+ if (res.redirected && res.url) {
125
+ this._resolvedUrl = res.url;
126
+ this.audio.src = res.url;
127
+ return res.url;
128
+ }
129
+ return null;
130
+ }),
131
+ fetchAudio: fromPromise(async ({ input, signal }) => {
132
+ const { resolvedUrl } = input as { resolvedUrl: string };
133
+ const res = await fetch(resolvedUrl, { signal });
134
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${resolvedUrl}`);
135
+ this._pendingArrayBuffer = await res.arrayBuffer();
136
+ }),
137
+ decodeAudio: fromPromise(async () => {
138
+ const buf = this._pendingArrayBuffer;
139
+ this._pendingArrayBuffer = null;
140
+ if (!buf || !this.ctx) throw new Error('No ArrayBuffer or AudioContext');
141
+ this.audioBuffer = await this.ctx.decodeAudioData(buf);
142
+ queueMicrotask(() => this.queueRef.onTrackBufferReady(this));
143
+ }),
144
+ },
145
+ }),
146
+ },
147
+ actions: {
148
+ playHtml5: () => this._playHtml5(),
149
+ startSourceNode: () => {
150
+ this._startSourceNode(this.pausedAtTrackTime);
151
+ },
152
+ startScheduledSourceNode: ({ context }: { context: TrackContext }) => {
153
+ const when = context.scheduledStartContextTime;
154
+ if (when === null || !this.ctx || !this.audioBuffer || !this.gainNode) return;
155
+ this._stopSourceNode();
156
+ this.sourceNode = this.ctx.createBufferSource();
157
+ this.sourceNode.buffer = this.audioBuffer;
158
+ this.sourceNode.connect(this.gainNode);
159
+ this.gainNode.connect(this.ctx.destination);
160
+ this.sourceNode.onended = this._handleWebAudioEnded;
161
+ this.sourceNode.start(when, 0);
162
+ this.webAudioStartedAt = when;
163
+ this.queueRef.onDebug(
164
+ `startScheduledSourceNode track=${this.index} when=${when.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(3)} delta=${(when - this.ctx.currentTime).toFixed(3)}s`
165
+ );
166
+ },
167
+ startProgressLoop: () => this.startProgressLoop(),
168
+ pauseHtml5: () => this.audio.pause(),
169
+ freezePausedTime: () => {
170
+ this.pausedAtTrackTime = this.currentTime;
171
+ },
172
+ stopSourceNode: () => this._stopSourceNode(),
173
+ disconnectGain: () => this._disconnectGain(),
174
+ stopProgressLoop: () => this._stopProgressLoop(),
175
+ reportProgress: () => this.queueRef.onProgress(this.toInfo()),
176
+ seekHtml5: () => this._seekHtml5(),
177
+ seekWebAudio: () => this._seekWebAudio(),
178
+ resetHtml5Element: () => {
179
+ this.audio.currentTime = 0;
180
+ },
181
+ resetTiming: () => {
182
+ this.webAudioStartedAt = 0;
183
+ this.pausedAtTrackTime = 0;
184
+ },
185
+ notifyTrackEnded: () => {
186
+ queueMicrotask(() => this.queueRef.onTrackEnded(this));
187
+ },
188
+ },
189
+ });
190
+ this._actor = createActor(machine);
191
+ this._actor.start();
192
+ }
193
+
194
+ // --------------------------------------------------------------------------
195
+ // Public playback controls
196
+ // --------------------------------------------------------------------------
197
+
198
+ play(): void {
199
+ this.queueRef.onDebug(
200
+ `Track.play() track=${this.index} machineState=${this._actor.getSnapshot().value} hasBuffer=${!!this.audioBuffer} hasCtx=${!!this.ctx} audioPaused=${this.audio.paused}`
201
+ );
202
+ this._actor.send({ type: 'PLAY' });
203
+ }
204
+
205
+ pause(): void {
206
+ this._actor.send({ type: 'PAUSE' });
207
+ }
208
+
209
+ seek(time: number): void {
210
+ const clamped = Math.max(0, isNaN(this.duration) ? time : Math.min(time, this.duration));
211
+ this.pausedAtTrackTime = clamped;
212
+ this._actor.send({ type: 'SEEK', time: clamped });
213
+ }
214
+
215
+ setVolume(v: number): void {
216
+ const vol = Math.min(1, Math.max(0, v));
217
+ this.audio.volume = vol;
218
+ if (this.gainNode) this.gainNode.gain.value = vol;
219
+ this._actor.send({ type: 'SET_VOLUME', volume: vol });
220
+ }
221
+
222
+ preload(): void {
223
+ this.queueRef.onDebug(
224
+ `preload() track=${this.index} state=${this._actor.getSnapshot().value} hasBuffer=${!!this.audioBuffer} hasCtx=${!!this.ctx}`
225
+ );
226
+ if (this._actor.getSnapshot().value === 'idle') {
227
+ this._actor.send({ type: 'PRELOAD' });
228
+ }
229
+ if (this.audioBuffer || !this.ctx) return;
230
+ this._actor.send({ type: 'START_FETCH' });
231
+ }
232
+
233
+ seekToEnd(secondsFromEnd = 6): void {
234
+ const dur = this.duration;
235
+ if (!isNaN(dur) && dur > secondsFromEnd) {
236
+ this.seek(dur - secondsFromEnd);
237
+ }
238
+ }
239
+
240
+ activate(): void {
241
+ this._actor.send({ type: 'ACTIVATE' });
242
+ }
243
+
244
+ deactivate(): void {
245
+ this.queueRef.onDebug(
246
+ `Track.deactivate() track=${this.index} machineState=${this._actor.getSnapshot().value} isPlaying=${this.isPlaying}`
247
+ );
248
+ this._actor.send({ type: 'DEACTIVATE' });
249
+ this.queueRef.onDebug(
250
+ `Track.deactivate() done track=${this.index} machineState=${this._actor.getSnapshot().value}`
251
+ );
252
+ }
253
+
254
+ destroy(): void {
255
+ this.deactivate();
256
+ this._pendingArrayBuffer = null;
257
+ this.audioBuffer = null;
258
+ this.gainNode?.disconnect();
259
+ this.gainNode = null;
260
+ this._actor.stop(); // Stops spawned fetchDecode child actor, aborting in-flight fetches
261
+ }
262
+
263
+ // --------------------------------------------------------------------------
264
+ // Gapless scheduling (called by Queue)
265
+ // --------------------------------------------------------------------------
266
+
267
+ cancelGaplessStart(): void {
268
+ const snap = this._actor.getSnapshot();
269
+ if (snap.context.scheduledStartContextTime === null) return;
270
+ this._actor.send({ type: 'CANCEL_GAPLESS' });
271
+ }
272
+
273
+ scheduleGaplessStart(when: number): void {
274
+ if (!this.ctx || !this.audioBuffer || !this.gainNode) return;
275
+ this._actor.send({ type: 'SCHEDULE_GAPLESS', when });
276
+ }
277
+
278
+ // --------------------------------------------------------------------------
279
+ // Getters
280
+ // --------------------------------------------------------------------------
281
+
282
+ get currentTime(): number {
283
+ const snap = this._actor.getSnapshot();
284
+ if (snap.value === 'webaudio') {
285
+ if (!snap.context.isPlaying) return this.pausedAtTrackTime;
286
+ if (!this.ctx) return 0;
287
+ return Math.max(0, this.ctx.currentTime - this.webAudioStartedAt);
288
+ }
289
+ return this.audio.currentTime;
290
+ }
291
+
292
+ get duration(): number {
293
+ if (this.audioBuffer) return this.audioBuffer.duration;
294
+ return this.audio.duration;
295
+ }
296
+
297
+ get isPaused(): boolean {
298
+ const snap = this._actor.getSnapshot();
299
+ if (snap.value === 'webaudio') return !snap.context.isPlaying;
300
+ return this.audio.paused;
301
+ }
302
+
303
+ get isPlaying(): boolean {
304
+ return this._actor.getSnapshot().context.isPlaying;
305
+ }
306
+
307
+ get trackUrl(): string {
308
+ return this._resolvedUrl;
309
+ }
310
+
311
+ get playbackType(): PlaybackType {
312
+ return this._actor.getSnapshot().context.playbackType;
313
+ }
314
+
315
+ get webAudioLoadingState(): WebAudioLoadingState {
316
+ return this._actor.getSnapshot().context.webAudioLoadingState;
317
+ }
318
+
319
+ get hasSourceNode(): boolean {
320
+ return this.sourceNode !== null;
321
+ }
322
+
323
+ get machineState(): string {
324
+ return this._actor.getSnapshot().value as string;
325
+ }
326
+
327
+ get scheduledStartContextTime(): number | null {
328
+ return this._actor.getSnapshot().context.scheduledStartContextTime;
329
+ }
330
+
331
+ get isBufferLoaded(): boolean {
332
+ return this.audioBuffer !== null;
333
+ }
334
+
335
+ toInfo(): TrackInfo {
336
+ return {
337
+ index: this.index,
338
+ currentTime: this.currentTime,
339
+ duration: this.duration,
340
+ isPlaying: this.isPlaying,
341
+ isPaused: this.isPaused,
342
+ volume: this.gainNode?.gain.value ?? this.audio.volume,
343
+ trackUrl: this.trackUrl,
344
+ playbackType: this.playbackType,
345
+ webAudioLoadingState: this.webAudioLoadingState,
346
+ metadata: this.metadata,
347
+ machineState: this.machineState,
348
+ };
349
+ }
350
+
351
+ // --------------------------------------------------------------------------
352
+ // Private: HTML5 helpers
353
+ // --------------------------------------------------------------------------
354
+
355
+ private _playHtml5(): void {
356
+ if (this.audio.preload !== 'auto') this.audio.preload = 'auto';
357
+ const promise = this.audio.play();
358
+ if (promise) {
359
+ promise.catch((err: unknown) => {
360
+ if (err instanceof Error && err.name === 'NotAllowedError') {
361
+ this.queueRef.onPlayBlocked();
362
+ } else if (err instanceof Error && err.name === 'AbortError') {
363
+ // Browser aborted — element will recover on next play()
364
+ } else {
365
+ this.queueRef.onError(err instanceof Error ? err : new Error(String(err)));
366
+ }
367
+ });
368
+ }
369
+ }
370
+
371
+ private _seekHtml5(): void {
372
+ const clamped = this.pausedAtTrackTime;
373
+ if (this.audio.preload !== 'auto') this.audio.preload = 'auto';
374
+ if (this.audio.readyState >= HTMLMediaElement.HAVE_METADATA) {
375
+ this.audio.currentTime = clamped;
376
+ } else {
377
+ this.audio.addEventListener(
378
+ 'loadedmetadata',
379
+ () => {
380
+ this.audio.currentTime = clamped;
381
+ },
382
+ { once: true }
383
+ );
384
+ this.audio.load();
385
+ }
386
+ }
387
+
388
+ // --------------------------------------------------------------------------
389
+ // Private: Web Audio helpers
390
+ // --------------------------------------------------------------------------
391
+
392
+ private _startSourceNode(offset: number): void {
393
+ if (!this.ctx || !this.audioBuffer || !this.gainNode) return;
394
+ this._stopSourceNode();
395
+
396
+ // Ensure the AudioContext is running (it may have been suspended after
397
+ // the previous track's source was disconnected).
398
+ if (this.ctx.state === 'suspended') {
399
+ this.ctx.resume();
400
+ }
401
+
402
+ this.sourceNode = this.ctx.createBufferSource();
403
+ this.sourceNode.buffer = this.audioBuffer;
404
+ this.sourceNode.connect(this.gainNode);
405
+ this.gainNode.connect(this.ctx.destination);
406
+ this.sourceNode.onended = this._handleWebAudioEnded;
407
+
408
+ this.webAudioStartedAt = this.ctx.currentTime - offset;
409
+ this.sourceNode.start(0, offset);
410
+ }
411
+
412
+ private _stopSourceNode(): void {
413
+ if (!this.sourceNode) return;
414
+ this.sourceNode.onended = null;
415
+ try {
416
+ this.sourceNode.stop();
417
+ } catch {
418
+ /* already stopped */
419
+ }
420
+ try {
421
+ this.sourceNode.disconnect();
422
+ } catch {
423
+ /* already disconnected */
424
+ }
425
+ this.sourceNode = null;
426
+ }
427
+
428
+ private _disconnectGain(): void {
429
+ if (!this.gainNode || !this.ctx) return;
430
+ try {
431
+ this.gainNode.disconnect(this.ctx.destination);
432
+ } catch {
433
+ /* already disconnected */
434
+ }
435
+ }
436
+
437
+ private _seekWebAudio(): void {
438
+ const snap = this._actor.getSnapshot();
439
+ const wasPlaying = snap.context.isPlaying;
440
+ const clamped = this.pausedAtTrackTime;
441
+ this._stopSourceNode();
442
+ if (wasPlaying) {
443
+ this._startSourceNode(clamped);
444
+ }
445
+ }
446
+
447
+ private _handleWebAudioEnded = (): void => {
448
+ this.queueRef.onDebug(
449
+ `_handleWebAudioEnded track=${this.index} sourceNode=${!!this.sourceNode} queueIdx=${this.queueRef.currentTrackIndex}`
450
+ );
451
+ if (!this.sourceNode) return;
452
+ this._actor.send({ type: 'WEBAUDIO_ENDED' });
453
+ };
454
+
455
+ // --------------------------------------------------------------------------
456
+ // Private: progress loop (requestAnimationFrame)
457
+ // --------------------------------------------------------------------------
458
+
459
+ startProgressLoop(): void {
460
+ if (this.rafId !== null) return;
461
+ const loop = () => {
462
+ if (this.isPaused || !this.isPlaying) {
463
+ this.rafId = null;
464
+ return;
465
+ }
466
+ this.queueRef.onProgress(this.toInfo());
467
+
468
+ const remaining = this.duration - this.currentTime;
469
+ const snap = this._actor.getSnapshot();
470
+ if (
471
+ !snap.context.notifiedLookahead &&
472
+ !isNaN(remaining) &&
473
+ remaining <= GAPLESS_SCHEDULE_LOOKAHEAD
474
+ ) {
475
+ this._actor.send({ type: 'LOOKAHEAD_REACHED' });
476
+ queueMicrotask(() => this.queueRef.onTrackBufferReady(this));
477
+ }
478
+
479
+ this.rafId = requestAnimationFrame(loop);
480
+ };
481
+ this.rafId = requestAnimationFrame(loop);
482
+ }
483
+
484
+ private _stopProgressLoop(): void {
485
+ if (this.rafId !== null) {
486
+ cancelAnimationFrame(this.rafId);
487
+ this.rafId = null;
488
+ }
489
+ }
490
+ }