Skip to content

Commit 6da782d

Browse files
authored
Abort a blocked play()'s downloads; unload fixes (5.0.0-alpha.2) (#32)
* Unload tracks that leave the preload window Moving a track out of the window now aborts its download, drops a decode in progress and releases its decoded buffer, instead of only cancelling an unfinished fetch. Late fetch/decode results from an unloaded load are ignored. * Bump to 5.0.0-alpha.1 * Abort a blocked play()'s downloads When the browser refused HTML5 play() (autoplay policy, NotAllowedError), gapless reported onPlayBlocked and the queue paused, but the track stayed in the html5 state with both downloads running: the Web Audio HEAD and GET started alongside play(), and the element, switched to preload=auto, kept buffering. Nothing cancelled them; unload() refuses the current track, and the element is internal. A page whose autoplay was blocked downloaded the whole first track anyway. A blocked play() now sends PLAY_BLOCKED to the track before onPlayBlocked runs. From html5 it returns to idle, as if play() had never been called: isPlaying cleared, progress loop stopped, the element released (source dropped and reattached with preload=none, which aborts its fetch without an error event), and a Web Audio load still in flight aborted, with any decode that finishes later dropped (the same bookkeeping as unload()). The seek target is kept, so the next play() resumes where it would have. The next play() starts both loads fresh. The queue's existing PAUSE then reaches an idle track, which ignores it, so the app sees a stopped queue inside its onPlayBlocked callback. * Bump to 5.0.0-alpha.2 * Report HTML5 again when an unloaded track replays Nothing set playbackType back to HTML5. Before tracks could be unloaded that didn't matter: a track that had played on Web Audio kept its buffer, so it replayed on Web Audio. Now unload() can release that buffer, and replaying the track goes through HTML5 with playbackType still WEBAUDIO, which the queue reads in two places: - _preloadAhead's bandwidth guard (defer preloading while the current track is on HTML5 and still loading) didn't apply, so the next track downloaded alongside the current track's fresh fetch, delaying its crossover; - _cancelScheduledGapless skipped cancelling the HTML5 gain mute. The html5 state now sets playbackType to HTML5 on entry: it's HTML5 playback by definition, and both transitions into it get it. Reported by Codex review on #32. * Release a finished source node when unloading its track unload() nulled audioBuffer, but a track that ended naturally on Web Audio still held its source node: WEBAUDIO_ENDED goes to idle without stopping it, and DEACTIVATE in idle doesn't either. The node's buffer is the decoded audio, so in ordinary sequential playback every finished track kept its decoded audio (hundreds of MB for a long track) after the queue unloaded it for leaving the preload window. unload() now also releases the source node through _stopSourceNode() (onended cleared, disconnected, dropped). unload() never runs on a playing or scheduled track, so the node it finds is a finished one. Reported by Codex review on #32.
1 parent d0d7174 commit 6da782d

9 files changed

Lines changed: 337 additions & 54 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,11 @@ safe at any point:
143143
- the current entry keeps playing, untouched;
144144
- if the entry after it changed, the old one's scheduled gapless start is
145145
cancelled, and the new one is preloaded and scheduled;
146-
- downloads for entries that left the preload window (the current entry plus
147-
the next `preloadNumTracks`) are aborted, so they don't compete with the
148-
entries about to play; decoded audio is kept.
146+
- entries that left the preload window (the current entry plus the next
147+
`preloadNumTracks`) are unloaded: downloads are aborted, so they don't
148+
compete with the entries about to play, and decoded audio is released,
149+
since a decoded track takes hundreds of MB. An entry that comes back into
150+
the window loads again.
149151

150152
#### Repeat
151153

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gapless",
3-
"version": "5.0.0-alpha.0",
3+
"version": "5.0.0-alpha.2",
44
"private": false,
55
"description": "Gapless audio playback javascript plugin",
66
"license": "MIT",

‎src/Queue.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ export class Queue implements TrackQueueRef {
604604
if (this.isPlaying) {
605605
this._preloadAhead(this._currentTrack);
606606
} else {
607-
this._cancelPreloadsOutsideWindow(this._currentTrack);
607+
this._unloadOutsideWindow(this._currentTrack);
608608
}
609609
}
610610

@@ -709,9 +709,9 @@ export class Queue implements TrackQueueRef {
709709
this._preloadAhead(current);
710710
this._tryScheduleGapless(current);
711711
} else {
712-
// "Next" is unchanged, but a move can still push a downloading track
713-
// out of the window.
714-
this._cancelPreloadsOutsideWindow(current);
712+
// "Next" is unchanged, but a move can still push a loading or loaded
713+
// track out of the window.
714+
this._unloadOutsideWindow(current);
715715
}
716716
}
717717

