Skip to content
Photo of Greg RickabyGreg Rickaby: Alabama-based software engineer, self-taught photographer, and published author.

8 min readCode

How to build a Web Audio API equalizer

A rainbow LED spectrum analyzer with ten glowing bars and their reflection below

Press Play and watch the bars.

Parts
Spacing

Even: every bar covers 2.4 kHz. The bass, rhythm, and melody all pile into the first one.

Turn on JavaScript to play with this demo.

Not very exciting, right? Almost everything piles into the first bar while the rest sit there. Now press Octaves.

There it is. The bass bounces on the left, the rhythm and melody dance in the middle, and the hi-hat hisses way out on the right. Same song, same data. The only thing that changed is how I sliced it.

That song is the chiptune from my post on 8-bit sound effects with the Web Audio API, and it turns out to be a perfect test track. Every part lives on its own channel, so you can switch parts off and see exactly where each one sits.

By the end of this post you'll have a ten-band equalizer built from one AnalyserNode and ten BiquadFilterNodes, with sliders, presets, and a curve that shows what it's doing. And then I'm going to ask you to delete the melody using only the sliders. You can't. I'll tell you why.

Seeing sound with an AnalyserNode

If you read the 8-bit post, you know the mental model: the Web Audio API is a bunch of guitar pedals connected by cables. An AnalyserNode is the strangest pedal on the board. Sound goes in and comes out exactly the same. It doesn't change a thing. It just lets you look inside.

const analyser = audio.createAnalyser()
analyser.fftSize = 2048 // how many samples it looks at at once
 
// Song into the analyser, analyser into your speakers
song.connect(analyser).connect(audio.destination)
 
// One number per frequency slice, from 0 to 255
const data = new Uint8Array(analyser.frequencyBinCount)
 
function draw() {
  analyser.getByteFrequencyData(data) // what's playing right now
  drawBars(canvas, data)
  requestAnimationFrame(draw) // again, next frame
}

Every frame, getByteFrequencyData fills data with how loud each slice of the spectrum is right now. It gets those slices from a fast Fourier transform, which takes a wiggly wave and tells you which pitches it's made of. The same trick works in reverse: the 8-bit post built a square wave by stacking pitches, and an FFT pulls them apart again.

With fftSize at 2048, you get 1,024 slices, called bins. Each one is the sample rate divided by fftSize wide: at 48,000 samples a second, that's about 23 Hz per bin, from 0 Hz all the way up to 24 kHz.

Calming the bars down

The analyser has one more setting that changes how the bars feel. smoothingTimeConstant blends each frame with the one before it. At 0, every frame stands alone. At the default of 0.8, each frame keeps 80% of the last one.

Drag it all the way down, then all the way up:

Around the default of 0.8. Smooth, but still on the beat.

Turn on JavaScript to play with this demo.

At 0 the bars twitch like they've had too much coffee. Up near 0.95 they move like syrup, and the kick drum and hi-hat smear into one long blob. The default of 0.8 sits right in the sweet spot: smooth enough to read, quick enough to keep the beat.

One knob: the BiquadFilterNode

Looking is fun. Now let's change something.

A BiquadFilterNode is the pedal that shapes the tone. It has eight types, and the one an equalizer wants is peaking: it puts a bump (or a dip) around one frequency and leaves everything else alone.

const filter = audio.createBiquadFilter()
filter.type = 'peaking'
filter.frequency.value = 1000 // where the bump is centered
filter.gain.value = 12 // how big: up 12 dB
filter.Q.value = 1.4 // how wide: bigger Q, narrower bump
 
// Song into the filter, filter into the analyser
song.connect(filter).connect(analyser)

Pick a frequency, crank the gain, and watch the bars under the curve grow:

Boosting 12 dB around 704 Hz.

Turn on JavaScript to play with this demo.

Push 12 dB around 100 Hz and the bass gets big and boomy. Pull 24 dB out around 8 kHz and the hi-hat nearly disappears. The curve on top is the filter's response: how much it lifts or cuts every frequency, from 20 Hz on the left to 20 kHz on the right.

Ten knobs: a graphic equalizer

A graphic equalizer is ten of those filters in a row, one per octave. The standard centers are 31, 62, 125, 250, 500, 1k, 2k, 4k, 8k, and 16k Hz, each double the one before it. (The ISO 266 list rounds the bottom two to 31.5 and 63. Stereo makers tend not to.)

