Why Web Audio Sounds Overlap or Fail to Stop

When building web-based synthesizers or sound effects using JavaScript's Web Audio API, a common trap is creating a new AudioContext on every click or keypress. In the original code, calling oscillator.stop() before declaring the new oscillator triggers a reference error, while removing it allows multiple sound nodes to play concurrently across separate audio contexts.

The Core Issues in the Original Code

  • Reference Errors: Calling oscillator.stop() before the variable oscillator is created in the event listener scope causes an unhandled JavaScript error.
  • Multiple Audio Contexts: Re-instantiating new AudioContext() on every click exhausts system audio instances and prevents proper control over existing nodes.
  • Scope Isolation: The local oscillator variable loses reference as soon as the click listener function finishes executing.

The Solution: Reusing AudioContext and Storing Node References

To ensure only one tone plays at a time (monophonic behavior), instantiate AudioContext once outside the event listener and store a reference to the active oscillator globally or in an outer scope.

// 1. Create a single AudioContext instance outside event listeners
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let currentOscillator = null;

// Frequency lookup map for cleaner syntax
const frequencies = {
  btn1: 200,
  btn2: 300,
  btn3: 400,
  btn4: 500
};

document.getElementById('button-container').addEventListener('click', function(evt) {
  const key = evt.target.id;
  const frq = frequencies[key];
  
  if (!frq) return; // Exit if clicked element isn't a synth button

  // Resume context if suspended by browser autoplay policies
  if (audioCtx.state === 'suspended') {
    audioCtx.resume();
  }

  // 2. Stop and disconnect the previous oscillator if it exists
  if (currentOscillator) {
    currentOscillator.stop();
    currentOscillator.disconnect();
  }

  // 3. Create new Oscillator and Gain nodes
  const oscillator = audioCtx.createOscillator();
  const gainNode = audioCtx.createGain();

  oscillator.type = 'triangle';
  oscillator.frequency.setValueAtTime(frq, audioCtx.currentTime);

  // Prevent clipping by adjusting gain level
  gainNode.gain.setValueAtTime(0.2, audioCtx.currentTime);

  // 4. Connect nodes to destination
  oscillator.connect(gainNode);
  gainNode.connect(audioCtx.destination);

  // Start the sound and hold reference
  oscillator.start();
  currentOscillator = oscillator;
});

Bonus: Preventing Audio Clicks and Pops

Stopping an oscillator abruptly can cause an audio artifact (a sharp pop or click sound) because the audio waveform is suddenly cut off mid-cycle. To fix this, apply a micro fade-out using gain parameter ramps before stopping the old node:

if (currentGainNode && currentOscillator) {
  const now = audioCtx.currentTime;
  // Rapid fade out over 10 milliseconds
  currentGainNode.gain.setValueAtTime(currentGainNode.gain.value, now);
  currentGainNode.gain.linearRampToValueAtTime(0.001, now + 0.01);
  
  // Stop oscillator right after fade out completes
  currentOscillator.stop(now + 0.01);
}

Summary

Always maintain a single AudioContext throughout your application's lifecycle. By keeping track of active audio nodes in an outer scope variable, you can cleanly stop previous sounds before starting new ones, giving you complete control over web sound playback.