Web Audio API Loops

September 30, 2013

Audio looping seems like a basic feature that you've probably come to expect from your favorite media player. Perhaps you've even played with HTML5 Audio Loops using the <audio> tag only to discover that they still don't work cross browser in 2013.

Well the future is here thanks to the Web Audio API. An amazing foundation for games, synthesizers, audio players, trackers and more. All within the comfort of your browser.

So, let's talk about inspiration, compatibility and then build a looping demo together.

Inspiration

Ultimately we want something fun to play with like this...

Web Audio API Demo

Which come to think of it, isn't that far away from being a neat interactive music promotion.

For example, let's pretend an artist released their latest song as separate instrument, voice and effect tracks. These tracks could be toggled on/off, panned, filtered and more. All with simple controls that anyone could play with in their web browser.

Now, let's say that a person made a mix they really like. Their settings are just data points and that means their mix could be shared with a special URL. Maybe their friends like it too and they re-share and and so do others until it becomes really popular. Maybe the artist thanks the most popular contributors by officially distributing a compilation of their mixes. Wouldn't it be great to be a part of a shared creative experience like that? :D

That's just a realistic idea too so I'm sure you can think of even wilder ones!

Daydreaming like this is quite intoxicating so before we get too lost, let's check in with the reality of compatibility.

Compatibility

Did I mention Safari includes iOS Safari? Oh yes.

I'd say compatibility is actually pretty great. Especially for such a relatively new technology. Certainly enough browsers to code for so let's do that next!

Build a Looping Demo

Ok! Let's build a simple audio looping demo using the Web Audio API.

If all goes well you'll end up with something similar to the following.

The goal is to have two buttons, each of which will play a unique loop when clicked. We'll also make sure to sync new sounds to already playing ones. Lastly, we'll stop sounds that are currently playing when their buttons are clicked again.

All we need is HTML and well... more JavaScript than you think.

Oh and two audio loops from Minimal French Electro Loop by Greg Baumont in WAV format. Why WAV? See Web Audio API Loops and Formats for more info.

HTML

<form>
    <button id="button-loop-1" type="button" value="1">Loop 1</button>
    <button id="button-loop-2" type="button" value="2">Loop 2</button>
</form>

Two buttons with specific IDs and values to make our JavaScript life easier later on.

JavaScript

In the real world, functions like to be declared before they're called but we're going to go through the code in a more human friendly way. The final source will be provided so no worries about copying bits of code.

JavaScript - Audio Object


//--------------
// Audio Object
//--------------
var audio = {
    buffer: {},
    compatibility: {},
    files: [
        'synth.wav',
        'beat.wav'
    ],
    proceed: true,
    source_loop: {},
    source_once: {}
};

Creating an audio object keeps our global namespace clean and gives us a singular object to analyze when troubleshooting.

The buffer element will hold our downloaded and decoded audio elements. Think of each buffer like a cassette tape with one song on it.

The compatibility element will be populated with string properties later on. The strings are different depending on the browser so we'll figure them out once, set the properties and then use those strings as many times as we want.

The files array is a list of audio files we want to download. One for each button.

The proceed boolean wants to have things proceed by default. If a browser doesn't support Web Audio it will get changed to false and keep more complex code from executing unnecessarily.

The source_loop object is where buffer elements actually get played. For purposes of this demo, think of source elements like dual cassette players on a sweet boombox. You load a buffer (cassette tape) into your source (cassette player) in order to listen to it.

The source_once object will only be used to play sounds once from an offset before a source_loop will take over and loop indefinitely. This is only needed for browsers like Safari 6 that use the depreciated API functions "noteOn" and "noteOff".

JavaScript - Check Web Audio API Support


//-----------------------------
// Check Web Audio API Support
//-----------------------------
try {
    window.AudioContext = window.AudioContext || window.webkitAudioContext;
    audio.context = new window.AudioContext();
} catch(e) {
    audio.proceed = false;
    alert('Web Audio API not supported in this browser.');
}

Firefox likes window.AudioContext while Chrome, Opera and Safari prefer window.webkitAudioContext. We try each variant with the winner being normalized as window.AudioContext.

