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:
Overloading the destination when the volume of the audio is <-1 or >1 Web Audio API glitching/distortions issues
Turning oscillators on and off abruptly causes audible clicks Web Audio: the ugly click and the human ear
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.