Skip to content

Pre-computed peaks

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.

Point 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.

Terminal window
npm install @arraypress/waveform-player @arraypress/waveform-gen
import 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
});
<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.

  1. Generate a JSON file per track

    Terminal window
    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.

  2. Scan a directory (optionally recursive)

    Terminal window
    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).

  3. 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:

song.markers.txt
0:00 Intro
0:30 Verse 1
1:15 Chorus
1:02:30 Bridge

Timestamps 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:

Terminal window
npx @arraypress/waveform-gen song.mp3 --format inline

If 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); // undefined

It 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:

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 / false
player.setWaveformData([0.1, 0.4, 0.6, 0.3]); // inline + redraw

When 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+).

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:

Terminal window
curl -sI -H "Range: bytes=0-99" https://example.com/track.mp3 | head -1
# HTTP/2 206 → ranges supported
# HTTP/2 200 → ranges ignored
  • Options — the full option surface, including samples and preload.
  • Data attributes — the declarative data-waveform contract.
  • Markers — how embedded JSON markers interact with the markers option.
  • BPM — surfacing tempo alongside pre-computed peaks.