Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,11 @@ safe at any point:
- the current entry keeps playing, untouched;
- if the entry after it changed, the old one's scheduled gapless start is
cancelled, and the new one is preloaded and scheduled;
- downloads for entries that left the preload window (the current entry plus
the next `preloadNumTracks`) are aborted, so they don't compete with the
entries about to play; decoded audio is kept.
- entries that left the preload window (the current entry plus the next
`preloadNumTracks`) are unloaded: downloads are aborted, so they don't
compete with the entries about to play, and decoded audio is released,
since a decoded track takes hundreds of MB. An entry that comes back into
the window loads again.

#### Repeat

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gapless",
"version": "5.0.0-alpha.0",
"version": "5.0.0-alpha.2",
"private": false,
"description": "Gapless audio playback javascript plugin",
"license": "MIT",
Expand Down
23 changes: 12 additions & 11 deletions src/Queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ export class Queue implements TrackQueueRef {
if (this.isPlaying) {
this._preloadAhead(this._currentTrack);
} else {
this._cancelPreloadsOutsideWindow(this._currentTrack);
this._unloadOutsideWindow(this._currentTrack);
}
}

Expand Down Expand Up @@ -709,9 +709,9 @@ export class Queue implements TrackQueueRef {
this._preloadAhead(current);
this._tryScheduleGapless(current);
} else {
// "Next" is unchanged, but a move can still push a downloading track
// out of the window.
this._cancelPreloadsOutsideWindow(current);
// "Next" is unchanged, but a move can still push a loading or loaded
// track out of the window.
this._unloadOutsideWindow(current);
}
}

Expand Down Expand Up @@ -746,7 +746,7 @@ export class Queue implements TrackQueueRef {
this._preloadAhead(cur);
this._tryScheduleGapless(cur);
} else {
this._cancelPreloadsOutsideWindow(cur);
this._unloadOutsideWindow(cur);
}
}

Expand Down Expand Up @@ -860,17 +860,18 @@ export class Queue implements TrackQueueRef {
}

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