The ends are a little different. The 31 Hz slider is a lowshelf, which lifts or cuts everything below it, and the 16k slider is a highshelf, which does the same for everything above. Shelves catch whatever falls off the ends of the chart.

const filters = BANDS.map((frequency, i) => {
  const filter = audio.createBiquadFilter()
  filter.type = i === 0 ? 'lowshelf' : i === BANDS.length - 1 ? 'highshelf' : 'peaking'
  filter.frequency.value = frequency
  filter.Q.value = 1.4
  return filter
})
 
// Plug them in a row: preamp, ten filters, analyser
let node = preamp
for (const filter of filters) node = node.connect(filter)
node.connect(analyser).connect(audio.destination)

Filters in a row add up. If two neighbors each push +6 dB where they overlap, that spot gets close to +12. You can see it in the curve when you move two sliders next to each other.

Try the presets, then make your own:

Presets

Preset: Flat.

Turn on JavaScript to play with this demo.

Telephone keeps only 300 Hz to 3.4 kHz, the range old phone lines carried. Underwater cuts everything above about 500 Hz, which is about what music sounds like from the bottom of a pool. Smiley face is the curve everyone draws on their car stereo the first time: bass up, treble up, middle down. (Admit it. You've done it too.)

Now press Turn it all up.

Can you delete the melody?

OK, I promised you a challenge.

Every note in the melody of this song falls between E5 and G6: 659 Hz to 1,568 Hz. That's the 500, 1k, and 2k bands. So if you cut those three bands all the way down, the melody should vanish.

Press Play, then Cut 500 Hz to 2 kHz:

The melody, uncut. Every note in it falls between 500 Hz and 2 kHz.

Turn on JavaScript to play with this demo.

It gets quieter. Way quieter. But it doesn't go away. There's a thin, buzzy ghost of the melody still playing, and the bars on the right are still bouncing in time with it.

That ghost is the reason square waves sound like Nintendo. Back in the 8-bit post, the square wave demo built a square by stacking pitches: the note itself, plus a quieter copy at 3 times the frequency, 5 times, 7 times, and on up. Those extra pitches are harmonics. The melody plays on a pulse wave, which is full of them. Cut the note's own frequency and the harmonics at 4 kHz, 8 kHz, and above sail right through.

A graphic EQ can turn a part down. It can't pull a part out, because the part isn't in one place. Every note is a whole stack of frequencies, and an equalizer only sees the stack.

Now try a real MP3

The chiptune is a great test track because every part sits in its own place. Real music doesn't work that way. Here's a lo-fi track, and this time the Play button is the browser's own audio player:

Presets

Preset: Flat.

Turn on JavaScript to play with this demo.

Music by Marco Conti from Pixabay.

You can't switch parts off this time. It's one recording with every instrument mixed together, so the sliders are all you get.

Try Turn it all up again, then pull the preamp down until the warning goes away. It takes a lot more preamp this time. A finished song is mastered to be about as loud as it can go, and this one's loudest moment is under 3 dB from the ceiling. Every part of the chiptune plays quietly (the melody at five percent volume), so it had room to spare.

The only new code is the source. Instead of oscillators, an <audio> element feeds the same chain:

const player = document.querySelector('audio')
const source = audio.createMediaElementSource(player)
source.connect(preamp) // then the same ten filters, analyser, and speakers

Once you do that, the element plays only through your graph. You get one source per element, too: call createMediaElementSource on the same element twice and it throws.

Here's the trap if you point it at a song on someone else's server: without CORS, the browser feeds your graph silence. The player's clock keeps ticking, but you hear nothing, and the bars stay flat. The other server has to send an Access-Control-Allow-Origin header, and your tag needs crossorigin="anonymous". Plenty of internet radio streams don't send it, and Spotify or YouTube embeds won't let you near the audio at all. This file lives right next to the post, so it doesn't run into any of that.

Equalizer checklist

  • Space your bars by octaves. Even bins put the whole song in the first bar.
  • Leave smoothing near 0.8. Lower twitches, higher blurs the beat.
  • Cut before you boost. Taking out what you don't want sounds cleaner than piling on what you do.
  • Leave room with a preamp. Boosts add up, and the speakers clip at 1.
  • Use shelves on the ends. They catch everything past the first and last bands.

The demos are in components/islands/demos/web-audio-api-equalizer/, and the chiptune comes from components/islands/chiptune.ts.

Now scroll back up and push the bass all the way up. The bass player in me insists.