Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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