Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ jobs:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm fmt:check
- run: pnpm test
- run: pnpm build
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
122 changes: 62 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<img src="./artifacts/icon.svg" width="64" height="64" alt="gapless.js logo">

![gapless.js diagram](./artifacts/graph.svg)

# Gapless.js
Expand Down Expand Up @@ -49,80 +51,80 @@ player.play();

```typescript
const player = new Queue({
tracks: [], // Initial list of track URLs
onProgress: (info) => {}, // Called at ~60fps while playing
onEnded: () => {}, // Called when the last track ends
onPlayNextTrack: (info) => {}, // Called when advancing to next track
onPlayPreviousTrack: (info) => {},// Called when going to previous track
onStartNewTrack: (info) => {}, // Called whenever a new track becomes current
onError: (error) => {}, // Called on audio errors
onPlayBlocked: () => {}, // Called when autoplay is blocked by the browser
onDebug: (msg) => {}, // Internal debug messages (development only)
playbackMethod: 'HYBRID', // 'HYBRID' | 'HTML5_ONLY' | 'WEBAUDIO_ONLY'
trackMetadata: [], // Per-track metadata (aligned by index)
volume: 1, // Initial volume, 0.0–1.0
preloadNumTracks: 2, // Number of tracks to preload ahead (0 to disable)
playbackRate: 1, // Initial playback rate, 0.25–4.0
tracks: [], // Initial list of track URLs
onProgress: (info) => {}, // Called at ~60fps while playing
onEnded: () => {}, // Called when the last track ends
onPlayNextTrack: (info) => {}, // Called when advancing to next track
onPlayPreviousTrack: (info) => {}, // Called when going to previous track
onStartNewTrack: (info) => {}, // Called whenever a new track becomes current
onError: (error) => {}, // Called on audio errors
onPlayBlocked: () => {}, // Called when autoplay is blocked by the browser
onDebug: (msg) => {}, // Internal debug messages (development only)
playbackMethod: 'HYBRID', // 'HYBRID' | 'HTML5_ONLY' | 'WEBAUDIO_ONLY'
trackMetadata: [], // Per-track metadata (aligned by index)
volume: 1, // Initial volume, 0.0–1.0
preloadNumTracks: 2, // Number of tracks to preload ahead (0 to disable)
playbackRate: 1, // Initial playback rate, 0.25–4.0
});
```

### Methods

| Method | Description |
|--------|-------------|
| `play()` | Start or resume playback |
| `pause()` | Pause playback |
| `togglePlayPause()` | Toggle between play and pause |
| `next()` | Advance to the next track |
| `previous()` | Go to previous track (restarts current track if > 8s in) |
| `gotoTrack(index, playImmediately?)` | Jump to a track by index |
| `seek(time)` | Seek to a position in seconds |
| `setVolume(volume)` | Set volume (0.0–1.0) |
| `setPlaybackRate(rate)` | Set playback rate (0.25–4.0), reschedules gapless transitions |
| `addTrack(url, options?)` | Add a track to the end of the queue |
| `removeTrack(index)` | Remove a track by index |
| `resumeAudioContext()` | Resume the AudioContext (for browsers that require user gesture) |
| `destroy()` | Clean up all resources |
| Method | Description |
| ------------------------------------ | ---------------------------------------------------------------- |
| `play()` | Start or resume playback |
| `pause()` | Pause playback |
| `togglePlayPause()` | Toggle between play and pause |
| `next()` | Advance to the next track |
| `previous()` | Go to previous track (restarts current track if > 8s in) |
| `gotoTrack(index, playImmediately?)` | Jump to a track by index |
| `seek(time)` | Seek to a position in seconds |
| `setVolume(volume)` | Set volume (0.0–1.0) |
| `setPlaybackRate(rate)` | Set playback rate (0.25–4.0), reschedules gapless transitions |
| `addTrack(url, options?)` | Add a track to the end of the queue |
| `removeTrack(index)` | Remove a track by index |
| `resumeAudioContext()` | Resume the AudioContext (for browsers that require user gesture) |
| `destroy()` | Clean up all resources |

### Getters

