Skip to main content

Web Audio API Implementation

This guide explores the production Web Audio API implementation used throughout the User Interface Wiki for procedural sound generation. All sound effects are synthesized in real-time using oscillators, noise generators, and audio processing nodes.

Architecture

AudioContext Management

The implementation uses a singleton pattern to manage the AudioContext lifecycle:
lib/sounds.ts
let audioContext: AudioContext | null = null;

function getAudioContext(): AudioContext {
  if (!audioContext) {
    audioContext = new AudioContext();
  }
  if (audioContext.state === "suspended") {
    audioContext.resume();
  }
  return audioContext;
}
The AudioContext may be suspended by browser autoplay policies. Always resume the context on user interaction.

Sound Synthesis Patterns

Click Sound: Filtered Noise

Short burst of filtered noise for immediate tactile feedback:
lib/sounds.ts
click: () => {
  try {
    const ctx = getAudioContext();
    const t = ctx.currentTime;

    // Generate white noise buffer
    const noise = ctx.createBufferSource();
    const buf = ctx.createBuffer(1, ctx.sampleRate * 0.008, ctx.sampleRate);
    const data = buf.getChannelData(0);
    for (let i = 0; i < data.length; i++) {
      data[i] = (Math.random() * 2 - 1) * Math.exp(-i / 50);
    }
    noise.buffer = buf;

    // Bandpass filter for tonal character
    const filter = ctx.createBiquadFilter();
    filter.type = "bandpass";
    filter.frequency.value = 4000 + Math.random() * 1000;
    filter.Q.value = 3;

    const gain = ctx.createGain();
    gain.gain.value = 0.5 + Math.random() * 0.15;

    // Connect audio graph
    noise.connect(filter);
    filter.connect(gain);
    gain.connect(ctx.destination);
    noise.start(t);
  } catch {}
}
Key Techniques:
  • Exponential decay envelope: Math.exp(-i / 50)
  • Random frequency variation: 4000 + Math.random() * 1000
  • Short duration: 8ms buffer

Pop Sound: Frequency Sweep

Smooth frequency glide for non-destructive feedback:
lib/sounds.ts
pop: () => {
  const ctx = getAudioContext();
  const t = ctx.currentTime;

  const osc = ctx.createOscillator();
  const gain = ctx.createGain();

  osc.type = "sine";
  osc.frequency.setValueAtTime(400, t);
  osc.frequency.exponentialRampToValueAtTime(150, t + 0.04);

  gain.gain.setValueAtTime(0.35, t);
  gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05);

  osc.connect(gain);
  gain.connect(ctx.destination);
  osc.start(t);
  osc.stop(t + 0.05);
}
Parameters:
  • Start frequency: 400 Hz
  • End frequency: 150 Hz
  • Duration: 50ms
  • Envelope: Exponential decay

Success Sound: Arpeggio Sequence

Musical phrase conveying positive outcome:
lib/sounds.ts
success: () => {
  const ctx = getAudioContext();
  const t = ctx.currentTime;

  const notes = [523.25, 659.25, 783.99]; // C5, E5, G5
  const spacing = 0.08;

  notes.forEach((freq, i) => {
    const osc = ctx.createOscillator();
    const osc2 = ctx.createOscillator();
    const gain = ctx.createGain();
    const filter = ctx.createBiquadFilter();

    osc.type = "triangle";
    osc.frequency.value = freq;
    osc2.type = "sine";
    osc2.frequency.value = freq * 2; // Octave harmonic

    filter.type = "lowpass";
    filter.frequency.value = 3000;

    const start = t + i * spacing;
    const duration = 0.15;

    gain.gain.setValueAtTime(0, start);
    gain.gain.linearRampToValueAtTime(0.25, start + 0.01);
    gain.gain.exponentialRampToValueAtTime(0.001, start + duration);

    osc.connect(gain);
    osc2.connect(gain);
    gain.connect(filter);
    filter.connect(ctx.destination);

    osc.start(start);
    osc2.start(start);
    osc.stop(start + duration);
    osc2.stop(start + duration);
  });

  // Shimmer finish
  const shimmer = ctx.createOscillator();
  const shimmerGain = ctx.createGain();
  shimmer.type = "sine";
  shimmer.frequency.value = 1046.5; // C6
  shimmerGain.gain.setValueAtTime(0, t + 0.24);
  shimmerGain.gain.linearRampToValueAtTime(0.15, t + 0.26);
  shimmerGain.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
  shimmer.connect(shimmerGain);
  shimmerGain.connect(ctx.destination);
  shimmer.start(t + 0.24);
  shimmer.stop(t + 0.45);
}
Musical Structure:
  • Notes: C major triad (C5, E5, G5)
  • Timing: 80ms spacing between notes
  • Harmonics: Fundamental + octave doubling
  • Finish: High shimmer at C6

Error Sound: Distorted Dissonance

