Skip to main content
← Back to Synth
Case StudyDeep Technical Dive

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

01

Look-ahead scheduling over setInterval

Achieves sample-accurate timing even under main thread load

02

useRef for all audio parameters

Bypasses React render cycle for instant response

03

Native nodes over AudioWorklet

Simpler architecture when custom DSP isn't required

04

cancelScheduledValues before parameter changes

Prevents envelope automation from fighting with UI controls