Next we create a new window.AudioContext and assign it to audio.context.

If any of the above fails we instead set the audio.proceed boolean to false and alert the user with bad news.

If audio.proceed equals true we'll continue with more JavaScript. Simply imagine all subsequent code wrapped with the following.


if (audio.proceed) {
   ...
}

JavaScript - Compatibility


//---------------
// Compatibility
//---------------
(function() {
    var start = 'start',
        stop = 'stop',
        buffer = audio.context.createBufferSource();
 
    if (typeof buffer.start !== 'function') {
        start = 'noteOn';
    }
    audio.compatibility.start = start;
 
    if (typeof buffer.stop !== 'function') {
        stop = 'noteOff';
    }
    audio.compatibility.stop = stop;
})();

We start with an anonymous self invoking function to keep the global namespace clean.

Buffer is an expendable variable which we set to a new BufferSourceNode. Mainly so we can poke it and see which function naming it supports. The newer "start" and "stop" or the older "noteOn" and "noteOff".

Results are saved for later use in the audio.compatibility object using the newer start and stop names.

JavaScript - Setup Audio Files and Buttons


//-------------------------------
// Setup Audio Files and Buttons
//-------------------------------
for (var a in audio.files) {
    (function() {
        var i = parseInt(a) + 1;
        var req = new XMLHttpRequest();
        req.open('GET', audio.files[i - 1], true); // array starts with 0 hence the -1
        req.responseType = 'arraybuffer';
        req.onload = function() {
            audio.context.decodeAudioData(
                req.response,
                function(buffer) {
                    audio.buffer[i] = buffer;
                    audio.source_loop[i] = {};
                    var button = document.getElementById('button-loop-' + i);
                    button.addEventListener('click', function(e) {
                        e.preventDefault();
                        audio.play(this.value, false);
                    });
                },
                function() {
                    console.log('Error decoding audio "' + audio.files[i - 1] + '".');
                }
            );
        };
        req.send();
    })();
}

There is a lot going on here; mainly due to the XMLHttpRequest (aka XHR).

We start out with a for loop to go through our audio.files array.

We then immediately open an anonymous self invoking function for two reasons. To keep our global namespace clean and more importantly, protect certain variables so they are still available when our XHR finishes.

On line 6 we set the variable "i" to the value of "a" plus one. This makes more sense if you think of the "a" array counting upwards from 0 when normally we want to count from 1 for our HTML buttons, audio buffers, and sources.

Next we create a new XHR and proceed to configure it. Nothing will happen until we call req.send() so the subsequent order of configuration is personal preference.

On line 8, req.open is set to use a GET request for audio.files[x] and download the file asynchronously thanks to the the true boolean.

Next we set req.responseType to arraybuffer which you can think of as an easy to digest format.

On line 10 we setup a req.onload function which will process audio once it finishes downloading.

We start by feeding three things into the audio.context.decodeAudioData() function. The first is req.response which you can think of as the raw audio data we just downloaded. The second is a function that will be called if the audio is decoded successfully. The third is a function that will be called if decoding was not possible.

On line 13, our success function gets passed a buffer. This is the decoded audio that is ready for further use. If we go with our boombox metaphor we just received a cassette tape and store it at audio.buffer[i] for later use.

Next we setup a placeholder object at audio.source_loop[i] which is what will ultimately play our sounds. The cassette player (source) for our cassette tapes (buffer).

On line 16, we find the button associated with our audio (1 or 2) and add a click handler. The handler will call audio.play() and pass a number based on clicked button's value. This completes our success function.

On line 22, we define an error function in case our audio fails to decode. In that event we simply log some information to the console.

Finally with everything in place we run req.send() to activate the XHR and loop around to do it again for the next file. Since we protected our variables with an anonymous self invoking function, we don't have to worry about timing issues at all. Sweet!

JavaScript - audio.play


