gapless.js

A gapless playback javascript library

Install

pnpm add gapless

gapless.js is ESM only and ships its own types. Its one dependency, XState, is bundled.

Quick start

Create a Queue with a list of track URLs, then call play() from a click handler.

import Queue from 'gapless';

const player = new Queue({
  tracks: [
    'https://example.com/set1-01.mp3',
    'https://example.com/set1-02.mp3',
    'https://example.com/set1-03.mp3',
  ],
  trackMetadata: [
    { title: 'Help on the Way' },
    { title: 'Slipknot!' },
    { title: 'Franklin\'s Tower' },
  ],
  onProgress: (track) => renderProgress(track.currentTime, track.duration),
  onStartNewTrack: (track) => renderNowPlaying(track.metadata?.title),
  onEnded: () => console.log('Show over'),
});

playButton.addEventListener('click', () => player.togglePlayPause());
nextButton.addEventListener('click', () => player.next());

gapless.js has no UI. It hands you plain data through callbacks and getters and you render it however you like.

How it works

An <audio> element can’t start a file on an exact sample. Calling play() on the next file when the first one fires ended always leaves an audible gap, even when the next file is fully buffered.

By default (HYBRID mode), each track starts playing right away through HTML5 audio so there’s no wait. Meanwhile gapless.js fetches and decodes the track with the Web Audio API. Once the decoded buffer is ready it swaps over mid-track without a click. Upcoming tracks are decoded ahead of time, and each one is scheduled on the AudioContext clock to start on the exact sample where the previous one ends.

If the next track isn’t decoded in time (a slow network, or you skipped ahead), it falls back to HTML5 audio for that handoff. You get a small gap instead of silence or an error, and it picks up Web Audio again once the decode finishes.

The queue and every track are driven by XState state machines, so playback is always in a known state no matter how fast users mash buttons.

Using with React

The Queue is a plain object. Create it once, read its getters into a snapshot whenever a callback fires, and subscribe with useSyncExternalStore.

use-player.ts

import { useEffect, useState, useSyncExternalStore } from 'react';
import Queue, { type TrackInfo } from 'gapless';

interface Snapshot {
  current?: TrackInfo;
  isPlaying: boolean;
}

class PlayerStore {
  queue: Queue | null = null;
  private snapshot: Snapshot = { isPlaying: false };
  private listeners = new Set<() => void>();

  constructor(private urls: string[]) {}

  subscribe = (fn: () => void) => {
    this.listeners.add(fn);
    return () => void this.listeners.delete(fn);
  };
  getSnapshot = () => this.snapshot;

  private sync = () => {
    const q = this.queue;
    if (!q) return;
    this.snapshot = { current: q.currentTrack, isPlaying: q.isPlaying };
    for (const fn of this.listeners) fn();
  };

  connect() {
    const queue = new Queue({
      tracks: this.urls,
      onProgress: this.sync,
      onStartNewTrack: this.sync,
      onEnded: this.sync,
    });
    this.queue = queue;
    return () => queue.destroy();
  }
}

export function usePlayer(urls: string[]) {
  const [store] = useState(() => new PlayerStore(urls));
  useEffect(() => store.connect(), [store]);
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
  return { ...state, queue: store.queue };
}

Create the Queue in an effect, not during render. It touches Audio and AudioContext, which don’t exist on the server. Call destroy() on unmount.

Options

Everything passed to new Queue(options). All of it is optional.

OptionTypeDescription
tracksstring[]Initial list of track URLs.
trackMetadataTrackMetadata[]Per-track metadata, matched to tracks by index.
volumenumberInitial volume, 0–1. Default 1.
playbackRatenumberInitial playback rate, 0.25–4. Default 1.
preloadNumTracksnumberHow many tracks to preload ahead. Default 2, 0 disables.
playbackMethod'HYBRID' | 'HTML5_ONLY' | 'WEBAUDIO_ONLY'How audio is rendered. Default HYBRID. See Playback method.
onProgress(track: TrackInfo) => voidCalled about 60 times a second while playing.
onStartNewTrack(track: TrackInfo) => voidCalled whenever a different track becomes current, for any reason.
onPlayNextTrack(track: TrackInfo) => voidCalled when the queue advances to the next track.
onPlayPreviousTrack(track: TrackInfo) => voidCalled when the queue goes back a track.
onEnded() => voidCalled when the last track finishes.
onError(error: Error) => voidCalled on audio errors.
onPlayBlocked() => voidCalled when the browser blocks playback (autoplay policy).
onDebug(msg: string) => voidInternal debug messages. Development only.

Methods

