diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f25de13..b557e58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index a7fa459..0ac4517 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +gapless.js logo + ![gapless.js diagram](./artifacts/graph.svg) # Gapless.js @@ -49,53 +51,53 @@ 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` @@ -103,18 +105,18 @@ All callbacks and getters return `TrackInfo` objects — plain data snapshots wi ```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 } ``` @@ -122,7 +124,7 @@ interface TrackInfo { ```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', @@ -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 @@ -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 diff --git a/artifacts/graph.svg b/artifacts/graph.svg index a8a2d33..8805893 100644 --- a/artifacts/graph.svg +++ b/artifacts/graph.svg @@ -1,4 +1,4 @@ - + - ╔══ Track One ══╗ ╔══ Track Two ══════╗ - ║ ║░░ ║ ║░░ - ║ Instant HTML5 ║░── preload ──▶║ Gapless Web Audio ║░░ - ║ ║░░ ║ ║░░ - ╚═══════════════╝░░ ╚═══════════════════╝░░ - ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░ + ╔══ Track One ══╗ ╔══ Track Two ╗ ╔══ Track Three ╗ + ║ Instant HTML5 ║░░ ║ ║░░ ║ ║░░ + ║ ↓ ║░── gapless ──▶║ Web Audio ║░── gapless ──▶║ Web Audio ║░░ + ║ Web Audio ║░░ ║ ║░░ ║ ║░░ + ╚═══════════════╝░░ ╚═════════════╝░░ ╚═══════════════╝░░ + ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░ diff --git a/artifacts/icon.svg b/artifacts/icon.svg new file mode 100644 index 0000000..6bbbd47 --- /dev/null +++ b/artifacts/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 0000000..5b36a70 --- /dev/null +++ b/demo/.gitignore @@ -0,0 +1 @@ +.timber diff --git a/demo/app/404.tsx b/demo/app/404.tsx new file mode 100644 index 0000000..5dba44d --- /dev/null +++ b/demo/app/404.tsx @@ -0,0 +1,17 @@ +'use client'; + +export default function NotFound() { + return ( +
+

+ 404 +

+

Nothing here.

+

+ + Back to the demo + +

+ + ); +} diff --git a/demo/app/_components/clips.ts b/demo/app/_components/clips.ts new file mode 100644 index 0000000..c09a801 --- /dev/null +++ b/demo/app/_components/clips.ts @@ -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, + ], +]; diff --git a/demo/app/_components/code.tsx b/demo/app/_components/code.tsx new file mode 100644 index 0000000..a213fb1 --- /dev/null +++ b/demo/app/_components/code.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react'; +import { highlight } from 'sugar-high'; + +export function CodeBlock({ children, title }: { children: string; title?: string }) { + return ( +
+ {title && ( +

+ {title} +

+ )} + {/* Highlighted at render time on the server; sugar-high escapes the source. */} +
+        
+      
+
+ ); +} + +export function Code({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/demo/app/_components/exclusive-playback.ts b/demo/app/_components/exclusive-playback.ts new file mode 100644 index 0000000..43b0e01 --- /dev/null +++ b/demo/app/_components/exclusive-playback.ts @@ -0,0 +1,14 @@ +// The page has more than one player. Starting one pauses the others. + +const players = new Map 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(); +} diff --git a/demo/app/_components/gapless-demo.tsx b/demo/app/_components/gapless-demo.tsx new file mode 100644 index 0000000..4ea06e6 --- /dev/null +++ b/demo/app/_components/gapless-demo.tsx @@ -0,0 +1,135 @@ +'use client'; + +import type { TrackInfo } from 'gapless'; +import { useGapless } from './use-gapless'; +import { CLIPS, CLIP_SECONDS } from './clips'; +import { Waveform } from './waveform'; +import { formatTime } from './tracks'; +import { PauseIcon, PlayIcon } from './icons'; +import { StageBadge, type Tone } from './ui'; + +const TOTAL = CLIP_SECONDS * CLIPS.length; + +function stage(info: TrackInfo | undefined, isCurrent: boolean): { tone: Tone; label: string } { + if (!info) return { tone: 'neutral', label: 'Not loaded' }; + if (info.webAudioLoadingState === 'ERROR') return { tone: 'bad', label: 'Decode failed' }; + if (isCurrent && info.isPlaying && info.playbackType === 'WEBAUDIO') + return { tone: 'good', label: 'Web Audio' }; + if (info.webAudioLoadingState === 'LOADED') return { tone: 'good', label: 'Decoded' }; + if (info.webAudioLoadingState === 'LOADING') return { tone: 'busy', label: 'Decoding' }; + if (isCurrent && info.isPlaying) return { tone: 'busy', label: 'HTML5' }; + return { tone: 'neutral', label: 'Not loaded' }; +} + +export function GaplessDemo() { + const player = useGapless('gapless-demo', CLIPS, { volume: 0.8 }); + const { current, tracks, lastHandoff } = player; + const idx = current?.index ?? 0; + const position = + player.state === 'idle' + ? 0 + : player.state === 'ended' + ? TOTAL + : idx * CLIP_SECONDS + (current?.currentTime ?? 0); + const playing = player.state === 'playing'; + + const seekTo = (seconds: number) => { + const index = Math.min(CLIPS.length - 1, Math.floor(seconds / CLIP_SECONDS)); + const offset = seconds - index * CLIP_SECONDS; + player.clearHandoff(); + if (index === idx && player.state !== 'idle' && player.state !== 'ended') { + player.seek(offset); + if (!playing) player.toggle(); + } else { + player.gotoTrack(index, offset); + } + }; + + return ( +
+
+ +
+

+ gapless.js +

+

+ Starts instantly over HTML5 Audio, then hands off to a decoded Web Audio buffer. +

+
+ +
+ +
+ + t.webAudioLoadingState === 'LOADED' + ? [[i * CLIP_SECONDS, (i + 1) * CLIP_SECONDS] as const] + : [] + )} + marker={{ + label: !lastHandoff ? 'gap' : lastHandoff.gapless ? 'no gap' : 'gap', + tone: lastHandoff && !lastHandoff.gapless ? 'bad' : 'good', + }} + /> + +
+ {CLIPS.map((clip, i) => ( +
+

+ {clip.title} +

+ +
+ ))} +
+
+ +
+ {/* Reserve two lines on narrow screens so the result never shifts layout. */} +
+ {lastHandoff ? ( + lastHandoff.gapless ? ( +

+ Gapless. part-2.wav started on the exact + sample part-1.wav ended. +

+ ) : ( +

+ Not gapless. part-2.wav wasn’t decoded in + time, so it fell back to HTML5. Replay the transition. +

+ ) + ) : ( +

+ The next file is scheduled on the AudioContext clock to start on the exact sample the + first one ends. +

+ )} +
+

