Agent skill

web-audio

Use when working with Web Audio API. Applies AudioContext management, signal routing, AudioWorklet patterns, and audio cleanup best practices.

Stars 163
Forks 31

Install this agent skill to your Project

npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/development/web-audio

SKILL.md

Web Audio Best Practices

Apply when building audio features with the Web Audio API.

Documentation: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API

AudioContext Management

Singleton Pattern

Share a single AudioContext across the application:

javascript
let ctx = null;

export function getAudioContext() {
  if (!ctx) {
    ctx = new (window.AudioContext || window.webkitAudioContext)();
  }
  return ctx;
}

export async function ensureRunning() {
  const audioCtx = getAudioContext();
  if (audioCtx.state === 'suspended') {
    await audioCtx.resume();
  }
  return audioCtx;
}

iOS/Safari Unlock

AudioContext must be resumed after a user gesture on iOS:

javascript
// Unlock on first user interaction
function unlockAudio() {
  ensureRunning().then(() => {
    document.removeEventListener('touchstart', unlockAudio);
    document.removeEventListener('click', unlockAudio);
  });
}

document.addEventListener('touchstart', unlockAudio, { once: true });
document.addEventListener('click', unlockAudio, { once: true });

Signal Flow

Basic Node Chain

Source → Gain → Filter → Destination
javascript
const ctx = getAudioContext();

// Create nodes
const source = ctx.createBufferSource();
const gain = ctx.createGain();
const filter = ctx.createBiquadFilter();

// Connect chain (returns destination for chaining)
source.connect(gain).connect(filter).connect(ctx.destination);

// Configure nodes
gain.gain.value = 0.8;
filter.type = 'lowpass';
filter.frequency.value = 2000;

// Start playback
source.buffer = audioBuffer;
source.start();

Parallel Routing (Dry/Wet Mix)

              ┌──→ Dry Gain ──────→┐
Source → Split│                    ├──→ Master → Destination
              └──→ Effect → Wet ──→┘
javascript
// Dry/wet mix for reverb
const dryGain = ctx.createGain();
const wetGain = ctx.createGain();
const convolver = ctx.createConvolver();

source.connect(dryGain).connect(masterGain);
source.connect(convolver).connect(wetGain).connect(masterGain);

// Control mix (0 = dry, 1 = wet)
function setWetMix(amount) {
  dryGain.gain.value = 1 - amount;
  wetGain.gain.value = amount;
}

Send/Return Pattern

javascript
// Create reverb send
const reverbSend = ctx.createGain();
const reverb = ctx.createConvolver();
reverb.buffer = impulseResponse;

reverbSend.connect(reverb).connect(ctx.destination);

// Multiple sources can connect to the send
source1.connect(reverbSend);
source2.connect(reverbSend);

// Control send amount per source
const source1Send = ctx.createGain();
source1Send.gain.value = 0.3;  // 30% to reverb
source1.connect(source1Send).connect(reverbSend);

AudioWorklet

When to Use

  • Real-time audio processing on audio thread
  • Avoid main thread blocking for audio
  • Sample-accurate timing
  • Custom DSP algorithms

Requires HTTPS (or localhost for development).

Processor (Runs on Audio Thread)

javascript
// my-processor.js - Registered as AudioWorklet
class MyProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
    this.phase = 0;
  }

  static get parameterDescriptors() {
    return [
      { name: 'frequency', defaultValue: 440, minValue: 20, maxValue: 20000 }
    ];
  }

  process(inputs, outputs, parameters) {
    const output = outputs[0];
    const frequency = parameters.frequency;

    for (let channel = 0; channel < output.length; channel++) {
      const outputChannel = output[channel];
      for (let i = 0; i < outputChannel.length; i++) {
        const freq = frequency.length > 1 ? frequency[i] : frequency[0];
        outputChannel[i] = Math.sin(this.phase);
        this.phase += (2 * Math.PI * freq) / sampleRate;
      }
    }

    return true;  // Keep processor alive
  }
}

registerProcessor('my-processor', MyProcessor);

Main Thread Usage

javascript
// Load and use the worklet
async function setupWorklet() {
  const ctx = getAudioContext();

  // Register processor module
  await ctx.audioWorklet.addModule('my-processor.js');

  // Create node
  const node = new AudioWorkletNode(ctx, 'my-processor');

  // Set parameters
  node.parameters.get('frequency').value = 880;

  // Communicate via MessagePort
  node.port.onmessage = (e) => {
    console.log('From worklet:', e.data);
  };
  node.port.postMessage({ command: 'start' });

  // Connect to output
  node.connect(ctx.destination);

  return node;
}

Feature Detection & Fallback

javascript
function isWorkletSupported() {
  return 'audioWorklet' in AudioContext.prototype &&
         (location.protocol === 'https:' || location.hostname === 'localhost');
}

async function createRecorder() {
  const ctx = getAudioContext();

  if (isWorkletSupported()) {
    // Modern path: AudioWorklet
    await ctx.audioWorklet.addModule('recorder-processor.js');
    return new AudioWorkletNode(ctx, 'recorder-processor');
  } else {
    // Fallback: ScriptProcessorNode (deprecated but widely supported)
    const processor = ctx.createScriptProcessor(4096, 1, 1);
    processor.onaudioprocess = (e) => {
      const samples = e.inputBuffer.getChannelData(0);
      // Process samples...
    };
    return processor;
  }
}