MethodDescription
play()Start or resume playback.
pause()Pause playback.
togglePlayPause()Play if paused, pause if playing.
next()Go to the next track. Does nothing on the last track.
previous()Go to the previous track, or restart the current one if more than 8 seconds in.
gotoTrack(index, playImmediately?, startTime?)Jump to a track by index, optionally starting playback at startTime seconds.
seek(time)Seek within the current track, in seconds.
setVolume(volume)Set volume, clamped to 0–1.
setPlaybackRate(rate)Set playback rate, clamped to 0.25–4. Reschedules gapless transitions.
addTrack(url, options?)Append a track. See AddTrackOptions below.
removeTrack(index)Remove a track by index.
resumeAudioContext()Create and resume the shared AudioContext. Call from a user gesture.
destroy()Stop playback and release every resource.

AddTrackOptions

player.addTrack('https://example.com/encore.mp3', {
  // Skip the HEAD request used to resolve redirects, if the URL is already final.
  skipHEAD: true,
  metadata: { title: 'Ripple', artist: 'Grateful Dead' },
});

Getters

Getters return fresh snapshots each time you read them. Nothing you get back is live.

GetterTypeDescription
currentTrackTrackInfo | undefinedThe current track.
currentTrackIndexnumberIndex of the current track.
tracksreadonly TrackInfo[]Every track in the queue.
isPlayingbooleanWhether the queue is playing.
isPausedbooleanWhether the queue is paused.
volumenumberCurrent volume.
playbackRatenumberCurrent playback rate.
preloadNumTracksnumberTracks to preload ahead. Also settable.

TrackInfo

Every callback and getter gives you TrackInfo: plain data with no methods, safe to store in React state or send over postMessage.

interface TrackInfo {
  index: number;            // Position in the queue
  currentTime: number;      // Seconds
  duration: number;         // Seconds, NaN until loaded
  isPlaying: boolean;
  isPaused: boolean;
  volume: number;
  playbackRate: number;
  trackUrl: string;         // Resolved URL, after redirects
  playbackType: 'HTML5' | 'WEBAUDIO';   // Which backend is making sound right now
  webAudioLoadingState: 'NONE' | 'LOADING' | 'LOADED' | 'ERROR';
  metadata?: TrackMetadata;
  machineState: string;     // Internal state, handy for debugging
}

playbackType tells you whether the next handoff can be gapless. When the current track is WEBAUDIO and the next one has webAudioLoadingState: 'LOADED', it will be.

Metadata & Media Session

Metadata is yours to define. title, artist, album and artwork are passed to the Media Session API, so they show up on lock screens, in OS media controls and on hardware media keys. Play, pause, next, previous and seek from those controls work automatically. Any other fields come back to you on track.metadata.

interface TrackMetadata {
  title?: string;
  artist?: string;
  album?: string;
  artwork?: MediaImage[];   // [{ src, sizes, type }]
  [key: string]: unknown;   // Anything else you want to carry along
}

Playback method

ValueBehaviorUse it for
HYBRIDStarts on HTML5 audio, switches to Web Audio once decoded. The default.Remote files. Instant start and gapless transitions.
HTML5_ONLYHTML5 audio only. No gapless playback.When Web Audio is unavailable or unwanted.
WEBAUDIO_ONLYWeb Audio only. Waits for each file to download and decode before playing.Small or local files where loading is instant.

Preloading

gapless.js preloads and decodes the next two tracks by default. Lower it to save bandwidth and memory, raise it for flaky connections. You can change it at any time.

const player = new Queue({ tracks, preloadNumTracks: 1 });

player.preloadNumTracks = 0; // Disable preloading
player.preloadNumTracks = 3; // Preload 3 ahead

Decoded audio lives in memory as raw PCM, roughly 10 MB per minute of stereo at 44.1 kHz. Keep this in mind for long tracks on mobile.

Playback rate

Speeds between 0.25× and 4× are supported, and gapless transitions are rescheduled to match.

const player = new Queue({ tracks, playbackRate: 1.5 });

player.setPlaybackRate(2);
player.playbackRate; // 2

Autoplay

Browsers won’t start audio until the user has interacted with the page. Call play(), next() or gotoTrack(…, true) from a click or key handler and it just works. gapless.js creates its AudioContext lazily on that first gesture, so you won’t see console warnings on page load.

If you start playback outside a gesture and the browser refuses, onPlayBlocked fires. Show a play button and call play() again when it’s clicked.

const player = new Queue({
  tracks,
  onPlayBlocked: () => showPlayButton(),
});

Migrating from v3

v4 is a complete rewrite. It’s ESM only, callbacks receive plain TrackInfo objects instead of Track instances, and Media Session support is built in.

v3v4
import GaplessQueue from 'gapless.js'import Queue from 'gapless'
player.playNext()player.next()
player.playPrevious()player.previous()
player.resetCurrentTrack()player.seek(0)
player.disableWebAudio()playbackMethod: 'HTML5_ONLY'
player.nextTrackplayer.tracks[player.currentTrackIndex + 1]
track.completeStateTrackInfo