Welcome to this tutorial on the Web Audio API. I’ll show you how to load and play sounds, how to adjust the volume, how to loop sounds, and how to crossfade tracks in and out – everything you need to get started implementing audio into your HTML5 games.
Web Audio – a brief history
Before the release of the Web Audio API in 2011, the only cross-platform way of playing audio in the browser (without using Flash) was with the <audio> element. The <audio> element has a very basic feature set – there isn’t much to it beyond loading, playing, pausing and stopping a single track.
For the game developers taking advantage of the new and improved graphics APIs (WebGL, Canvas), audio support – or lack thereof – was a source of constant frustration. As graphics advanced, the paucity of the <audio> feature set became more pronounced. Worse still, <audio> was plagued by bugs across the different browser implementations, thwarting developers’ attempts to use the API for even the most basic of it’s intended purposes.
Ingenious hacks had to be devised – the ‘audio sprite’* was invented simply to get audio to work correctly in iOS. Developers clamoured for a better audio API to complement the rich, engaging visual experiences they were creating with the far-superior graphics APIs.
Enter, the Web Audio API.
The Web Audio API
The Web Audio API enables developers to create vibrant, immersive audio experiences in the browser. It provides a high-level abstraction for manipulating and controlling audio.
The API has a node-based architecture: a sound can be routed through several different types of nodes before reaching it’s end-point. Each node has it’s own unique purpose; there are nodes for generating, modifying, analysing and outputting sounds.
Where is it supported?
The Web Audio API is currently supported in all good browsers.
Test for API Support
Before you can load a sound, you first need to check whether the API is supported in your target browser. This snippet of code attempts to create an AudioContext.
var context;
try {
// still needed for Safari
window.AudioContext = window.AudioContext || window.webkitAudioContext;
// create an AudioContext
context = new AudioContext();
} catch(e) {
// API not supported
throw new Error('Web Audio API not supported.');
}
If the test fails, you have one of three options: a) ignore it (user gets no audio); b) use an <audio> sprite; c) use a Flash fallback. Personally, I’m in favour of option a) – as mentioned in the footnotes, audio sprites are a real pain in the backside to create, and Flash is a no-go in HTML5 mobile games.
Load a sound
Next, we’ll load a sound. The binary audio data is loaded into an ArrayBuffer via Ajax. In the onload callback, it’s decoded using the AudioContext’s decodeAudioData method. The decoded audio is then assigned to our sound
variable.
var sound;
/**
* Example 1: Load a sound
* @param {String} src Url of the sound to be loaded.
*/
function loadSound(url) {
var request = new XMLHttpRequest();
request.open('GET', url, true);
request.responseType = 'arraybuffer';
request.onload = function() {
// request.response is encoded... so decode it now
context.decodeAudioData(request.response, function(buffer) {
sound = buffer;
}, function(err) {
throw new Error(err);
});
}
request.send();
}
// loadSound('audio/BaseUnderAttack.mp3');
Testing file format support
To ensure that our audio is playable wherever the Web Audio API is supported, we’ll need to provide the browser with two variants of our audio source, in MP3 and Ogg format. This code snippet checks whether the browser can play Ogg format audio, and helps us to fall back to the MP3 where it’s not supported.
var format = '.' + (new Audio().canPlayType('audio/ogg') !== '' ? 'ogg' : 'mp3');
// loadSound('audio/baseUnderAttack' + format);
Play a sound
To play a sound, we need to take the AudioBuffer containing our sound, and use it to create an AudioBufferSourceNode. We then connect the AudioBufferSourceNode to the AudioContext’s destination and call the start() method to play it.
/**
* Example 2: Play a sound
* @param {Object} buffer AudioBuffer object - a loaded sound.
*/
function playSound(buffer) {
var source = context.createBufferSource();
source.buffer = buffer;
source.connect(context.destination);
source.start(0);
}
// playSound(sound);
Load multiple sounds
To load more than one sound, reference your sounds in a format that can be iterated over (like an array
or an object
).
var sounds = {
laser : {
src : 'audio/laser'
},
coin : {
src : 'audio/coin'
},
explosion : {
src : 'audio/explosion'
}
};
/**
* Example 3a: Modify loadSound fn to accept changed params
* @param {Object} obj Object containing url of sound to be loaded.
*/
function loadSoundObj(obj) {
var request = new XMLHttpRequest();
request.open('GET', obj.src + format, true);
request.responseType = 'arraybuffer';
request.onload = function() {
// request.response is encoded... so decode it now
context.decodeAudioData(request.response, function(buffer) {
obj.buffer = buffer;
}, function(err) {
throw new Error(err);
});
}
request.send();
}
// loadSoundObj({ src : 'audio/baseUnderAttack' });
/**
* Example 3b: Function to loop through and load all sounds
* @param {Object} obj List of sounds to loop through.
*/
function loadSounds(obj) {
var len = obj.length, i;
// iterate over sounds obj
for (i in obj) {
if (obj.hasOwnProperty(i)) {
// load sound
loadSoundObj(obj[i]);
}
}
}
// loadSounds(sounds);
Adjusting the volume
In the ‘play’ example, we created an AudioBufferSourceNode, and then connected it to a destination. To change the volume of an audio source, we need to create an AudioBufferSourceNode as before, but then we create a GainNode, and connect the AudioBufferSourceNode to that, before connecting the GainNode to the destination. Then we can use the GainNode to alter the volume.
sounds = {
laser : {
src : 'audio/laser',
volume : 2
},
coin : {
src : 'audio/coin',
volume : 1.5
},
explosion : {
src : 'audio/explosion',
volume : 0.5
}
};
/**
* Example 4: Modify the playSoundObj function to accept volume property
* @param {Object} obj Object containing url of sound to be loaded.
*/
function playSoundObj(obj) {
var source = context.createBufferSource();
source.buffer = obj.buffer;
// create a gain node
obj.gainNode = context.createGain();
// connect the source to the gain node
source.connect(obj.gainNode);
// set the gain (volume)
obj.gainNode.gain.value = obj.volume;
// connect gain node to destination
obj.gainNode.connect(context.destination);
// play sound
source.start(0);
}
// loadSounds(sounds);
Muting a sound
To mute a sound, we simply need to set the value of the gain on the GainNode to zero.
var nyan = {
src : 'audio/nyan',
volume : 1
};
loadSoundObj(nyan);
/**
* Example 5: Muting a sound
* @param {object} obj Object containing a loaded sound buffer.
*/
function muteSoundObj(obj) {
obj.gainNode.gain.value = 0;
}
// muteSoundObj(nyan);
Looping sounds
Whenever you’re creating any game, you should always be mindful of optimising file sizes. There’s no point making your player download a 10Mb audio file, when the same effect is achievable with 0.5Mb of looped audio. This is especially the case if you’re creating games for HTML5 mobile game portals.
To create a looping sound, set the loop attribute of the AudioBufferSourceNode’s to true just before connecting it to the GainNode.
sounds = {
laser : {
src : 'audio/laser',
volume : 1,
loop: true
},
coin : {
src : 'audio/coin',
volume : 1,
loop: true
},
explosion : {
src : 'audio/explosion',
volume : 1,
loop: true
}
};
/**
* Example 6: Modify the playSoundObj function again to accept a loop property
* @param {Object} obj Object containing url of sound to be loaded.
*/
function playSoundObj(obj) {
var source = context.createBufferSource();
source.buffer = obj.buffer;
// loop the audio?
source.loop = obj.loop;
// create a gain node
obj.gainNode = context.createGain();
// connect the source to the gain node
source.connect(obj.gainNode);
// set the gain (volume)
obj.gainNode.gain.value = obj.volume;
// connect gain node to destination
obj.gainNode.connect(context.destination);
// play sound
source.start(0);
}
// loadSounds(sounds);
Crossfading sounds
Making use of multiple audio tracks is a great way to aurally demarcate the different areas of your game. In Brickout, for example, I crossfade between the title music and the game music when the game starts, and back again when it ends.
To crossfade between two tracks, you’ll need to schedule a transition between the gain volume ‘now’ and a time fixed in the future (e.g. ‘now’ plus 3 seconds). ‘Now’ in Web Audio terms is the AudioContext’s currentTime property – the time that has elapsed since the AudioContext was created.
var crossfade = {
battle : {
src : 'audio/the-last-encounter',
volume : 1,
loop : true
},
eclipse : {
src : 'audio/red-eclipse',
volume : 0,
loop : true
}
};
/**
* Example 7: Crossfading between two sounds
* @param {Object} a Sound object to fade out.
* @param {Object} b Sound object to fade in.
*/
function crossFadeSounds(a, b) {
var currentTime = context.currentTime,
fadeTime = 3; // 3 seconds fade time
// fade out
a.gainNode.gain.linearRampToValueAtTime(1, currentTime);
a.gainNode.gain.linearRampToValueAtTime(0, currentTime + fadeTime);
// fade in
b.gainNode.gain.linearRampToValueAtTime(0, currentTime);
b.gainNode.gain.linearRampToValueAtTime(1, currentTime + fadeTime);
}
// crossFadeSounds(crossfade.battle, crossfade.eclipse);
LinearRampToValueAtTime has a catch that isn’t immediately apparent – if you try to change the gain after using it, nothing will happen. You need to cancel any scheduled effects you’ve applied before you can set the gain, even if your schedule has long since expired. You can do this with the cancelScheduledValues()
method.
Final Tip
If you’re struggling to get a sense of what each of the vast array of audio nodes does, head over to the spec. Under each node’s subheading you’ll find the following:
numberOfInputs: n
numberOfOutputs: n
From this you can get a rough idea of what the node does. If the node has no inputs and one output, it will load or synthesise audio. If it has one input and n outputs, an audio source can be connected to it and modified or analysed in some respect. If the node has inputs but no output, it will be an end-point – the final destination that connects your audio to your user’s headphones or speakers.
Further Reading
https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API
*The process of creating an audio sprite was painstaking – all of your game’s audio had to be composited into one file, and once loaded the ‘playhead’ had to be jogged back and forth between each of the ‘sprites’. This workaround still had it’s downsides – sounds were often clipped if a new sound was triggered before the previous sound had finished playing.