| Getter | Type | Description |
|--------|------|-------------|
| `currentTrack` | `TrackInfo \| undefined` | Snapshot of the current track |
| `currentTrackIndex` | `number` | Index of the current track |
| `tracks` | `readonly TrackInfo[]` | Snapshot of all tracks |
| `isPlaying` | `boolean` | Whether the queue is playing |
| `isPaused` | `boolean` | Whether the queue is paused |
| `volume` | `number` | Current volume |
| `playbackRate` | `number` | Current playback rate |
| `preloadNumTracks` | `number` | Number of tracks to preload ahead (read/write) |
| Getter | Type | Description |
| ------------------- | ------------------------ | ---------------------------------------------- |
| `currentTrack` | `TrackInfo \| undefined` | Snapshot of the current track |
| `currentTrackIndex` | `number` | Index of the current track |
| `tracks` | `readonly TrackInfo[]` | Snapshot of all tracks |
| `isPlaying` | `boolean` | Whether the queue is playing |
| `isPaused` | `boolean` | Whether the queue is paused |
| `volume` | `number` | Current volume |
| `playbackRate` | `number` | Current playback rate |
| `preloadNumTracks` | `number` | Number of tracks to preload ahead (read/write) |

### `TrackInfo`

All callbacks and getters return `TrackInfo` objects — plain data snapshots with no methods:

```typescript
interface TrackInfo {
index: number; // Position in the queue
currentTime: number; // Playback position in seconds
duration: number; // Total duration (NaN until loaded)
index: number; // Position in the queue
currentTime: number; // Playback position in seconds
duration: number; // Total duration (NaN until loaded)
isPlaying: boolean;
isPaused: boolean;
volume: number;
trackUrl: string; // Resolved audio URL
trackUrl: string; // Resolved audio URL
playbackType: 'HTML5' | 'WEBAUDIO';
webAudioLoadingState: 'NONE' | 'LOADING' | 'LOADED' | 'ERROR';
metadata?: TrackMetadata;
playbackRate: number; // Current playback rate
machineState: string; // Internal state machine state
playbackRate: number; // Current playback rate
machineState: string; // Internal state machine state
}
```

### `AddTrackOptions`

```typescript
player.addTrack('https://example.com/track.mp3', {
skipHEAD: true, // Skip HEAD request for URL resolution
skipHEAD: true, // Skip HEAD request for URL resolution
metadata: {
title: 'Track Title',
artist: 'Artist',
Expand Down Expand Up @@ -150,11 +152,11 @@ interface TrackMetadata {

The `playbackMethod` option controls how audio is rendered:

| Value | Behavior | Use case |
|-------|----------|----------|
| `'HYBRID'` (default) | Starts with HTML5 audio, switches to Web Audio after decode | Remote files — instant playback + gapless transitions |
| `'HTML5_ONLY'` | HTML5 audio exclusively, no Web Audio | When Web Audio is unavailable or unwanted; gapless playback disabled |
| `'WEBAUDIO_ONLY'` | Web Audio API exclusively, no HTML5 fallback | Very small or local files where buffering is instant |
| Value | Behavior | Use case |
| -------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------- |
| `'HYBRID'` (default) | Starts with HTML5 audio, switches to Web Audio after decode | Remote files — instant playback + gapless transitions |
| `'HTML5_ONLY'` | HTML5 audio exclusively, no Web Audio | When Web Audio is unavailable or unwanted; gapless playback disabled |
| `'WEBAUDIO_ONLY'` | Web Audio API exclusively, no HTML5 fallback | Very small or local files where buffering is instant |

```javascript
// Web Audio only — waits for decode before playing
Expand Down Expand Up @@ -200,16 +202,16 @@ console.log(player.playbackRate); // 2

v4 is a complete rewrite. The public API has changed:

| v3 | v4 |
|----|-----|
| v3 | v4 |
| --------------------------------------- | ----------------------------------------------------- |
| `import GaplessQueue from 'gapless.js'` | `import Queue from 'gapless'` (or `import { Queue }`) |
| `player.playNext()` | `player.next()` |
| `player.playPrevious()` | `player.previous()` |
| `player.resetCurrentTrack()` | `player.seek(0)` |
| `player.disableWebAudio()` | Pass `playbackMethod: 'HTML5_ONLY'` in constructor |
| `player.nextTrack` | `player.tracks[player.currentTrackIndex + 1]` |
| `track.completeState` | Callbacks now receive `TrackInfo` objects |
| Callbacks receive Track instances | Callbacks receive plain `TrackInfo` data snapshots |
| `player.playNext()` | `player.next()` |
| `player.playPrevious()` | `player.previous()` |
| `player.resetCurrentTrack()` | `player.seek(0)` |
| `player.disableWebAudio()` | Pass `playbackMethod: 'HTML5_ONLY'` in constructor |
| `player.nextTrack` | `player.tracks[player.currentTrackIndex + 1]` |
| `track.completeState` | Callbacks now receive `TrackInfo` objects |
| Callbacks receive Track instances | Callbacks receive plain `TrackInfo` data snapshots |

### Key differences

Expand Down
14 changes: 7 additions & 7 deletions artifacts/graph.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions artifacts/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.timber
17 changes: 17 additions & 0 deletions demo/app/404.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client';