@@ -746,7 +746,7 @@ export class Queue implements TrackQueueRef {
746746
this._preloadAhead(cur);
747747
this._tryScheduleGapless(cur);
748748
} else {
749-
this._cancelPreloadsOutsideWindow(cur);
749+
this._unloadOutsideWindow(cur);
750750
}
751751
}
752752

@@ -860,17 +860,18 @@ export class Queue implements TrackQueueRef {
860860
}
861861

862862
/**
863-
* Abort downloads for tracks that are no longer in the preload window (the
864-
* current track plus the next preloadNumTracks in play order), so they don't
865-
* compete with the tracks that are about to play. Decoded buffers are kept.
863+
* Unload tracks that are no longer in the preload window (the current track
864+
* plus the next preloadNumTracks in play order): abort their downloads, so
865+
* they don't compete with the tracks about to play, and release their
866+
* decoded audio, which is large. They load again if they come back.
866867
*/
867-
private _cancelPreloadsOutsideWindow(from: Track | null | undefined): void {
868+
private _unloadOutsideWindow(from: Track | null | undefined): void {
868869
const window = new Set(from ? this._preloadWindow(from) : []);
869-
for (const t of this._tracks) if (!window.has(t)) t.cancelPreload();
870+
for (const t of this._tracks) if (!window.has(t)) t.unload();
870871
}
871872

872873
private _preloadAhead(from: Track | null | undefined): void {
873-
this._cancelPreloadsOutsideWindow(from);
874+
this._unloadOutsideWindow(from);
874875
if (!from) return;
875876
const cur = from;
876877

‎src/Track.ts‎

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export class Track {
6060
readonly skipHEAD: boolean;
6161
/** Temporary holder between fetch and decode steps (unserializable — stays on Track class). */
6262
private _pendingArrayBuffer: ArrayBuffer | null = null;
63+
/** Bumped by unload(): a fetch or decode that finishes after it is dropped. */
64+
private _loadGeneration = 0;
6365

6466
// ---- HTML5 Audio ---------------------------------------------------------
6567
readonly audio: HTMLAudioElement;
@@ -236,15 +238,21 @@ export class Track {
236238
}),
237239
fetchAudio: fromPromise(async ({ input, signal }) => {
238240
const { resolvedUrl } = input as { resolvedUrl: string };
241+
const generation = this._loadGeneration;
239242
const res = await fetch(resolvedUrl, { signal });
240243
if (!res.ok) throw new Error(`HTTP ${res.status} for ${resolvedUrl}`);
241-
this._pendingArrayBuffer = await res.arrayBuffer();
244+
const bytes = await res.arrayBuffer();
245+
if (generation === this._loadGeneration) this._pendingArrayBuffer = bytes;
242246
}),
243247
decodeAudio: fromPromise(async () => {
244248
const buf = this._pendingArrayBuffer;
245249
this._pendingArrayBuffer = null;
246250
if (!buf || !this.ctx) throw new Error('No ArrayBuffer or AudioContext');
247-
this.audioBuffer = await this.ctx.decodeAudioData(buf);
251+
const generation = this._loadGeneration;
252+
const decoded = await this.ctx.decodeAudioData(buf);
253+
// Unloaded mid-decode (which can't be aborted): drop the result.
254+
if (generation !== this._loadGeneration) return;
255+
this.audioBuffer = decoded;
248256
this._maybeComputeBufferAlignment();
249257
}),
250258
},
@@ -309,6 +317,8 @@ export class Track {
309317
this._seekAbort = new AbortController();
310318
this.audio.currentTime = 0;
311319
},
320+
releaseHtml5Element: () => this._releaseHtml5Element(),
321+
dropPendingDecode: () => this._dropPendingDecode(),
312322
resetTiming: () => {
313323
this._waRefCtxTime = 0;
314324
this._waRefTrackTime = 0;
@@ -407,21 +417,26 @@ export class Track {
407417
}
408418

409419
/**
410-
* Abort an in-flight Web Audio download, so the track can be preloaded again
411-
* later from scratch. Only while the URL is being resolved or the file
412-
* fetched: once decoding starts the bytes are already here, and the decode
413-
* (which assigns audioBuffer itself) can't be aborted. Returns whether a
414-
* download was cancelled.
420+
* Let go of this track's Web Audio data, so a track out of the preload
421+
* window doesn't hold memory (a decoded hour of audio is hundreds of MB):
422+
* abort its download, drop a decode in progress when it finishes (decoding
423+
* can't be aborted), or release its decoded buffer. It loads again from
424+
* scratch if it comes back into the window. Never while it's playing,
425+
* scheduled to play, or waiting for its buffer to play. Returns whether
426+
* anything was let go.
415427
*/
416-
cancelPreload(): boolean {
417-
const snap = this._actor.getSnapshot();
418-
if (snap.context.webAudioLoadingState !== 'LOADING' || snap.context.isPlaying) return false;
419-
const child = snap.children.fetchDecode as { getSnapshot(): { value: unknown } } | undefined;
420-
const stage = child?.getSnapshot().value;
421-
if (stage !== 'resolvingUrl' && stage !== 'fetching') return false;
422-
this.queueRef.onDebug(`cancelPreload() track=${this.key} stage=${String(stage)}`);
423-
this._pendingArrayBuffer = null;
424-
this._actor.send({ type: 'CANCEL_PRELOAD' });
428+
unload(): boolean {
429+
const { context } = this._actor.getSnapshot();
430+
const state = context.webAudioLoadingState;
431+
if (state !== 'LOADING' && state !== 'LOADED') return false;
432+
if (context.isPlaying || context.pendingPlay) return false;
433+
this.queueRef.onDebug(`unload() track=${this.key} state=${state}`);
434+
this._dropPendingDecode();
435+
this.audioBuffer = null;
436+
// A track that ended on Web Audio keeps its finished source node, whose
437+
// buffer is the decoded audio: let it go too, or the memory stays held.
438+
this._stopSourceNode();
439+
this._actor.send({ type: 'UNLOAD' });
425440
return true;
426441
}
427442

@@ -609,6 +624,8 @@ export class Track {
609624
if (promise) {
610625
promise.catch((err: unknown) => {
611626
if (err instanceof Error && err.name === 'NotAllowedError') {
627+
// First, so the queue (and the app's callback) see a stopped track.
628+
this._actor.send({ type: 'PLAY_BLOCKED' });
612629
this.queueRef.onPlayBlocked();
613630
} else if (err instanceof Error && err.name === 'AbortError') {
614631
// Browser aborted — element will recover on next play()
@@ -619,6 +636,27 @@ export class Track {
619636
}
620637
}
621638

639+
/** A fetch or decode that finishes from now on is dropped (see unload()). */
640+
private _dropPendingDecode(): void {
641+
this._loadGeneration++;
642+
this._pendingArrayBuffer = null;
643+
}
644+
645+
/**
646+
* Stop the element's download without an error event: drop its source
647+
* (which aborts the fetch) and put it back unloaded, so the next play()
648+
* starts it fresh.
649+
*/
650+
private _releaseHtml5Element(): void {
651+
this.audio.pause();
652+
this._seekAbort.abort();
653+
this._seekAbort = new AbortController();
654+
this.audio.preload = 'none';
655+
this.audio.removeAttribute('src');
656+
this.audio.load();
657+
this.audio.src = this._trackUrl;
658+
}
659+
622660
private _seekHtml5(target: number): void {
623661
if (!isFinite(target)) return;
624662
if (this.audio.preload !== 'auto') this.audio.preload = 'auto';

‎src/machines/track.machine.ts‎

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export type TrackEvent =
5959
| { type: 'PRELOAD' }
6060
| { type: 'BUFFER_LOADING' }
6161
| { type: 'PLAY' }
62+
// The browser refused HTML5 play() (autoplay policy): nothing will play, so
63+
// nothing should keep downloading.
64+
| { type: 'PLAY_BLOCKED' }
6265
| { type: 'PLAY_WEBAUDIO' }
6366
| { type: 'PAUSE' }
6467
| { type: 'SEEK'; time: number }
@@ -69,8 +72,8 @@ export type TrackEvent =
6972
| { type: 'WEBAUDIO_ENDED' }
7073
| { type: 'URL_RESOLVED'; url: string }
7174
| { type: 'START_FETCH' }
72-
// Abort an in-flight fetch (sent by Track only before decoding starts).
73-
| { type: 'CANCEL_PRELOAD' }
75+
// Let go of Web Audio data: abort a fetch, drop a decode, release a buffer.
76+
| { type: 'UNLOAD' }
7477
| { type: 'SCHEDULE_GAPLESS'; when: number }
7578
| { type: 'CANCEL_GAPLESS' }
7679
| { type: 'LOOKAHEAD_REACHED' };
@@ -87,8 +90,10 @@ export function createTrackMachine(initialContext: TrackContext) {
8790
fetchDecode: fetchDecodeMachine,
8891
},
8992
guards: {
90-
canCancelPreload: ({ context }) =>
91-
context.webAudioLoadingState === 'LOADING' && !context.isPlaying,
93+
canUnload: ({ context }) =>
94+
(context.webAudioLoadingState === 'LOADING' || context.webAudioLoadingState === 'LOADED') &&
95+
!context.isPlaying &&
96+
!context.pendingPlay,
9297
canPlayWebAudio: () => false,
9398
isWebAudioOnly: () => false,
9499
canStartFetch: ({ context }) =>
@@ -110,6 +115,8 @@ export function createTrackMachine(initialContext: TrackContext) {
110115
seekHtml5: () => {},
111116
seekWebAudio: () => {},
112117
resetHtml5Element: () => {},
118+
releaseHtml5Element: () => {},
119+
dropPendingDecode: () => {},
113120
resetTiming: () => {},
114121
notifyTrackEnded: () => {},
115122
triggerFetchForPendingPlay: () => {},
@@ -157,6 +164,9 @@ export function createTrackMachine(initialContext: TrackContext) {
157164
setPlaybackTypeWebAudio: assign({
158165
playbackType: () => 'WEBAUDIO' as PlaybackType,
159166
}),
167+
setPlaybackTypeHtml5: assign({
168+
playbackType: () => 'HTML5' as PlaybackType,
169+
}),
160170
clearNotifiedLookahead: assign({
161171
notifiedLookahead: () => false,
162172
}),
@@ -189,10 +199,11 @@ export function createTrackMachine(initialContext: TrackContext) {
189199
}),
190200
],
191201
},
192-
// Stopping the child aborts its fetch. Never for a playing track: its
193-
// decode is what lets it cross over to Web Audio.
194-
CANCEL_PRELOAD: {
195-
guard: 'canCancelPreload',
202+
// Stopping the child aborts its fetch (Track drops a decode in flight and
203+
// the buffer itself). Never for a playing, scheduled or waiting track:
204+
// its decode is what lets it cross over to Web Audio.
205+
UNLOAD: {
206+
guard: 'canUnload',
196207
actions: ['stopFetch', 'resetLoadingState'],
197208
},
198209
},
@@ -290,6 +301,9 @@ export function createTrackMachine(initialContext: TrackContext) {
290301
// html5: HTML5 Audio is playing; WebAudio decode may be in progress
291302
// -----------------------------------------------------------------
292303
html5: {
304+
// HTML5 is the output here, whatever played before: a track that ran
305+
// on Web Audio, was unloaded and plays again starts over on HTML5.
306+
entry: 'setPlaybackTypeHtml5',
293307
on: {
294308
PAUSE: {
295309
actions: [
@@ -303,6 +317,35 @@ export function createTrackMachine(initialContext: TrackContext) {
303317
PLAY: {
304318
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
305319
},
320+
// Blocked before a sound was made: back to idle, as if play() had
321+
// never been called. The element stops buffering, and a Web Audio
322+
// download still in flight is aborted (a decoded buffer is kept: it
323+
// costs nothing more). The position is kept for the next play().
324+
PLAY_BLOCKED: [
325+
{
326+
guard: ({ context }: { context: TrackContext }) =>
327+
context.webAudioLoadingState === 'LOADING',
328+
target: 'idle',
329+
actions: [
330+
'clearIsPlaying',
331+
'stopProgressLoop',
332+
'releaseHtml5Element',
333+
'stopFetch',
334+
'resetLoadingState',
335+
'dropPendingDecode',
336+
'reportProgress',
337+
],
338+
},
339+
{
340+
target: 'idle',
341+
actions: [
342+
'clearIsPlaying',
343+
'stopProgressLoop',
344+
'releaseHtml5Element',
345+
'reportProgress',
346+
],
347+
},
348+
],
306349
BUFFER_LOADING: {
307350
actions: 'setLoadingState',
308351
},
@@ -367,8 +410,8 @@ export function createTrackMachine(initialContext: TrackContext) {
367410
// -----------------------------------------------------------------
368411
loading: {
369412
on: {
370-
CANCEL_PRELOAD: {
371-
guard: 'canCancelPreload',
413+
UNLOAD: {
414+
guard: 'canUnload',
372415
target: 'idle',
373416
actions: ['stopFetch', 'resetLoadingState'],
374417
},

‎tests/setup.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,10 @@ export class MockAudioElement {
171171

172172
load: Mock = vi.fn();
173173

174+
removeAttribute = vi.fn((name: string) => {
175+
if (name === 'src') this.src = '';
176+
});
177+
174178
addEventListener = vi.fn(
175179
(type: string, cb: EventListenerOrEventListenerObject, opts?: AddEventListenerOptions) => {
176180
if (!this._listeners[type]) this._listeners[type] = [];

0 commit comments

Comments
 (0)