Building a Browser-Based
Audio Engine
A technical examination of the architecture, DSP algorithms, and performance optimizations behind the ACID-303 synthesizer—a production-grade TB-303 emulation running entirely in the browser.
Signal Flow Architecture
┌─────────────────────────────────────────────────────────────────────────────┐ │ AUDIO ENGINE │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ OSCILLATOR │ │ LOWPASS │ │ VCA │ │ │ │ │─────▶│ FILTER │─────▶│ (Gain) │ │ │ │ Saw/Square │ │ (24dB/oct) │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ PITCH ENV │ │ FILTER ENV │ │ AMP ENV │ │ │ │ (Slide) │ │ (Attack/ │ │ (Attack/ │ │ │ │ │ │ Decay) │ │ Decay) │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ CUTOFF │ │ ACCENT │ │ │ │ + ENVMOD │ │ (Boost) │ │ │ │ + RESONANCE │ │ │ │ │ └──────────────┘ └──────────────┘ │ │ │ │ ├──────────────────────────────────────────────────────┼──────────────────────┤ │ ▼ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ ANALYSER │◀─────│ MASTER GAIN │◀─────│ DRUM GAIN │ │ │ │ (Scope) │ │ (Volume) │ │ (Optional) │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ CANVAS │ │ DESTINATION │ │ │ │ (Waveform) │ │ (Speakers) │ │ │ └──────────────┘ └──────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘
The Challenge
Building a convincing TB-303 emulation presents several technical challenges that push the browser to its limits:
- Timing Accuracy: JavaScript timers drift. For music, even 10ms of jitter is audible.
- Real-time Parameter Control: React's render cycle is too slow for audio. Knob changes must be instant.
- Filter Modeling: The 303's diode ladder filter has a unique resonant character. Web Audio's BiquadFilter is an approximation.
- Main Thread Blocking: Heavy JavaScript execution causes audio glitches (underruns).
Sample-Accurate Timing
The biggest mistake developers make with Web Audio is using setInterval to schedule notes. JavaScript timers are tied to the main thread and can drift by 20-50ms under load. For music, this creates audible "flamming" and tempo instability.
The solution: Look-ahead scheduling. We use a loose JavaScript timer to wake up frequently (~25ms), but schedule audio events ahead using the Web Audio clock, which runs on a separate high-priority thread with sample-level precision.
Core Scheduler Implementation
// Look-ahead scheduler - runs frequently, schedules notes ahead
const scheduler = useCallback(() => {
if (!isPlayingRef.current || !audioContextRef.current) return
const ctx = audioContextRef.current
const secondsPerStep = 60 / bpmRef.current / 4
// Schedule all notes that will play before the next interval
while (nextNoteTimeRef.current < ctx.currentTime + scheduleAheadTime) {
const synthStep = synthStepRef.current
// Schedule this step at the EXACT audio clock time
scheduleStep(nextNoteTimeRef.current, synthStep)
// Advance to next step
synthStepRef.current = (synthStep + 1) % seqLengthRef.current
nextNoteTimeRef.current += secondsPerStep
}
}, [scheduleStep])"I chose this architecture because the main thread was causing timing jitter. By scheduling 100ms ahead using AudioContext.currentTime, we achieve sample-accurate playback even when React is re-rendering or garbage collection kicks in."
Filter & Resonance Modeling
The 303's distinctive "squelch" comes from its 4-pole (24dB/octave) lowpass filter with resonance that can self-oscillate. The Web Audio BiquadFilterNode provides a reasonable approximation, but the devil is in the envelope modulation.
Filter Envelope Implementation
// The Q (resonance) parameter controls how much the filter // emphasizes frequencies near the cutoff. Higher Q = more squelch. const filter = ctx.createBiquadFilter() filter.type = "lowpass" filter.frequency.value = currentCutoff // Base cutoff frequency filter.Q.value = currentResonance // Q of 15-25 gives that classic acid sound // Envelope modulation - this is where the magic happens const accentMult = step.accent ? 1 + currentAccent : 1 const filterEnv = currentEnvMod * accentMult // Calculate peak frequency (clamped to prevent aliasing) const peak = Math.min(currentCutoff + filterEnv, 18000) // Schedule the filter sweep filter.frequency.setValueAtTime(currentCutoff, time) filter.frequency.linearRampToValueAtTime(peak, time + 0.005) // 5ms attack filter.frequency.exponentialRampToValueAtTime( Math.max(currentCutoff, 80), // Don't go below 80Hz time + currentDecay )
The resonance (Q) parameter is crucial. At values around 15-25, the filter starts to ring and emphasize harmonics near the cutoff frequency. Combined with the envelope sweeping the cutoff up and down, this creates the characteristic "wow" sound.
Q = 0-5
Smooth, warm tone
Q = 15-20
Classic acid squelch
Q = 25+
Self-oscillation territory
React Performance: Refs vs State
One of the most critical lessons for building audio apps in React: never use useState for audio parameters. React's render cycle introduces latency between user input and audio output.
❌ Using State (Causes Latency)
// BAD: State updates trigger re-renders const [cutoff, setCutoff] = useState(800) // This won't update the filter // until the next render cycle! filter.frequency.value = cutoff
✓ Using Refs (Instant Response)
// GOOD: Refs bypass render cycle const cutoffRef = useRef(800) cutoffRef.current = cutoff // Sync on render // Audio callback reads ref directly filter.frequency.value = cutoffRef.current
The pattern: use useState for UI display, but immediately sync to a useRef. Audio callbacks read from refs, ensuring parameter changes take effect instantly—not on the next React render cycle.
Why Not AudioWorklet?
AudioWorklet is the modern way to do custom DSP in the browser—it runs your code on the audio render thread with guaranteed timing. So why didn't I use it here?
For this project, native nodes were sufficient. The 303's signal path maps directly to Web Audio primitives: OscillatorNode → BiquadFilterNode → GainNode. No custom DSP algorithms required.
AudioWorklet would be essential if I needed:
- • Custom filter topologies (ladder, SVF, Moog-style)
- • Waveshaping or distortion algorithms
- • Sample-accurate event timing within the audio callback
- • WASM-based DSP (Rust/C++ compiled to WebAssembly)
The trade-off: AudioWorklet adds complexity (separate processor files, message passing for parameters) and doesn't improve the sound when native nodes already do the job.
"For more accurate 303 filter emulation, I would implement a diode ladder filter in an AudioWorklet using the Zavalishin virtual analog approach. But for a web demo, BiquadFilterNode with careful envelope design gets you 90% there."
Performance Characteristics
<1ms
Timing Jitter
60fps
Visualizer
~2%
CPU Usage
0
Dependencies
Measured on M1 MacBook Air, Chrome 120, at 138 BPM with drums enabled
Key Engineering Decisions
Look-ahead scheduling over setInterval
Achieves sample-accurate timing even under main thread load
useRef for all audio parameters
Bypasses React render cycle for instant response
Native nodes over AudioWorklet
Simpler architecture when custom DSP isn't required
cancelScheduledValues before parameter changes
Prevents envelope automation from fighting with UI controls