audio.play = function(n) {
    if (audio.source_loop[n]._playing) {
        audio.stop(n);
    } else {
        audio.source_loop[n] = audio.context.createBufferSource();
        audio.source_loop[n].buffer = audio.buffer[n];
        audio.source_loop[n].loop = true;
        audio.source_loop[n].connect(audio.context.destination);
 
        var offset = audio.findSync(n);
        audio.source_loop[n]._startTime = audio.context.currentTime;
 
        if (audio.compatibility.start === 'noteOn') {
            /*
            The depreciated noteOn() function does not support offsets.
            Compensate by using noteGrainOn() with an offset to play once and then schedule a noteOn() call to loop after that.
            */
            audio.source_once[n] = audio.context.createBufferSource();
            audio.source_once[n].buffer = audio.buffer[n];
            audio.source_once[n].connect(audio.context.destination);
            audio.source_once[n].noteGrainOn(0, offset, audio.buffer[n].duration - offset); // currentTime, offset, duration
            /*
            Note about the third parameter of noteGrainOn().
            If your sound is 10 seconds long, your offset 5 and duration 5 then you'll get what you expect.
            If your sound is 10 seconds long, your offset 5 and duration 10 then the sound will play from the start instead of the offset.
            */
 
            // Now queue up our looping sound to start immediatly after the source_once audio plays.
            audio.source_loop[n][audio.compatibility.start](audio.context.currentTime + (audio.buffer[n].duration - offset));
        } else {
            audio.source_loop[n][audio.compatibility.start](0, offset);
        }
 
        audio.source_loop[n]._playing = true;
    }
};

Our audio.play function takes one parameter which specifies the audio.buffer (cassette tape) to play.

If the boolean "_playing" for this particular audio.source_loop is already true then run audio.stop() otherwise continue with the rest of the else statement.

We set audio.source_loop[n] to a new BufferSource. Think of a BufferSource as disposable play thing. You use them once to play a sound (or loop), they expire when that sound stops and then you create more as you need them.

Next we set the source_loop buffer (cassette player) to the equivalent audio.buffer (cassette tape) that was setup previously.

On line 7, we set the loop option to true and connect the audio to the default destination. In most cases this means your speakers. Very much like connecting a cassette player (audio.source_loop) to a pair of headphones (audio.context.destination).

Next we find the offset our audio should start playing from using the audio.findSync() function. We'll cover this function later on. For now just know that it returns a number from 0 to however many seconds (including precision) are needed to sync to the currently playing audio.

We are just about to play our sound so we log the "_startTime" as the audio.context.currentTime so it can be used by a future findSync() function.

Interestingly, the audio context time starts at 0 if nothing has played. Once a sound is playing it will keep track of how many seconds have passed even if all sounds are stopped. Like a stopwatch you can start but never turn off. This precision timeline is very nice because we can get fancy and cue up certain events at certain times. Much finer control than working with JavaScript setTimeout() and <audio> elements.

On line 13 we check to see if the browser uses the depreciated "noteOn" function. If it does we need to use both "noteOn" and "noteGrainOn" to simulate what the Web Audio API "start" function does in one call.

On lines 18-20 we create a new buffer source, assign a buffer and then connect the speakers.

On line 21 we use the "noteGrainOn" function with three parameters. The first is set to 0 and means play immediately. The second is how far into the buffer to start playback from. The third is duration to play with one very important caveat. If you try to play longer than is left in the buffer it will decrease the second parameter to compensate. Weird ya!

On line 29 we setup our source_loop audio to play when source_once finishes. The source_loop will play from the beginning of the buffer which is the only thing the "noteOn" function supports and why we had to do all the extra work to play from an offset with "noteGrainOn".

Back to line 13, if the browser has the newer "start" function we instead jump to line 31 and run audio.source_loop[n][audio.compatibility.start] which when translated, can look like audio.source_loop[1][start]. We then feed two parameters to the start function. The first is 0 which means play immediately. The second is the offset or how far into the audio we want to move the playhead.

For example, if we had two samples which were 30 seconds long and one sample was already playing at the 15 second mark then our offset would be 15 seconds.

Finally we set the "_playing" boolean to true so we'll know to stop the audio in case the button for this audio element is clicked again.

JavaScript - audio.stop