private _preloadAhead(from: Track | null | undefined): void {
this._cancelPreloadsOutsideWindow(from);
this._unloadOutsideWindow(from);
if (!from) return;
const cur = from;

Expand Down
67 changes: 51 additions & 16 deletions src/Track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export class Track {
readonly skipHEAD: boolean;
/** Temporary holder between fetch and decode steps (unserializable — stays on Track class). */
private _pendingArrayBuffer: ArrayBuffer | null = null;
/** Bumped by unload(): a fetch or decode that finishes after it is dropped. */
private _loadGeneration = 0;

// ---- HTML5 Audio ---------------------------------------------------------
readonly audio: HTMLAudioElement;
Expand Down Expand Up @@ -236,15 +238,21 @@ export class Track {
}),
fetchAudio: fromPromise(async ({ input, signal }) => {
const { resolvedUrl } = input as { resolvedUrl: string };
const generation = this._loadGeneration;
const res = await fetch(resolvedUrl, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status} for ${resolvedUrl}`);
this._pendingArrayBuffer = await res.arrayBuffer();
const bytes = await res.arrayBuffer();
if (generation === this._loadGeneration) this._pendingArrayBuffer = bytes;
}),
decodeAudio: fromPromise(async () => {
const buf = this._pendingArrayBuffer;
this._pendingArrayBuffer = null;
if (!buf || !this.ctx) throw new Error('No ArrayBuffer or AudioContext');
this.audioBuffer = await this.ctx.decodeAudioData(buf);
const generation = this._loadGeneration;
const decoded = await this.ctx.decodeAudioData(buf);
// Unloaded mid-decode (which can't be aborted): drop the result.
if (generation !== this._loadGeneration) return;
this.audioBuffer = decoded;
this._maybeComputeBufferAlignment();
}),
},
Expand Down Expand Up @@ -309,6 +317,8 @@ export class Track {
this._seekAbort = new AbortController();
this.audio.currentTime = 0;
},
releaseHtml5Element: () => this._releaseHtml5Element(),
dropPendingDecode: () => this._dropPendingDecode(),
resetTiming: () => {
this._waRefCtxTime = 0;
this._waRefTrackTime = 0;
Expand Down Expand Up @@ -407,21 +417,23 @@ export class Track {
}

/**
* Abort an in-flight Web Audio download, so the track can be preloaded again
* later from scratch. Only while the URL is being resolved or the file
* fetched: once decoding starts the bytes are already here, and the decode
* (which assigns audioBuffer itself) can't be aborted. Returns whether a
* download was cancelled.
* Let go of this track's Web Audio data, so a track out of the preload
* window doesn't hold memory (a decoded hour of audio is hundreds of MB):
* abort its download, drop a decode in progress when it finishes (decoding
* can't be aborted), or release its decoded buffer. It loads again from
* scratch if it comes back into the window. Never while it's playing,
* scheduled to play, or waiting for its buffer to play. Returns whether
* anything was let go.
*/
cancelPreload(): boolean {
const snap = this._actor.getSnapshot();
if (snap.context.webAudioLoadingState !== 'LOADING' || snap.context.isPlaying) return false;
const child = snap.children.fetchDecode as { getSnapshot(): { value: unknown } } | undefined;
const stage = child?.getSnapshot().value;
if (stage !== 'resolvingUrl' && stage !== 'fetching') return false;
this.queueRef.onDebug(`cancelPreload() track=${this.key} stage=${String(stage)}`);
this._pendingArrayBuffer = null;
this._actor.send({ type: 'CANCEL_PRELOAD' });
unload(): boolean {
const { context } = this._actor.getSnapshot();
const state = context.webAudioLoadingState;
if (state !== 'LOADING' && state !== 'LOADED') return false;
if (context.isPlaying || context.pendingPlay) return false;
this.queueRef.onDebug(`unload() track=${this.key} state=${state}`);
this._dropPendingDecode();
this.audioBuffer = null;
this._actor.send({ type: 'UNLOAD' });
Comment on lines +435 to +439

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset playback type before replaying an unloaded track

When a track that previously played through Web Audio leaves the preload window, unloading clears its buffer but leaves context.playbackType as WEBAUDIO. Returning to that track before it is preloaded again starts HTML5 playback, but neither ACTIVATE nor the HTML5 PLAY transition resets this field. Besides reporting the wrong playback type through TrackInfo, this bypasses the bandwidth-contention guard in Queue._preloadAhead, which checks for playbackType === 'HTML5': after jumping away and back, the next track can download concurrently with the current track's fresh fetch, delaying its crossover. Reset the playback type when entering HTML5 playback so replaying an unloaded track follows the same loading policy as its first play.

Useful? React with 👍 / 👎.

Comment on lines +434 to +439

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release the finished source node when unloading its buffer

When a Web Audio track ends naturally, WEBAUDIO_ENDED transitions it to idle without clearing sourceNode, and the subsequent idle DEACTIVATE does not clear it either. Consequently, unloading that track only nulls audioBuffer; this.sourceNode.buffer still references the decoded audio. Normal sequential playback therefore retains every completed track's decoded buffer despite moving them outside the preload window, defeating the new memory limit and potentially retaining hundreds of MB per track. Clear or dispose of the finished source node during unloading so the decoded buffer can actually be reclaimed.

Useful? React with 👍 / 👎.

return true;
}

Expand Down Expand Up @@ -609,6 +621,8 @@ export class Track {
if (promise) {
promise.catch((err: unknown) => {
if (err instanceof Error && err.name === 'NotAllowedError') {
// First, so the queue (and the app's callback) see a stopped track.
this._actor.send({ type: 'PLAY_BLOCKED' });
this.queueRef.onPlayBlocked();
} else if (err instanceof Error && err.name === 'AbortError') {
// Browser aborted — element will recover on next play()
Expand All @@ -619,6 +633,27 @@ export class Track {
}
}

/** A fetch or decode that finishes from now on is dropped (see unload()). */
private _dropPendingDecode(): void {
this._loadGeneration++;
this._pendingArrayBuffer = null;
}

/**
* Stop the element's download without an error event: drop its source
* (which aborts the fetch) and put it back unloaded, so the next play()
* starts it fresh.
*/
private _releaseHtml5Element(): void {
this.audio.pause();
this._seekAbort.abort();
this._seekAbort = new AbortController();
this.audio.preload = 'none';
this.audio.removeAttribute('src');
this.audio.load();
this.audio.src = this._trackUrl;
}

private _seekHtml5(target: number): void {
if (!isFinite(target)) return;
if (this.audio.preload !== 'auto') this.audio.preload = 'auto';
Expand Down
57 changes: 47 additions & 10 deletions src/machines/track.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export type TrackEvent =
| { type: 'PRELOAD' }
| { type: 'BUFFER_LOADING' }
| { type: 'PLAY' }
// The browser refused HTML5 play() (autoplay policy): nothing will play, so
// nothing should keep downloading.
| { type: 'PLAY_BLOCKED' }
| { type: 'PLAY_WEBAUDIO' }
| { type: 'PAUSE' }
| { type: 'SEEK'; time: number }
Expand All @@ -69,8 +72,8 @@ export type TrackEvent =
| { type: 'WEBAUDIO_ENDED' }
| { type: 'URL_RESOLVED'; url: string }
| { type: 'START_FETCH' }
// Abort an in-flight fetch (sent by Track only before decoding starts).
| { type: 'CANCEL_PRELOAD' }
// Let go of Web Audio data: abort a fetch, drop a decode, release a buffer.
| { type: 'UNLOAD' }
| { type: 'SCHEDULE_GAPLESS'; when: number }
| { type: 'CANCEL_GAPLESS' }
| { type: 'LOOKAHEAD_REACHED' };
Expand All @@ -87,8 +90,10 @@ export function createTrackMachine(initialContext: TrackContext) {
fetchDecode: fetchDecodeMachine,
},
guards: {
canCancelPreload: ({ context }) =>
context.webAudioLoadingState === 'LOADING' && !context.isPlaying,
canUnload: ({ context }) =>
(context.webAudioLoadingState === 'LOADING' || context.webAudioLoadingState === 'LOADED') &&
!context.isPlaying &&
!context.pendingPlay,
canPlayWebAudio: () => false,
isWebAudioOnly: () => false,
canStartFetch: ({ context }) =>
Expand All @@ -110,6 +115,8 @@ export function createTrackMachine(initialContext: TrackContext) {
seekHtml5: () => {},
seekWebAudio: () => {},
resetHtml5Element: () => {},
releaseHtml5Element: () => {},
dropPendingDecode: () => {},
resetTiming: () => {},
notifyTrackEnded: () => {},
triggerFetchForPendingPlay: () => {},
Expand Down Expand Up @@ -189,10 +196,11 @@ export function createTrackMachine(initialContext: TrackContext) {
}),
],
},
// Stopping the child aborts its fetch. Never for a playing track: its
// decode is what lets it cross over to Web Audio.
CANCEL_PRELOAD: {
guard: 'canCancelPreload',
// Stopping the child aborts its fetch (Track drops a decode in flight and
// the buffer itself). Never for a playing, scheduled or waiting track:
// its decode is what lets it cross over to Web Audio.
UNLOAD: {
guard: 'canUnload',
actions: ['stopFetch', 'resetLoadingState'],
},
},
Expand Down Expand Up @@ -303,6 +311,35 @@ export function createTrackMachine(initialContext: TrackContext) {
PLAY: {
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
},
// Blocked before a sound was made: back to idle, as if play() had
// never been called. The element stops buffering, and a Web Audio
// download still in flight is aborted (a decoded buffer is kept: it
// costs nothing more). The position is kept for the next play().
PLAY_BLOCKED: [
{
guard: ({ context }: { context: TrackContext }) =>
context.webAudioLoadingState === 'LOADING',
target: 'idle',
actions: [
'clearIsPlaying',
'stopProgressLoop',
'releaseHtml5Element',
'stopFetch',
'resetLoadingState',
'dropPendingDecode',
'reportProgress',
],
},
{
target: 'idle',
actions: [
'clearIsPlaying',
'stopProgressLoop',
'releaseHtml5Element',
'reportProgress',
],
},
],
BUFFER_LOADING: {
actions: 'setLoadingState',
},
Expand Down Expand Up @@ -367,8 +404,8 @@ export function createTrackMachine(initialContext: TrackContext) {
// -----------------------------------------------------------------
loading: {
on: {
CANCEL_PRELOAD: {
guard: 'canCancelPreload',
UNLOAD: {
guard: 'canUnload',
target: 'idle',
actions: ['stopFetch', 'resetLoadingState'],
},
Expand Down
4 changes: 4 additions & 0 deletions tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export class MockAudioElement {

load: Mock = vi.fn();

removeAttribute = vi.fn((name: string) => {
if (name === 'src') this.src = '';
});

addEventListener = vi.fn(
(type: string, cb: EventListenerOrEventListenerObject, opts?: AddEventListenerOptions) => {
if (!this._listeners[type]) this._listeners[type] = [];
Expand Down
Loading
Loading