Waveform data for peaks.js and wavesurfer, without a second pipeline
Both peaks.js and wavesurfer draw their waveform from data you hand them, and both expect you to have computed it ahead of time. With audioproxy, you don’t have to, because it renders that data from a signed URL, f:peaks, the same way it renders an mp3.
Suppose your audio player needs to draw a waveform. The peaks.js README tells you how this normally goes: run the audiowaveform CLI over your file, get a .dat out, then point the player at it.
dataUri: {
arraybuffer: 'sample.dat' // or json: 'sample.json'
}
That one line implies a fair amount of work and resources behind it:
- somewhere to run
audiowaveformwhen a file arrives, - somewhere to put the output, next to the audio or in its own bucket,
- a column in a database to find it again,
- a way to backfill data, for the day you want 2000 points instead of 800,
- etc.
A Waveform Is Just Another Format
audioproxy renders audio variants from a signed URL, which the first post in this series covers. Peak data is not a separate feature bolted onto that. It’s simply another format, in the same sense as f:mp3 or f:opus are:
curl "$BASE/$SIG/f:peaks/pts:800/plain/local://interview.mp3"
It’s signed the same way like an audio variant, it’s cached under the same kind of key, it’s written back to the same variant store, etc.
Let’s look at the actual output of audiowaveform that’s needed to conform to peaks.js:
{"version":2,"channels":1,"sample_rate":44100,"samples_per_pixel":11580,
"bits":16,"length":800,
"data":[-16583,15023,-18258,19488,-19352,17923, ...]}
A minimum and a maximum per pixel to draw, so length × 2 numbers for mono. The values are 16-bit by default but support 8-bit with pk_bits:8, which the next section is about. Ask for pk_fmt:dat and you get audiowaveform’s binary instead. The reason you might reach for this is payload size:
f:peaks 9237 bytes application/json
f:peaks/pk_fmt:dat 3224 bytes application/octet-stream
How Do I Feed peaks.js from a URL Instead of a .dat File?
peaks.js takes the URL directly:
Peaks.init({
zoomview: { container: document.getElementById('zoomview-container') },
overview: { container: document.getElementById('overview-container') },
mediaElement: document.getElementById('audio'),
dataUri: {
arraybuffer: 'https://audio.example.com/<sig>/f:peaks/pts:2000/pk_fmt:dat/pk_bits:8/enc/<source>'
},
}, (err, peaks) => { /* ... */ });
pk_bits:8 is the one part worth explaining. peaks.js reads audiowaveform’s format but only at 8 bits, which is why its README tells you to generate files with audiowaveform -b 8.
ptsalso caps how far peaks.js can zoom in: it never shows more detail than the data it was given. Ask for more points if your players need to get closer.
Here’s a live demo of a running audioproxy instance serving a peaks.js rendering:
How Do I Load audioproxy Peaks into wavesurfer.js?
wavesurfer.js is the other library people reach for, and it requires something different again: arrays of floats per channel, plus the duration. (The latter because it is not decoding the file to work that out for itself.)
Fetch the peaks as JSON at the default 16 bits, divide each value by 32768 to land in the -1 to 1 range wavesurfer expects, and split the interleaved min/max pairs into two arrays:
function toWavesurfer(wave) {
const scale = 1 << (wave.bits - 1); // 32768 at 16 bits, 128 at 8
const maxima = [], minima = [];
for (let i = 0; i < wave.data.length; i += 2) {
minima.push(wave.data[i] / scale);
maxima.push(wave.data[i + 1] / scale);
}
return [maxima, minima];
}
const peaksUrl = "https://audio.example.com/<sig>/f:peaks/pts:2000/enc/<source>";
const infoUrl = "https://audio.example.com/<sig>/info/enc/<source>";
const audioUrl = "https://audio.example.com/<sig>/f:mp3/br:128/enc/<source>";
const wave = await (await fetch(peaksUrl)).json();
const { duration } = await (await fetch(infoUrl)).json();
WaveSurfer.create({
container: "#waveform",
url: audioUrl,
peaks: toWavesurfer(wave),
duration,
});
Three signed URLs for one source: the peaks, the metadata, and the audio the player streams.
Here’s a live demo again:
f:peaks/pts:800 through the toWavesurfer function above,
and the audio element plays the matching f:opus/br:96 variant.One thing not to skip is duration. You can derive it from the peaks payload itself, as length × samples_per_pixel / sample_rate, but this can result in rounding errors. Take the exact number from /info instead, that’s one request you can make once and keep.
What a Peaks Render Costs
Peaks are much cheaper to render than an audio variant of the same file. A cold render of 1200 points, then the same URL again:
req1 200 13447 bytes 0.50s x-audio-proxy: MISS
req2 200 13447 bytes 0.004s x-audio-proxy: HIT
The same file as a 96 kbps Opus variant, for comparison, is 2.42 seconds cold and 2.6 MB.
Peaks URLs from Rails and ActiveStorage
audioproxy-rails, the gem the previous post sets up, has a helper for this, so the format is not something you pass:
<%# a peaks.js conforming URL from an ActiveStorage attachment %>
<%= audioproxy_peaks_url @recording.audio,
peak_count: 2000, peak_format: "dat", peak_bits: 8 %>
<%= audioproxy_audio_tag @recording.audio,
format: "opus", bitrate: 96, html: { controls: true } %>
audioproxy_peaks_url fixes f:peaks and accepts only the options peaks read:
ArgumentError: Audioproxy peaks_url does not take bitrate; peaks accept only
pts, pk_fmt, pk_bits, ch, t, fade, gain, norm, dl, cb
For wavesurfer you also need the duration, and there is a helper for that too: audioproxy_info_url points at the /info endpoint the earlier section fetched by hand.
Try It Yourself
The explorer on the front page draws its waveform from exactly these requests, against a live deployment. Toggle normalisation and watch the shape change, because that is a fresh render rather than a redrawn cache.
- The proxy: github.com/audioproxy/audioproxy (Apache-2.0)
- The gem: github.com/audioproxy/audioproxy-rails
- Peaks options in the contract: docs.audioproxy.dev