audio.stop = function(n) {
    if (audio.source_loop[n]._playing) {
        audio.source_loop[n][audio.compatibility.stop](0);
        audio.source_loop[n]._playing = false;
        audio.source_loop[n]._startTime = 0;
        if (audio.compatibility.start === 'noteOn') {
            audio.source_once[n][audio.compatibility.stop](0);
        }
    }
};

If our audio.source_loop is playing we proceed with the if statement.

Line 3 will end up looking like audio.source_loop[1][stop](0) with the 0 meaning to stop immediately.

On line 4 and 5 we set the "_playing" boolean to false and the "_startTime" to 0.

On line 6 we check to see if the browser uses the depreciated "noteOn" function. If it does we simply stop the source_once player in case it was still active.

JavaScript - audio.findSync


audio.findSync = function(n) {
    var first = 0,
        current = 0,
        offset = 0;
 
    // Find the audio source with the earliest startTime to sync all others to
    for (var i in audio.source_loop) {
        current = audio.source_loop[i]._startTime;
        if (current > 0) {
            if (current < first || first === 0) {
                first = current;
            }
        }
    }
 
    if (audio.context.currentTime > first) {
        offset = (audio.context.currentTime - first) % audio.buffer[n].duration;
    }
 
    return offset;
};

The audio.findSync() function has one purpose and that is to return an offset for the audio that is about to play to start at.

For example, if one 30 second sound was already playing and it was half-way through, we would expect the findSync function to return 15. If that same 30 second sound was playing for 45 seconds then 45 - 30 would equal 15 yet again and our sounds would match up perfectly.

So, let's walk through the function now.

First we define some local variables which all default to 0.

Next we go through each audio.source_loop (cassette player) to find out if anything is playing.

We set the value of current to the source "_startTime" and if current is greater than 0 we proceed with a further if statement. If current is less than first OR first is exactly 0 then set first to current. Basically what this is doing is finding the lowest "_startTime" that is not 0, if possible. Zero is totally ok too of course because maybe no audio is playing yet or maybe only one audio button was clicked. In either case our "_startTime" would be 0 and totally valid.

Since audio.context.currentTime is a running counter from the time any audio was first played, it comes in quite handy. For example, let's imagine our 30 second loops again. Let's say the current time is 85 seconds and our "first" variable is 0. Since audio.context.currentTime is greater than "first" we can figure out the offset by taking the current time (85 seconds) minus the "first" variable (0 seconds) to get 85. We would then do 85 % (aka mod or remainder) the audio buffer duration of 30 seconds. So... 85 - 30 = 55 then 55 - 30 = 25 and 25 is our offset to sync up our audio.

Finally we return whatever offset we found and vanish in a poof of garbage collection.

Final Source


