Introduction
A few weeks ago, I set out to build a fully functional 10-band audio equalizer that runs entirely in the browser—no server uploads, no signups, just pure client-side audio processing.
The result is SonicLab's Equalizer, a free online tool that lets users boost or cut specific frequency ranges in real-time.
In this post, I'll walk through how I built it using the Web Audio API's BiquadFilterNode, and share some lessons learned along the way.
Why Build a Browser-Based Equalizer?
Most online audio tools require uploading files to a server, which raises privacy concerns and adds latency. By processing audio entirely in the browser using the Web Audio API, we can:
Keep user data private — audio never leaves the device
Provide real-time preview — users hear changes instantly
Eliminate server costs — all processing is client-side
Support offline usage — no internet connection required after the page loads
The Core Technology: BiquadFilterNode
The Web Audio API provides a BiquadFilterNode that implements various filter types. For an equalizer, we use the peaking filter type, which boosts or cuts a specific frequency range.
// Create a filter node for a specific frequency band
const createFilter = (audioContext, frequency, gain, Q) => {
const filter = audioContext.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = frequency; // e.g., 32Hz, 64Hz, 125Hz...
filter.gain.value = gain; // -12dB to +12dB
filter.Q.value = Q || 1.0; // Bandwidth control
return filter;
};
The 10 Frequency Bands
A 10-band equalizer typically covers the audible spectrum with these center frequencies:
Band Frequency Typical Use
1 32 Hz Sub-bass
2 64 Hz Bass
3 125 Hz Low-mid
4 250 Hz Mid-low
5 500 Hz Mid
6 1 kHz Upper-mid
7 2 kHz Presence
8 4 kHz High-mid
9 8 kHz Brilliance
10 16 kHz Air
Wiring It Together
The signal flow is straightforward:
AudioBufferSourceNode
↓
BiquadFilterNode (32Hz)
↓
BiquadFilterNode (64Hz)
↓
... (all 10 filters in series)
↓
AudioDestinationNode (speakers/headphones)
Here's the simplified implementation:
class EqualizerEngine {
constructor(audioContext) {
this.ctx = audioContext;
this.filters = [];
this.source = null;
}
// Create all 10 filters and connect them in series
setupFilters(frequencies, gains) {
// Create the filter chain
let previousNode = null;
frequencies.forEach((freq, index) => {
const filter = this.ctx.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.gain.value = gains[index] || 0;
filter.Q.value = 1.0;
this.filters.push(filter);
if (previousNode) {
previousNode.connect(filter);
}
previousNode = filter;
});
return this.filters[0]; // Return the first filter (input)
}
// Apply EQ to an audio buffer
async applyEQ(audioBuffer, gains) {
const frequencies = [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const firstFilter = this.setupFilters(frequencies, gains);
// Connect the last filter to the destination
const lastFilter = this.filters[this.filters.length - 1];
lastFilter.connect(this.ctx.destination);
// Create and start the source
this.source = this.ctx.createBufferSource();
this.source.buffer = audioBuffer;
this.source.connect(firstFilter);
this.source.start(0);
return this.source;
}
// Clean up
dispose() {
this.filters.forEach(filter => filter.disconnect());
this.filters = [];
if (this.source) {
this.source.stop();
this.source.disconnect();
this.source = null;
}
}
}
Real-Time Preview vs. Offline Processing
One challenge was handling both real-time preview (for immediate feedback) and offline rendering (for export).
For preview, I use a standard AudioContext:
javascript
const ctx = new (window.AudioContext || window.webkitAudioContext)();
For export, I use OfflineAudioContext, which renders the audio faster than real-time:
const offlineCtx = new OfflineAudioContext(
2, // Stereo
audioBuffer.length, // Same duration
audioBuffer.sampleRate // Same sample rate
);
The UI Challenge: Responsive Sliders
With 10 frequency bands, the UI needs to be both compact and usable. I used a horizontal layout with sliders, where each slider's position visually represents the gain value.
<div className="equalizer-bands">
{frequencies.map((freq, index) => (
<div key={freq} className="band">
<input
type="range"
min="-12"
max="12"
value={gains[index]}
step="0.5"
onChange={(e) => handleGainChange(index, parseFloat(e.target.value))}
className="eq-slider"
style={{
transform: `rotate(-90deg)`,
height: '150px'
}}
/>
<span className="frequency-label">{freq}Hz</span>
</div>
))}
</div>
Performance Considerations
A 10-band equalizer processes audio in real-time, so performance is critical. Here are a few optimizations I applied:
Use AudioWorklet for heavy processing — but for a 10-band EQ, BiquadFilterNode is efficient enough.
Limit the number of concurrent AudioContexts — only one context at a time.
Disconnect nodes when not in use — prevents memory leaks.
Use OfflineAudioContext for exports — faster than real-time.
Lessons Learned
The Q factor matters — A Q value of 1.0 provides a smooth, musical EQ curve. Lower values create wider bands, higher values create narrower, more surgical cuts/boosts.
User expectations — Most users expect visual feedback. Adding a waveform display or spectrum analyzer significantly improves the experience.
Headphone warning — Like with 8D audio, equalizer effects are more noticeable on headphones. A gentle reminder helps set expectations.
Try It Yourself
You can test the live equalizer here: SonicLab Equalizer
Have you built an audio tool with the Web Audio API? I'd love to hear about your experience in the comments!
Top comments (0)