audioproxy
WritingAudio variants without a pipeline · part 3

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:

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.

pts also 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:

The same file and the same proxy, through peaks.js. It reads audiowaveform's format, but only at 8 bits, and the proxy always emits 16. Scaling the values by 256 is the entire difference.
drag the lower strip to move through the file

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:

Live, against the demo deployment. The waveform below is drawn from 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.

Julian Rubisch is an independent software developer based in Vienna, Austria, working on web applications and audio tooling, and the author of audioproxy and the audioproxy-rails gem.

Source
audioproxy/audioproxy
Elixir. Signing algorithm and reference implementations in the README.
Module docs
hexdocs.pm
The library behind the container, documented function by function.
Writing
Blog
How the URL design works, and what it is like to use from an app.
Integration
Rails
Turns ActiveStorage attachments into signed variant URLs.
For agents
llms.txt
The contract in a form a coding agent can read in one pass.
Contact
hello@audioproxy.dev
Questions, feedback, commercial interest. Managed hosting and pro features are on the roadmap; hearing what your platform needs moves them up.

Built by Julian Rubisch, an independent developer in Vienna. Available for Rails and Hotwire work through Rails Reviews.

Other things I make:Better Stimulus,Discover Bandcamp Labels,Crow's Nest,SoundLogo,resonAIte.