Search code examples
javascriptweb-audio-apigame-development

Web audio api "clicks" "crackles" "pops" & distortion noise elimination. Can I do any more?


I've recently started to look at the web audio api in order to introduce sounds and music into canvas based games. It was not long before I noticed that playing sounds simultaneously (e.g. rapid repeats of shooting sounds decaying over a couple of seconds and or, shooting sounds played over background music rapidly leads to horrible distortion.

To try and get my head round the causes of the unwanted noises I made a very simple keyboard instrument to play musical notes. When a single note is played you can hear an ugly click as the sound ends. When multiple keys are pressed in rapid succession, the situation becomes worse.

// create the context
const actx = new AudioContext();

function playNoteUgh(freq = 261.63, type = "sine", decay = 0.5) {
  // create oscillator and gain nodes
  let osc = actx.createOscillator();
  let vol = actx.createGain();

  // set the supplied values
  osc.frequency.value = freq;
  osc.type = type;

  vol.gain.value = 0.1;

  //create the audio graph
  osc.connect(vol).connect(actx.destination);

  osc.start(actx.currentTime);
  osc.stop(actx.currentTime + decay);
}

function playNote(freq = 261.63, type = "sine", decay = 2) {
  // Create a new oscillator and audio graph for each keypress
  createOsc(freq, type, decay);
}

function createOsc(freq, type, decay) {
  console.log(freq, type, decay);

  // create oscillator, gain and compressor nodes
  let osc = actx.createOscillator();
  let vol = actx.createGain();
  let compressor = actx.createDynamicsCompressor();

  // set the supplied values
  osc.frequency.value = freq;
  osc.type = type;

  // set the volume value so that we do not overload the destination
  // when multiple voices are played simmultaneously
  vol.gain.value = 0.1;

  //create the audio graph
  osc.connect(vol).connect(compressor).connect(actx.destination);

  // ramp up to volume so that we minimise the
  // ugly "click" when the key is pressed
  vol.gain.exponentialRampToValueAtTime(
    vol.gain.value,
    actx.currentTime + 0.03
  );

  // ramp down to minimise the ugly click when the oscillator stops
  vol.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + decay);

  osc.start(actx.currentTime);
  osc.stop(actx.currentTime + decay + 0.03);
}

window.addEventListener("keydown", keyDown, { passive: false });

// Some musical note values:
let C4 = 261.63,
  D4 = 293.66,
  E4 = 329.63,
  F4 = 349.23,
  G4 = 392,
  A5 = 440,
  B5 = 493.88,
  C5 = 523.25,
  D5 = 587.33,
  E5 = 659.25;

function keyDown(event) {
  let key = event.key;

  if (key === "q") playNoteUgh(C4);
  if (key === "w") playNoteUgh(D4);
  if (key === "e") playNoteUgh(E4);
  if (key === "r") playNoteUgh(F4);
  if (key === "t") playNoteUgh(G4);
  if (key === "y") playNoteUgh(A5);
  if (key === "u") playNoteUgh(B5);
  if (key === "i") playNoteUgh(C5);
  if (key === "o") playNoteUgh(D5);
  if (key === "p") playNoteUgh(E5);
}
<p>Keys Q through P play C4 through E4</p>

So, reading up on these problems I figured that there were a couple things going on:

So, the first link advises that we control the volume through a gain node and to also route the music through a Dynamics Compressor rather than linking directly to the AudioContext destination. I've also read that reducing the gain value tenfold

To reduce the 'Ugly clicks', we are advised to ramp the oscillators up and down, rather than just starting and stopping them abruptly.

Ideas in this post How feasible is it to use the Oscillator.connect() and Oscillator.disconnect() methods to turn on/off sounds in an app built with the Web Audio API? suggest that you can create oscillators on the fly as and when needed.

Using the above info I came up with this.

// create the context
const actx = new AudioContext();

function playNote(freq = 261.63, type = "sine", decay = 2) {
  // Create a new oscillator and audio graph for each keypress
  createOsc(freq, type, decay);
}

function createOsc(freq, type, decay) {
  
  // create oscillator, gain and compressor nodes
  let osc = actx.createOscillator();
  let vol = actx.createGain();
  let compressor = actx.createDynamicsCompressor();

  // set the supplied values
  osc.frequency.value = freq;
  osc.type = type;

  // set the volume value so that we do not overload the destination
  // when multiple voices are played simmultaneously
  vol.gain.value = 0.1;

  //create the audio graph
  osc.connect(vol).connect(compressor).connect(actx.destination);

  // ramp up to volume so that we minimise the
  // ugly "click" when the key is pressed
  vol.gain.exponentialRampToValueAtTime(
    vol.gain.value,
    actx.currentTime + 0.03
  );

  // ramp down to minimise the ugly click when the oscillator stops
  vol.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + decay);

  osc.start(actx.currentTime);
  osc.stop(actx.currentTime + decay + 0.03);
}

window.addEventListener("keydown", keyDown, { passive: false });

// Some musical note values:
let C4 = 261.63,
  D4 = 293.66,
  E4 = 329.63,
  F4 = 349.23,
  G4 = 392,
  A5 = 440,
  B5 = 493.88,
  C5 = 523.25,
  D5 = 587.33,
  E5 = 659.25;

function keyDown(event) {
  let key = event.key;

  if (key === "1") playNote(C4);
  if (key === "2") playNote(D4);
  if (key === "3") playNote(E4);
  if (key === "4") playNote(F4);
  if (key === "5") playNote(G4);
  if (key === "6") playNote(A5);
  if (key === "7") playNote(B5);
  if (key === "8") playNote(C5);
  if (key === "9") playNote(D5);
  if (key === "0") playNote(E5);

}
<p>Key 1 to 0 play C4 through to E5</p>

My questions now are, am I doing this correctly and can I do more as the clicks and distortion have been significantly reduced but still detectable if I go a little crazy on the keyboard!

I'd really appreciate and feedback on this, so, thanks in advance.


Solution

  • The volume automation in its current form shouldn't have any effect.

    You set the volume first.

    vol.gain.value = 0.1;
    

    And later you define a ramp which is supposed to ramp to the same value.

    vol.gain.exponentialRampToValueAtTime(
        vol.gain.value,
        actx.currentTime + 0.03
    );
    

    The result is a constant value of 0.1.

    Let's say you want to set the volume initially and want to keep the volume constant until actx.currentTime + decay before it finally fades out for another 0.03 seconds. The code for that would look like this.

    const currentTime = actx.currentTime;
    
    // This defines the initial value.
    vol.gain.setValueAtTime(0.1, currentTime);
    // This sets the starting point for the ramp.
    vol.gain.setValueAtTime(0.1, currentTime + decay);
    // Finally this schedules the fade out.
    vol.gain.exponentialRampToValueAtTime(
        0.001, 
        currentTime + decay + 0.03
    );
    

    Exponential ramps can't end at 0. Therefore there is still a tiny risk for glitches. You could avoid that by adding another linear ramp at the end. But I guess it's not necessary.