Pre-decoded peaks
Why pre-decode
By default wavesurfer downloads the full audio file and decodes it in the browser before drawing the waveform. For short clips this is fine, but for long recordings (podcasts, interviews, full albums) the decode step can take several seconds and blocks drawing entirely.
Supplying pre-decoded peaks sidesteps this: wavesurfer draws the waveform immediately from the data you provide and only streams the audio for playback. This is also the only way to show a waveform when you use the MediaElement backend, because that backend never decodes the audio itself.
When peaks are provided alongside duration, wavesurfer skips the fetch-and-decode path and goes straight to rendering.
Generating peaks
The easiest server-side tool is the audiowaveform CLI from the BBC.
Generate JSON peak data at 20 points per second with 8-bit precision:
audiowaveform -i clip.mp3 -o clip.json --pixels-per-second 20 --bits 8
For stereo or multi-channel files, add --split-channels to get per-channel data:
audiowaveform -i clip.mp3 -o clip.json --pixels-per-second 20 --bits 8 --split-channels
The output JSON has a data array (and a channels field when --split-channels is used). Wavesurfer expects one array per channel, so you will need to reshape interleaved data — see the Normalization section below for a Python script that handles this automatically.
Loading peaks
Pass a peaks array and duration either to WaveSurfer.create() or to ws.load() after creation.
Shape: peaks is an array of channel arrays, each containing float values. A mono file has one inner array; stereo has two.
// At creation time (no URL needed — renders immediately)
const ws = WaveSurfer.create({
container: '#waveform',
peaks: [[0, 0.1, -0.2, 0.5, -0.5, 0.3]], // one array per channel
duration: 180, // seconds
})
// Or load later, pairing peaks with the audio URL
const response = await fetch('/audio/clip.json')
const json = await response.json()
const ws = WaveSurfer.create({ container: '#waveform' })
await ws.load('/audio/clip.mp3', json.data, json.duration)
The second argument to load() accepts the same Array<Float32Array | number[]> type as the peaks option.
If you only have peaks and no audio to play back (for example in a read-only visualisation), omit the URL and pass an empty string: ws.load('', peaks, duration).
Normalization
Wavesurfer expects peak values in the range -1 to 1. The raw output from audiowaveform uses integer values (e.g. -128 to 127 for 8-bit), so you must normalize before passing the data.
Client-side (easiest): Enable normalize: true in the options and wavesurfer will rescale the peaks on every load:
const ws = WaveSurfer.create({
container: '#waveform',
normalize: true,
peaks: json.data,
duration: json.duration,
})
Server-side (better for performance): Pre-scale once so the browser does no extra work on each page load. The script below normalizes and de-interleaves the audiowaveform JSON in place:
python scale-json.py clip.json
import sys
import json
def scale_json(filename):
with open(filename, "r") as f:
file_content = f.read()
json_content = json.loads(file_content)
data = json_content["data"]
channels = json_content["channels"]
# number of decimals to use when rounding the peak value
digits = 2
max_val = float(max(data))
new_data = []
for x in data:
new_data.append(round(x / max_val, digits))
# audiowaveform is generating interleaved peak data when using the --split-channels flag, so we have to deinterleave it
if channels > 1:
deinterleaved_data = deinterleave(new_data, channels)
json_content["data"] = deinterleaved_data
else:
json_content["data"] = new_data
file_content = json.dumps(json_content, separators=(',', ':'))
with open(filename, "w") as f:
f.write(file_content)
def deinterleave(data, channelCount):
# first step is to separate the values for each audio channel and min/max value pair, hence we get an array with channelCount * 2 arrays
deinterleaved = [data[idx::channelCount * 2] for idx in range(channelCount * 2)]
new_data = []
# this second step combines each min and max value again in one array so we have one array for each channel
for ch in range(channelCount):
idx1 = 2 * ch
idx2 = 2 * ch + 1
ch_data = [None] * (len(deinterleaved[idx1]) + len(deinterleaved[idx2]))
ch_data[::2] = deinterleaved[idx1]
ch_data[1::2] = deinterleaved[idx2]
new_data.append(ch_data)
return new_data
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: python scale_json.py file.json")
exit()
filename = sys.argv[1]
scale_json(filename)
Peaks outside -1..1 break the waveform drawing. A RangeError: Invalid array length at runtime usually means the peak array is empty or malformed — check that your JSON was parsed correctly and that json.data is not undefined. A mismatched duration (e.g. 0 or Infinity) also causes rendering problems; always pass the actual track length in seconds.
Exporting peaks
Once wavesurfer has decoded audio (via the normal fetch-and-decode path), you can extract the peak data it computed and store it for future use:
const peaks = ws.exportPeaks({
channels: 2, // max number of channels to export (default: 2)
maxLength: 8000, // max samples per channel (default: 8000)
precision: 10000, // rounding precision (default: 10000)
})
// peaks is Array<number[]>, one array per channel
console.log(peaks) // [[0.1, -0.2, 0.5, ...], [0.08, -0.18, ...]]
All three parameters are optional — calling ws.exportPeaks() with no arguments uses the defaults shown above.
Note that channels caps the number of channels exported. For surround-sound (5.1) audio the Web Audio API may only expose the left and right channels to the decoded buffer, so exportPeaks may return fewer channels than the source file contains.
Shrinking peak data
Large peak arrays increase JSON payload size and parsing time. Several levers control the size:
| Lever | Where |
|---|---|
Lower --pixels-per-second (e.g. 5 instead of 20) |
audiowaveform CLI |
Lower --bits (8 is fine for visualisation) |
audiowaveform CLI |
Lower maxLength (e.g. 1000) |
ws.exportPeaks({ maxLength: 1000 }) |
Lower precision (e.g. 100 rounds to 2 decimal places) |
ws.exportPeaks({ precision: 100 }) |
For most use cases, --pixels-per-second 4 to 20 and --bits 8 give a good balance between visual fidelity and file size.