+ {formatTime(position)} / {formatTime(TOTAL)} +

+
+
+ ); +} diff --git a/demo/app/_components/icons.tsx b/demo/app/_components/icons.tsx new file mode 100644 index 0000000..4c21072 --- /dev/null +++ b/demo/app/_components/icons.tsx @@ -0,0 +1,54 @@ +export function PlayIcon({ className = 'size-5 sm:size-4' }: { className?: string }) { + return ( + + ); +} + +export function PauseIcon({ className = 'size-5 sm:size-4' }: { className?: string }) { + return ( + + ); +} + +export function PreviousIcon({ className = 'size-5 sm:size-4' }: { className?: string }) { + return ( + + ); +} + +export function NextIcon({ className = 'size-5 sm:size-4' }: { className?: string }) { + return ( + + ); +} diff --git a/demo/app/_components/mark.tsx b/demo/app/_components/mark.tsx new file mode 100644 index 0000000..642da3d --- /dev/null +++ b/demo/app/_components/mark.tsx @@ -0,0 +1,36 @@ +// The site mark: an irregular waveform with the green gap marker in it, as +// on the page. The bars either side of it are nearly the same height and it +// keeps the row's spacing, so the signal reads as carrying straight +// through. Rendered to PNG by takumi in icon.tsx / apple-icon.tsx and as plain +// DOM in the header (inline styles only, so both renderers agree). + +const BEFORE = [0.5, 0.28, 0.82, 0.62]; +const AFTER = [0.64, 0.9, 0.36, 0.55]; + +export function Mark({ size }: { size: number }) { + const u = size / 64; + const bar = (h: number, i: number) => ( +
+ ); + return ( +
+ {BEFORE.map(bar)} +
+ {AFTER.map(bar)} +
+ ); +} diff --git a/demo/app/_components/native-demo.tsx b/demo/app/_components/native-demo.tsx new file mode 100644 index 0000000..eef2d46 --- /dev/null +++ b/demo/app/_components/native-demo.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useEffect, useState, useSyncExternalStore } from 'react'; +import { CLIPS, CLIP_SECONDS } from './clips'; +import { formatTime } from './tracks'; +import { PauseIcon, PlayIcon } from './icons'; +import { Waveform } from './waveform'; +import { claimPlayback, registerPlayer } from './exclusive-playback'; + +interface NativeSnapshot { + started: boolean; + playing: boolean; + /** Seconds into the combined 24s. */ + position: number; + /** Silence measured between the two files, in ms. */ + gapMs: number | null; + /** What each element has buffered, in combined seconds. */ + buffered: [number, number][]; +} + +const INITIAL: NativeSnapshot = { + started: false, + playing: false, + position: 0, + gapMs: null, + buffered: [], +}; + +// Passes audio through untouched and reports every run of digital silence +// longer than 32 samples, in seconds. The clips are cut mid-music, so the only +// such run is the one between the two files. +const GAP_METER = ` +registerProcessor('gap-meter', class extends AudioWorkletProcessor { + run = 0; + process([input], [output]) { + for (let c = 0; c < output.length; c++) if (input[c]) output[c].set(input[c]); + const ch = input[0]; + if (!ch) { this.run += 128; return true; } + for (let i = 0; i < ch.length; i++) { + if (Math.abs(ch[i]) < 1e-6) this.run++; + else { if (this.run > 32) this.port.postMessage(this.run / sampleRate); this.run = 0; } + } + return true; + } +});`; + +/** + * What most players do without gapless.js: one