export default function NotFound() {
return (
<main className="mx-auto flex max-w-5xl flex-col gap-4 px-4 py-24 sm:px-6 lg:px-8">
<p className="font-mono text-sm/6 font-medium tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
404
</p>
<h1 className="text-4xl font-semibold tracking-tight text-balance">Nothing here.</h1>
<p className="text-base/7 text-zinc-600 dark:text-zinc-400">
<a href="/" className="font-medium text-zinc-950 dark:text-white">
Back to the demo
</a>
</p>
</main>
);
}
30 changes: 30 additions & 0 deletions demo/app/_components/clips.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { DemoTrack } from './tracks';

// Two halves of one continuous 10-second stretch of Scarlet Begonias
// (Cornell 1977-05-08, 2:22–2:32), cut at an exact sample with no fade.
// The music runs straight through the cut, so any gap is audible.
// Regenerate with ffmpeg from the 48 kHz source:
// atrim=start_sample=6816000:end_sample=7056000 (part 1)
// atrim=start_sample=7056000:end_sample=7296000 (part 2)
export const CLIP_SECONDS = 5;

export const CLIPS: DemoTrack[] = [
{ url: '/clips/part-1.wav', title: 'part-1.wav' },
{ url: '/clips/part-2.wav', title: 'part-2.wav' },
];

/** Normalized peak amplitude, 64 buckets per clip. */
export const PEAKS: number[][] = [
[
0.54, 0.44, 0.52, 0.35, 0.76, 0.6, 0.27, 0.61, 0.32, 0.65, 0.34, 0.55, 0.77, 0.34, 0.28, 0.25,
0.28, 0.38, 0.3, 0.52, 0.39, 0.7, 0.48, 0.27, 0.26, 0.23, 0.6, 0.44, 0.28, 0.27, 0.2, 0.71,
0.28, 0.22, 0.25, 0.21, 0.59, 0.21, 0.53, 0.65, 0.3, 0.35, 0.29, 0.73, 0.34, 0.44, 0.45, 0.47,
0.55, 0.26, 0.38, 0.31, 0.41, 0.35, 0.31, 0.32, 0.28, 0.18, 0.24, 0.44, 1.0, 0.61, 0.34, 0.32,
],
[
0.37, 0.56, 0.35, 0.32, 0.33, 0.44, 0.44, 0.21, 0.34, 0.28, 0.46, 0.51, 0.36, 0.32, 0.17, 0.66,
0.59, 0.35, 0.35, 0.24, 0.47, 0.19, 0.14, 0.19, 0.25, 0.69, 0.26, 0.21, 0.22, 0.18, 0.43, 0.31,
0.26, 0.23, 0.52, 0.66, 0.31, 0.23, 0.18, 0.41, 0.31, 0.15, 0.23, 0.14, 0.76, 0.73, 0.32, 0.36,
0.2, 0.36, 0.27, 0.62, 0.48, 0.43, 0.63, 0.33, 0.47, 0.35, 0.38, 0.57, 0.31, 0.56, 0.36, 0.47,
],
];
26 changes: 26 additions & 0 deletions demo/app/_components/code.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { highlight } from 'sugar-high';

export function CodeBlock({ children, title }: { children: string; title?: string }) {
return (
<div className="overflow-hidden rounded-xl bg-zinc-950 ring-1 ring-zinc-950/10 dark:bg-zinc-800/60 dark:ring-white/10">
{title && (
<p className="border-b border-white/10 px-4 py-2 font-mono text-xs/5 text-zinc-400">
{title}
</p>
)}
{/* Highlighted at render time on the server; sugar-high escapes the source. */}
<pre className="sh overflow-x-auto px-4 py-3 font-mono text-sm/6 text-zinc-100">
<code dangerouslySetInnerHTML={{ __html: highlight(children.trim()) }} />
</pre>
</div>
);
}

export function Code({ children }: { children: ReactNode }) {
return (
<code className="rounded-md bg-zinc-950/5 px-1.5 py-0.5 font-mono text-[0.875em] text-zinc-950 dark:bg-white/10 dark:text-zinc-100">
{children}
</code>
);
}
14 changes: 14 additions & 0 deletions demo/app/_components/exclusive-playback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// The page has more than one player. Starting one pauses the others.

const players = new Map<string, () => void>();

export function registerPlayer(id: string, pause: () => void): () => void {
players.set(id, pause);
return () => {
if (players.get(id) === pause) players.delete(id);
};
}

export function claimPlayback(id: string): void {
for (const [otherId, pause] of players) if (otherId !== id) pause();
}
Loading
Loading