The Equalizer That Actually Listens: A Web Audio Analyser Deep-Dive
An equalizer that doesn't listen to the music is just a screensaver with good branding. If the bars are animating on a timer and the music isn't driving them, your users can tell. They may never say it out loud, but the
An equalizer that doesn't listen to the music is just a screensaver with good branding.
If the bars are animating on a timer and the music isn't driving them, your users can tell. They may never say it out loud, but they feel it - the same way they feel a fake progress bar. Sound-reactive visuals are one of the rare places on the web where honesty is literally measurable: the waveform either agrees with the audio or it doesn't.
This is the deep-dive on how I built a real one. Not CSS bars with a random keyframe animation - an actual AnalyserNode reading the frequency spectrum of a playing track and painting eight bars on a canvas, every frame, in sync with the sound. It runs on the hover-preview cards of The DJ Calendar, an electronic music event discovery platform I build and run. When you hover a DJ card, a 30-second preview starts playing and the equalizer on the card starts dancing to it. Zero dependencies. All vanilla JS.
Here's everything I learned building it, including the two failure modes nobody warns you about.
The pipeline in sixty seconds
Web Audio gives you the whole chain for free, built into every browser:
- You take an audio element - in my case playing a preview stream from the iTunes Search API - and you wrap it in a MediaElementAudioSourceNode.
- You pipe that through an AnalyserNode, which keeps a running Fourier transform of whatever is playing.
- You pipe the analyser to the destination so the sound keeps playing.
- Every animation frame, you call getByteFrequencyData and get back an array describing how much energy is in each frequency band, from 0 to 255.
That last array is the entire magic trick. Everything else is drawing.
Two details in that chain will bite you if nobody tells you, so let me tell you now. First: createMediaElementSource can only ever be called once per audio element. Call it twice and you get an exception, so keep your wiring in one place. Second, and worse: if the audio source is cross-origin and the server doesn't send CORS headers, the analyser doesn't error. It returns all zeros. The music plays fine, the animation runs, and the bars just... sit there, dead flat, forever. I lost real time to this in the demo build before adding a diagnostic that detects the taint. An analyser that fails silently is the cruelest kind of bug, because every part of your pipeline looks like it works.
Why fftSize 64 is the right amount of wrong
The default fftSize is 2048, which gives you 1024 frequency bins. That sounds impressive and is mostly useless for a visualizer: you would be drawing bars for frequency detail the human eye cannot parse at animation speed, at a per-frame cost you pay for nothing.
I use fftSize 64. That is 32 bins. And it turns out that for a music visualizer, 32 is not a compromise - it is close to the correct answer, because most of the perceptual energy in a dance track lives in the bottom of the spectrum. Kick drums, basslines, the sub region: that is what makes an electronic track feel alive, and it all sits in the low bins.
With 32 bins I need 8 bars, and I could just average the bins into 8 groups. Instead I sample with a stride of 2 - bin 0, bin 2, bin 4 and so on. The reason is character. Averaging smooths away the differences between neighboring bands, and those differences are exactly what makes a visualizer look like music instead of a metronome. Stride sampling keeps each bar pointing at a specific, stable frequency region, so when the kick hits, the left bars spike and the recovery ripples rightward. The image reads as a wave moving through the spectrum, because it is one.
Smoothing is the difference between data and a strobe light
Raw FFT frames flicker. Music has sharp transients - a hi-hat is a spike that exists for a few milliseconds - and if you paint raw frames your equalizer turns into a strobe that triggers anyone with photosensitivity issues and looks broken for everyone else.
The AnalyserNode has a smoothingTimeConstant property, and I set it to 0.8. That is a heavily damped value: each frame, the analyser blends the new spectrum with the old one at 80/20, so the bars rise fast enough to feel percussive and fall slowly enough to feel like they have mass. Go lower and the bars twitch; go higher and they miss the beat entirely. 0.8 sits in the pocket for dance music specifically, where transients are big and regular.
If you only steal one number from this article, steal that one. It is doing more visible work than anything else in the system.
Drawing eight bars that belong to the card
The visual style has one hard rule: the equalizer is part of the card, not an overlay floating on it. On the production cards it is a full-width strip anchored flush to the bottom edge - eight bars at 31.5 pixels with 4-pixel gaps, which comes out to exactly 280 pixels on a 280-pixel card, 80 pixels tall, with a cyan gradient fading to transparent toward the bottom of each bar.
That flush-to-the-edge geometry is a small thing that reads as a big thing. A floating EQ looks like a widget was pasted on top of a photo. An EQ that runs edge to edge and dies exactly at the card border looks manufactured - like the card was designed around it. Same code, different margins, completely different perceived quality.
The canvas is cleared and redrawn every frame with the bar heights scaled from the byte data. Eight fillRect calls per frame is nothing; the entire render loop would run comfortably on hardware from a decade ago. Performance was never the hard part of this feature. Correctness was.
The race condition nobody warns you about
Here is the bug that ate my afternoon. The cards are hover-activated. A user dragging a cursor across a row of ten DJ cards fires ten hover events in about a second. Each one wants to: start a preview, wire a new analyser, and start a requestAnimationFrame loop.
The problem: those operations do not complete in order. Audio loading is async. So hover event for card B can arrive while card A's animation loop is still running, and now you have two loops, two audio sources, and one canvas being painted by both. The visual result is bars flickering between two different songs' spectra, and it looks exactly like the broken-random-bars antipattern this whole feature exists to avoid.
The fix is a generation counter. Every time a new preview starts, I increment a global generation id and capture it in the closure of the new animation loop. Every frame, the loop checks whether its generation is still current; if it is not, the loop exits without drawing and without rescheduling. One line of state, and stale loops kill themselves politely. No cancellations to manage, no listeners to track, no way to leak.
If you build any hover-activated media feature, build the generation counter on day one. Do it after the bug and you will spend an afternoon watching two ghosts fight over one canvas.
The autoplay gate, and why I stopped fighting it
Browsers will not start an AudioContext without a user gesture. That is not a bug, it is policy, and it is non-negotiable. My first instinct - and the first instinct of everyone who builds this - was to treat it as an obstacle and try to route around it.
The better move is to design for it. On The DJ Calendar, the first hover on a card arms the system: it preloads the audio and warms up the context, and the card shows its "you are about to hear this" state. The first click anywhere on the page unlocks audio for the whole session, permanently. From the user's point of view nothing was ever blocked - hover previews just start working after the first interaction, which is the exact moment they have expressed interest anyway.
I learned this one the hard way in May, rebuilding this exact feature, before I internalized that the browser is not asking permission. It is telling you the user has not asked yet.
Touch and reduced motion get real states, not broken ones
Two accessibility rules that shape the whole interaction model:
On touch devices there is no hover. hover: none media queries detect this, and the interaction becomes click-to-toggle: tap the card, preview plays, EQ dances; tap again, it stops. Same system, different trigger, and nobody on a phone ever sees a dead hover state.
For prefers-reduced-motion, the equalizer does not animate at all - it renders a static, honest state instead of dancing. A user who has told their OS they get motion sick has not asked for an animated frequency display, and the respectful fallback costs about ten lines.
And because the previews are real playing media, the card registers mediaSession metadata, so the track name shows up correctly in OS-level audio UI, and the page title swaps to the playing track. Small details. They are also the details that make a feature feel shipped rather than demoed.
Why zero dependencies matters here
None of this needed a library. The Web Audio API, canvas 2D context, and requestAnimationFrame are all in the platform, and the whole visualizer is a few hundred lines of readable JavaScript. For code that runs on every keystroke-equivalent event - here, every audio frame - being able to read the entire pipeline in one sitting is not sentimentality. It is how you catch the silent CORS failure, the double-wired source node, the stale animation loop. Dependencies do not just add bytes; they add distance between you and the failure.
Read next
- The Search Engine With Zero Server Calls - the other side of the card system: instant client-side search from a prebuilt JSON index
- I Built My Portfolio Like a Piece of Rack Gear - the design philosophy underneath all of it
- The repo: github.com/bryanhamiltondev/dj-card-preview, with a live demo that plays a real 30-second preview through the real analyser
- And eq-visualizer, the pure-CSS sibling: same visual language, zero JavaScript, for when you have no audio to analyse
If you build something with this, the two things I would beg you not to skip: the generation counter and the honest analyser. Fake bars are easy. Bars that agree with the music are the feature.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.