<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Web Audio API Loops Demo</title>
</head>
<body>
 
    <form>
        <button id="button-loop-1" type="button" value="1">Loop 1</button>
        <button id="button-loop-2" type="button" value="2">Loop 2</button>
    </form>
 
    <script>
    //--------------
    // Audio Object
    //--------------
    var audio = {
        buffer: {},
        compatibility: {},
        files: [
            'synth.wav',
            'beat.wav'
        ],
        proceed: true,
        source_loop: {},
        source_once: {}
    };
 
    //-----------------
    // Audio Functions
    //-----------------
    audio.findSync = function(n) {
        var first = 0,
            current = 0,
            offset = 0;
 
        // Find the audio source with the earliest startTime to sync all others to
        for (var i in audio.source_loop) {
            current = audio.source_loop[i]._startTime;
            if (current > 0) {
                if (current < first || first === 0) {
                    first = current;
                }
            }
        }
 
        if (audio.context.currentTime > first) {
            offset = (audio.context.currentTime - first) % audio.buffer[n].duration;
        }
 
        return offset;
    };
 
    audio.play = function(n) {
        if (audio.source_loop[n]._playing) {
            audio.stop(n);
        } else {
            audio.source_loop[n] = audio.context.createBufferSource();
            audio.source_loop[n].buffer = audio.buffer[n];
            audio.source_loop[n].loop = true;
            audio.source_loop[n].connect(audio.context.destination);
 
            var offset = audio.findSync(n);
            audio.source_loop[n]._startTime = audio.context.currentTime;
 
            if (audio.compatibility.start === 'noteOn') {
                /*
                The depreciated noteOn() function does not support offsets.
                Compensate by using noteGrainOn() with an offset to play once and then schedule a noteOn() call to loop after that.
                */
                audio.source_once[n] = audio.context.createBufferSource();
                audio.source_once[n].buffer = audio.buffer[n];
                audio.source_once[n].connect(audio.context.destination);
                audio.source_once[n].noteGrainOn(0, offset, audio.buffer[n].duration - offset); // currentTime, offset, duration
                /*
                Note about the third parameter of noteGrainOn().
                If your sound is 10 seconds long, your offset 5 and duration 5 then you'll get what you expect.
                If your sound is 10 seconds long, your offset 5 and duration 10 then the sound will play from the start instead of the offset.
                */
 
                // Now queue up our looping sound to start immediatly after the source_once audio plays.
                audio.source_loop[n][audio.compatibility.start](audio.context.currentTime + (audio.buffer[n].duration - offset));
            } else {
                audio.source_loop[n][audio.compatibility.start](0, offset);
            }
 
            audio.source_loop[n]._playing = true;
        }
    };
 
    audio.stop = function(n) {
        if (audio.source_loop[n]._playing) {
            audio.source_loop[n][audio.compatibility.stop](0);
            audio.source_loop[n]._playing = false;
            audio.source_loop[n]._startTime = 0;
            if (audio.compatibility.start === 'noteOn') {
                audio.source_once[n][audio.compatibility.stop](0);
            }
        }
    };
 
    //-----------------------------
    // Check Web Audio API Support
    //-----------------------------
    try {
        // More info at http://caniuse.com/#feat=audio-api
        window.AudioContext = window.AudioContext || window.webkitAudioContext;
        audio.context = new window.AudioContext();
    } catch(e) {
        audio.proceed = false;
        alert('Web Audio API not supported in this browser.');
    }
 
    if (audio.proceed) {
        //---------------
        // Compatibility
        //---------------
        (function() {
            var start = 'start',
                stop = 'stop',
                buffer = audio.context.createBufferSource();
 
            if (typeof buffer.start !== 'function') {
                start = 'noteOn';
            }
            audio.compatibility.start = start;
 
            if (typeof buffer.stop !== 'function') {
                stop = 'noteOff';
            }
            audio.compatibility.stop = stop;
        })();
 
        //-------------------------------
        // Setup Audio Files and Buttons
        //-------------------------------
        for (var a in audio.files) {
            (function() {
                var i = parseInt(a) + 1;
                var req = new XMLHttpRequest();
                req.open('GET', audio.files[i - 1], true); // array starts with 0 hence the -1
                req.responseType = 'arraybuffer';
                req.onload = function() {
                    audio.context.decodeAudioData(
                        req.response,
                        function(buffer) {
                            audio.buffer[i] = buffer;
                            audio.source_loop[i] = {};
                            var button = document.getElementById('button-loop-' + i);
                            button.addEventListener('click', function(e) {
                                e.preventDefault();
                                audio.play(this.value);
                            });
                        },
                        function() {
                            console.log('Error decoding audio "' + audio.files[i - 1] + '".');
                        }
                    );
                };
                req.send();
            })();
        }
    }
    </script>
 
</body>
</html>

Download Final Source & Audio

Zoinks! That was a lot of code.

I'd give you a piece of cake but unfortunately we'll have to wait until IPv6 adds support for baked goods. ^_^

Demos

By now you should have your own working demo but feel free to play with these loop buttons if you just want to hear things work.

For even more fun, try out this...

Web Audio API Demo

...which shares a lot of code with our simple demo. Code which you can do with as you please to create something even more fantastical!

Epilogue

Sure, it probably took more JavaScript than you thought to make simple loops but with just a bit more work you can really kick things up a notch. Visualizers, volume control, special effects and so much more. You'd be amazed at the possibilities. I know I am!

So, play around, have fun and see you in the comments. ^_^

Cheers!