No decode pass
Skips decodeAudioData() entirely — the most expensive step. Saves roughly 1–5s per track on slow connections.
By default, the player decodes the audio file with the Web Audio API to extract the waveform — it downloads the file, runs decodeAudioData(), and walks the samples to build peaks. That is accurate but expensive: it costs a full download plus a decode pass (roughly 1–5s per track on a slow connection) before anything draws.
Pre-computed peaks sidestep all of it. You generate the peak data once (at build time, on upload, or in a worker), store it as a small JSON file next to the audio, and hand it to the player. The player skips the decode entirely and draws the waveform the instant the JSON arrives.
This is the single biggest performance lever in the library, and the recommended default for any catalogue of more than a handful of tracks.
waveform optionPoint the player at peak data through the waveform option (or the data-waveform attribute). It accepts four shapes:
| Shape | Example | When to use |
|---|---|---|
.json URL |
'/audio/track.json' |
The standard path — fetched, peaks + markers + bpm applied, decode skipped. A ?query / #hash after .json is fine (1.28.0+). |
Inline number[] |
[0.2, 0.37, 0.41, 0.55] |
Peaks already in memory (e.g. embedded in a JS payload). |
| JSON-array string | '[0.2,0.37,0.41,0.55]' |
Peaks serialized into a single attribute value. |
| Comma-separated string | '0.2,0.37,0.41,0.55' |
Compact inline form, no brackets. |
In every case the Web Audio decode pass is skipped — the player normalizes whatever you give it straight onto the canvas. The one exception is a .json URL that can’t be used (see below): from 1.28.0 the player then decodes the audio as if no peaks had been given.
npm install @arraypress/waveform-player @arraypress/waveform-gen pnpm add @arraypress/waveform-player @arraypress/waveform-gen yarn add @arraypress/waveform-player @arraypress/waveform-gen bun add @arraypress/waveform-player @arraypress/waveform-genimport WaveformPlayer from '@arraypress/waveform-player';import '@arraypress/waveform-player/dist/waveform-player.css';
const player = new WaveformPlayer('#player', { url: '/audio/track.mp3', waveform: '/audio/track.json', // .json URL → fetched, no decode});data-waveform)<div data-waveform-player data-url="/audio/track.mp3" data-waveform="/audio/track.json"></div>The auto-initialiser scans for [data-waveform-player] on DOMContentLoaded, so the declarative form needs no JS at all. See Data attributes for the full attribute contract.
The fetched file is a single object. Only peaks is required:
{ "peaks": [0.2, 0.37, 0.41, 0.55, 0.48, 0.31], "bpm": 124, "markers": [ { "time": 0, "label": "Intro" }, { "time": 30, "label": "Chorus" } ]}peaks — number[], each value a normalized amplitude in 0..1. This is the waveform.bpm — optional number, written by WaveformGen’s --bpm. Feeds the BPM badge (with showBPM) when no bpm option is set — the option still wins (1.28.0+).markers — optional Array<{ time: number, label: string, color?: string }>. Embedded markers are applied automatically only if you have not already set markers on the player — an explicit markers option/attribute wins. See Markers.A bare top-level array is also accepted ([0.2, 0.37, ...]) and treated as the peaks list, but the object form is preferred because it carries markers.
@arraypress/waveform-gen is the companion CLI that produces these JSON files. Run it once over your audio, commit or deploy the output next to the source files, and you are done.
Generate a JSON file per track
npx @arraypress/waveform-gen ./audio/*.mp3 --output ./public/audio/This writes a track.json for each track.mp3 into ./public/audio/. Leave out --output to write each JSON beside its audio file instead.
Scan a directory (optionally recursive)
npx @arraypress/waveform-gen ./audio/ --recursive --output ./public/audio/With --output, each file keeps its path relative to the directory it was found in (audio/albums/intro.mp3 → public/audio/albums/intro.json), with folders created as needed — so the JSON lands at the same relative URL as the audio and getPeaksUrl() finds it, nested folders included. Two inputs that map to the same JSON (song.mp3 + song.wav) fail rather than overwrite each other (WaveformGen 2.0.0+; 1.x flattened everything into --output).
Point the player at the output — via the waveform option or data-waveform, as shown above.
| Option | Default | Description |
|---|---|---|
--samples <n> |
1800 |
Number of peaks written to the file. |
--precision <n> |
2 |
Decimal places each peak is rounded to. |
--output <dir> |
Same as input | Directory for the generated JSON; mirrors each directory input’s subfolders (2.0.0+). |
--format <type> |
json |
json (file) or inline (peaks to stdout — one object keyed by path for several files, 2.0.0+). |
--bpm |
off | Detect tempo and write a bpm field. |
--recursive |
off | Scan sub-directories. |
--quiet |
off | Suppress progress and the summary. Errors still go to stderr (2.0.0+). |
From WaveformGen 2.0.0 any failed file makes the run exit 1, so a corrupt file fails a prebuild step instead of shipping a missing JSON. Values can also be written --flag=value. See the WaveformGen CLI reference for exit codes and the full flag list.
Drop a *.markers.txt file next to the audio and WaveformGen folds the markers into the JSON automatically — no flag needed:
0:00 Intro0:30 Verse 11:15 Chorus1:02:30 BridgeTimestamps accept SS, MM:SS, and H:MM:SS. Lines starting with # are ignored. The resulting markers array lands in the JSON and is applied by the player on fetch (unless you set markers explicitly).
--format inline prints the peaks array to stdout instead of writing a file (for several files, WaveformGen 2.0.0+ prints one JSON object keyed by path) — handy for embedding peaks directly in a server template or a JS payload, then passing them as an inline array / string waveform value:
npx @arraypress/waveform-gen song.mp3 --format inlinegetPeaksUrl helperIf your peaks JSON lives alongside the audio with a matching name (track.mp3 → track.json), you do not need to track two URLs. WaveformPlayer.getPeaksUrl() derives the JSON URL from the audio URL by swapping the extension:
WaveformPlayer.getPeaksUrl('/audio/track.mp3'); // '/audio/track.json'WaveformPlayer.getPeaksUrl('/audio/track.wav?v=2'); // '/audio/track.json?v=2'WaveformPlayer.getPeaksUrl('/stream/track'); // undefined (no recognised extension)WaveformPlayer.getPeaksUrl(undefined); // undefinedIt recognises mp3, wav, ogg, flac, m4a, and aac, and preserves any ?query and #hash. When the input has no recognised audio extension it returns undefined — which is exactly the right value to pass straight through:
const player = new WaveformPlayer('#player', { url: track.audioUrl, waveform: WaveformPlayer.getPeaksUrl(track.audioUrl),});Because undefined is treated as “no pre-computed peaks”, a track whose URL doesn’t match the pattern transparently falls back to live decoding — and from 1.28.0 so does one whose sidecar JSON is missing. You can wire this up unconditionally across a whole catalogue without branching.
Two methods bring peaks in after construction:
setWaveformData(data)Normalize and redraw with new peaks on a live player. Accepts the same four shapes as the waveform option — array, JSON/CSV string, or .json URL (fetched async). Malformed input collapses to an empty waveform rather than throwing. For a .json URL it returns a Promise<boolean> — whether the sidecar was applied; a failed one resolves false and leaves the canvas as it was (1.28.0+).
await player.setWaveformData('/audio/other.json'); // fetch + redraw → true / falseplayer.setWaveformData([0.1, 0.4, 0.6, 0.3]); // inline + redrawWhen the argument is a .json URL, embedded markers are applied (again, only if you haven’t already set markers), and an embedded bpm feeds the badge unless a bpm option is set. When the sidecar comes in through load() / loadTrack(), onLoad fires only once it has been applied (1.28.0+).
loadTrack(...)When swapping tracks, pass waveform in the per-track options so the new track gets its own peaks:
player.loadTrack('/audio/next.mp3', 'Next Track', null, { waveform: WaveformPlayer.getPeaksUrl('/audio/next.mp3'),});Need peaks but no player instance (e.g. to cache them yourself)? WaveformPlayer.generateWaveformData() decodes a URL and resolves to the peaks array:
const peaks = await WaveformPlayer.generateWaveformData('/audio/track.mp3', 256);// peaks: number[] (samples defaults to 1800)This still performs a Web Audio decode — it is the runtime counterpart to the build-time WaveformGen CLI. For production catalogues, prefer generating ahead of time.
No decode pass
Skips decodeAudioData() entirely — the most expensive step. Saves roughly 1–5s per track on slow connections.
Tiny payloads
A peaks JSON is a few KB. You fetch numbers, not megabytes of audio, to draw the waveform.
Render before audio
The waveform paints from JSON while the audio is still preload="metadata" — the UI is ready before playback is.
Scales to catalogues
Lists of dozens of players each skipping a decode is the difference between instant and janky.
Browsers seek audio with HTTP Range requests. Some hosts ignore them and answer with the whole file (200 instead of 206, no Accept-Ranges header): Cloudflare Pages and Workers static assets do, and so do plenty of plain servers. Chrome can’t seek such a file at all, even once it has fully downloaded, which shows up as long tracks “snapping back to 0”. Safari and Firefox are unaffected.
Since 1.29.1 the player handles it: when the browser reports the target as unseekable, it waits for the download to finish, reloads the file from the browser cache (which can seek), and continues from the new position — playback, events and the play button carry on as if it were a normal seek. It logs one console warning per file so you know why.
For instant seeking on long files, serve audio from a host that supports ranges, such as Cloudflare R2, S3, or any standard web server. You can check a host with:
curl -sI -H "Range: bytes=0-99" https://example.com/track.mp3 | head -1# HTTP/2 206 → ranges supported# HTTP/2 200 → ranges ignoredsamples and preload.data-waveform contract.markers option.