Loading Audio

Fetch and Decode

javascript
async function loadAudioBuffer(url) {
  const ctx = getAudioContext();

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to load audio: ${response.status}`);
  }

  const arrayBuffer = await response.arrayBuffer();
  return ctx.decodeAudioData(arrayBuffer);
}

// Usage
const buffer = await loadAudioBuffer('/audio/sample.mp3');
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start();

With Loading State

javascript
async function loadWithProgress(url, onProgress) {
  const response = await fetch(url);
  const contentLength = response.headers.get('Content-Length');
  const total = parseInt(contentLength, 10);

  const reader = response.body.getReader();
  const chunks = [];
  let loaded = 0;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    chunks.push(value);
    loaded += value.length;
    onProgress?.(loaded / total);
  }

  const arrayBuffer = new Uint8Array(loaded);
  let position = 0;
  for (const chunk of chunks) {
    arrayBuffer.set(chunk, position);
    position += chunk.length;
  }

  const ctx = getAudioContext();
  return ctx.decodeAudioData(arrayBuffer.buffer);
}

Parameter Automation

Smooth Value Changes

javascript
const gain = ctx.createGain();

// BAD: Instant change causes clicks
gain.gain.value = 0;

// GOOD: Smooth ramp over 50ms
const now = ctx.currentTime;
gain.gain.setTargetAtTime(0, now, 0.05);

// Or linear ramp
gain.gain.linearRampToValueAtTime(0, now + 0.05);

Scheduling

javascript
const osc = ctx.createOscillator();

// Schedule frequency changes
const now = ctx.currentTime;
osc.frequency.setValueAtTime(440, now);
osc.frequency.linearRampToValueAtTime(880, now + 1);  // Slide to 880Hz over 1s
osc.frequency.setValueAtTime(440, now + 2);           // Jump back

// Schedule start/stop
osc.start(now);
osc.stop(now + 3);

Cleanup

Node Disposal

Always disconnect and dereference nodes when done:

javascript
class AudioEffect {
  constructor(ctx) {
    this.ctx = ctx;
    this.source = null;
    this.gain = ctx.createGain();
    this.filter = ctx.createBiquadFilter();

    this.gain.connect(this.filter).connect(ctx.destination);
  }

  play(buffer) {
    // Stop previous if playing
    this.stop();

    this.source = this.ctx.createBufferSource();
    this.source.buffer = buffer;
    this.source.connect(this.gain);
    this.source.start();

    // Clean up when finished
    this.source.onended = () => {
      this.source?.disconnect();
      this.source = null;
    };
  }

  stop() {
    if (this.source) {
      this.source.stop();
      this.source.disconnect();
      this.source = null;
    }
  }

  dispose() {
    this.stop();
    this.filter.disconnect();
    this.gain.disconnect();
    this.filter = null;
    this.gain = null;
  }
}

BufferSource Reuse

AudioBufferSourceNode is single-use. Create new ones for each playback:

javascript
// BAD: Trying to reuse source
const source = ctx.createBufferSource();
source.buffer = buffer;
source.start();
source.start();  // Error! Already started

// GOOD: Create new source each time
function playSound(buffer) {
  const source = ctx.createBufferSource();
  source.buffer = buffer;
  source.connect(ctx.destination);
  source.start();
  source.onended = () => source.disconnect();
}

Common Issues

Click/Pop Prevention

javascript
// Fade in/out to avoid clicks
function fadeIn(gainNode, duration = 0.05) {
  const now = gainNode.context.currentTime;
  gainNode.gain.setValueAtTime(0, now);
  gainNode.gain.linearRampToValueAtTime(1, now + duration);
}

function fadeOut(gainNode, duration = 0.05) {
  const now = gainNode.context.currentTime;
  gainNode.gain.setValueAtTime(gainNode.gain.value, now);
  gainNode.gain.linearRampToValueAtTime(0, now + duration);
}

Timing Precision

javascript
// BAD: JavaScript timing (imprecise)
setTimeout(() => source.start(), 1000);

// GOOD: Web Audio scheduling (sample-accurate)
source.start(ctx.currentTime + 1);

Memory Leaks

javascript
// BAD: Nodes accumulate without cleanup
function playNote() {
  const osc = ctx.createOscillator();
  osc.connect(ctx.destination);
  osc.start();
  osc.stop(ctx.currentTime + 0.5);
  // Oscillator never disconnected!
}

// GOOD: Clean up after playback
function playNote() {
  const osc = ctx.createOscillator();
  osc.connect(ctx.destination);
  osc.start();
  osc.stop(ctx.currentTime + 0.5);
  osc.onended = () => osc.disconnect();
}

Avoid

  • Creating multiple AudioContexts (use singleton)
  • Setting gain.value directly during playback (use ramps)
  • Forgetting to disconnect nodes (causes memory leaks)
  • Using setTimeout for audio timing (use ctx.currentTime)
  • Reusing AudioBufferSourceNode (create new each time)
  • Forgetting iOS unlock (resume on user gesture)
  • Skipping HTTPS for AudioWorklet (feature detection!)

Didn't find tool you were looking for?

Be as detailed as possible for better results