Harsh tone conveying failure:
lib/sounds.ts
error: () => {
  const ctx = getAudioContext();
  const t = ctx.currentTime;

  const osc1 = ctx.createOscillator();
  const osc2 = ctx.createOscillator();
  const gain = ctx.createGain();
  const distortion = ctx.createWaveShaper();

  // Waveshaper curve for distortion
  const curve = new Float32Array(256);
  for (let i = 0; i < 256; i++) {
    const x = i / 128 - 1;
    curve[i] = Math.tanh(x * 2);
  }
  distortion.curve = curve;

  // Detuned oscillators create beating
  osc1.type = "sawtooth";
  osc1.frequency.setValueAtTime(180, t);
  osc1.frequency.exponentialRampToValueAtTime(80, t + 0.25);

  osc2.type = "square";
  osc2.frequency.setValueAtTime(190, t);
  osc2.frequency.exponentialRampToValueAtTime(85, t + 0.25);

  gain.gain.setValueAtTime(0, t);
  gain.gain.linearRampToValueAtTime(0.3, t + 0.02);
  gain.gain.setValueAtTime(0.3, t + 0.08);
  gain.gain.linearRampToValueAtTime(0.25, t + 0.1);
  gain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);

  const filter = ctx.createBiquadFilter();
  filter.type = "lowpass";
  filter.frequency.value = 800;

  osc1.connect(distortion);
  osc2.connect(distortion);
  distortion.connect(gain);
  gain.connect(filter);
  filter.connect(ctx.destination);

  osc1.start(t);
  osc2.start(t);
  osc1.stop(t + 0.3);
  osc2.stop(t + 0.3);
}
Dissonance Techniques:
  • Detuned oscillators: 180 Hz vs 190 Hz (beating effect)
  • WaveShaper distortion: tanh transfer function
  • Frequency glide downward
  • Lowpass filter at 800 Hz

Audio Graph Patterns

Node Connection Best Practices

// Simple oscillator → gain → destination
const osc = ctx.createOscillator();
const gain = ctx.createGain();

osc.connect(gain);
gain.connect(ctx.destination);

Envelope Shaping

Linear vs Exponential Ramps:
// Linear: Constant rate of change
gain.gain.linearRampToValueAtTime(0.5, t + 0.1);

// Exponential: Natural-sounding decay
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
Exponential ramps cannot reach zero. Use 0.001 as the target value instead.
Attack-Decay-Sustain-Release (ADSR):
const t = ctx.currentTime;
const attack = 0.01;
const decay = 0.05;
const sustain = 0.7;
const release = 0.1;

gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(1, t + attack);
gain.gain.exponentialRampToValueAtTime(sustain, t + attack + decay);
gain.gain.setValueAtTime(sustain, t + duration - release);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);

Performance Considerations

Memory Management

// ✅ Good: Reuse AudioContext
const ctx = getAudioContext();

// ❌ Bad: Create new context per sound
const ctx = new AudioContext();

Error Handling

All sound functions wrap Web Audio code in try-catch blocks:
sounds = {
  click: () => {
    try {
      // Web Audio code
    } catch {}
  }
}
Rationale:
  • Browsers may not support Web Audio
  • AudioContext may fail to resume
  • Sounds should never break functionality

Buffer Generation

// Efficient: Generate once, reuse buffer
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.008, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < data.length; i++) {
  data[i] = (Math.random() * 2 - 1) * Math.exp(-i / 50);
}
noise.buffer = buf;

Integration with React

Button Component

components/button/index.tsx
import { sounds } from "@/lib/sounds";

function Button({ sound = true, onClick, ...props }: ButtonProps) {
  const handleClick: typeof onClick = (event) => {
    if (sound) {
      sounds.click();
    }
    onClick?.(event);
  };

  return <BaseButton onClick={handleClick} {...props} />;
}

Context-Specific Sounds

// Success action
onSubmit={() => {
  sounds.success();
  // ... success logic
}}

// Error handling
onError={() => {
  sounds.error();
  // ... error logic
}}

// State toggle
onToggle={() => {
  sounds.toggle();
  // ... toggle logic
}}

Browser Compatibility

Safari requires user gesture to unlock AudioContext. The implementation automatically resumes suspended contexts on first interaction.
All major browsers support Web Audio API:
  • Chrome/Edge: Full support
  • Firefox: Full support
  • Safari: Full support (requires gesture unlock)
  • Mobile browsers: Full support

Advanced Techniques

Frequency Randomization

Add subtle variation to prevent repetition fatigue:
filter.frequency.value = 4000 + Math.random() * 1000;

Gain Randomization

Simulate natural variation in interaction force:
gain.gain.value = 0.5 + Math.random() * 0.15;

Noise Envelopes

// Sharp attack, fast decay
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / 50);

// Smooth bell curve
const env = Math.sin((i / data.length) * Math.PI);
data[i] = (Math.random() * 2 - 1) * env;

Reference

Filter Types

TypeUse Case
lowpassDull harsh sounds, isolate bass
highpassRemove rumble, emphasize brightness
bandpassIsolate mid-range, telephone effect
notchRemove specific frequency
peakingBoost/cut specific frequency

Oscillator Types

TypeCharacter
sinePure, smooth, gentle
triangleWarm, hollow
squareBright, harsh, retro
sawtoothBuzzy, aggressive

Timing Guidelines

Sound TypeDurationUse Case
Click/Tick4-15msImmediate feedback
Pop/Confirm40-60msState change confirmation
Success300-500msTask completion
Error250-300msAttention without alarm
Warning200-300msAlert with repeat

Next Steps

Motion Implementation

Explore advanced animation patterns with Motion

Performance Optimization

Learn bundle size and runtime optimization techniques

Build docs developers (and LLMs) love