HTML5 Drum Machine With Web Audio
Posted on 12/5/2016 by Gio
Can you make a drum machine using no recorded samples, just some code? Yes, yes you can thanks to Web Audio. And it's quite easy too.
READ MORE

Today I'm going to spend a few words on the wonderful world of audio synthesizers, and specifically how you can start playing with them with just some simple JavaScript.

I've always been into this stuff, but back in the day you actually had to fiddle with electronic components, build your own circuits and all that. Which was a lot of fun, but also impractical, because you had to buy a bunch of things that cost money (quite a lot of money for my 15 year old self), and it would take a fairly long amount of time to start hearing something.

Not so in the modern age, where you can generate amazingly complex music out of a handful of lines of code!

It's a very broad topic and I promise I'll do several blog posts on it, but for today our aim will be to build a dead simple drum machine like this (go ahead, click the play button and those other buttons too):

This is all made possible thanks to some recent tech called WebAudio, and you'll need a not-too-ancient browser to use it (no IE, sorry... though Edge is fine). A lot of people use WebAudio to play pre-made sounds (mp3, ogg, etc). And indeed, you could achieve our objective quite simply by associating an mp3 with each of those buttons above. But where would be the fun in that? We want to actually generate those sounds with some code! There is something truly fascinating about mathematical formulas turning into audible sounds, it's like hearing the sound that numbers make.

First of all we need an "audio context" to play our sounds. If you are doing this with pure JavaScript, you'll want to do something like

	var context = new AudioContext();

If you are doing this in WADE (which you should do, right?) then you'll want to reuse the audio context that WADE is already using to handle its sounds:

	var context = wade.getWebAudioContext();

At this point you may want to check whether your context variable is undefined, because if it is, the user is on some ancient browser that's not going to work, so perhaps display an error message asking them to upgrade and enjoy the marvels of the modern world.

With that out of the way, let's start with the easiest of those drum sounds: the kick. The kick is simple because it is just, essentially, a low-frequency sine wave. So, in principle, we could just create an oscillator with a low frequency, connect it to our output channel (in WebAudio terminology, this is called the destination of the audio context), stop it after a short time, and hear a kick sound:

    var osc = context.createOscillator();
    osc.frequency.value = 150;  // 150Hz is pretty low frequency, change it as you like
    osc.connect(context.destination);
    osc.start(0);
    osc.stop(0.1); // stop after 0.1 seconds

It's a start but, to be honest, it doesn't sound like a drum kick at all. It would be much closer if, rather than having a fixed frequency, we could quickly change the frequency of the sound, going from an initial value (150Hz) to near-zero in a short time (say half a second). Luckily WebAudio provides us with a handy function to change frequencies (or any other quantities) over time, called exponentialRampToValueAtTime:

    var osc = context.createOscillator();
    osc.frequency.setValueAtTime(150, 0);  
    osc.frequency.exponentialRampToValueAtTime(0.01, 0.5);
    osc.connect(context.destination);
    osc.start(0);
    osc.stop(0.5); // stop after 0.5 seconds

Now we're getting closer! But notice those annoying click sounds? That's what you get when you abruptly cut a sine wave: there's a click at the end of the sound. Of course we can get rid of it, we just need to make our synthesizer a bit more complex by adding a gain node: instead of connecting the oscillator to the output channel directly, we connect the oscillator to the gain node, and the gain node to the output channel. By doing that, we can manipulate the gain node to control the volume of the kick sound, so it doesn't get cut abruptly: it smoothly goes to 0.01 (always best to avoid exactly 0 values, because the calculations involved may include divisions and we want to be safe).

    var osc = context.createOscillator();
    var gain = context.createGain();
    osc.frequency.setValueAtTime(150, 0);  // 150Hz is pretty low frequency, change it as you like
    osc.frequency.exponentialRampToValueAtTime(0.01, 0.5);
    gain.gain.setValueAtTime(3, 0);
    gain.gain.exponentialRampToValueAtTime(0.01, 0.5);
    osc.connect(gain);
    gain.connect(context.destination);
    osc.start(0);
    osc.stop(0.5);

Now that's a pretty convincing kick sound! Let's make it repeat by wrapping it into a function, and calling that function several times in a loop:

    var kick = function(context, time)
    {
        var osc = context.createOscillator();
        var gain = context.createGain();
        osc.frequency.setValueAtTime(150, time);
        osc.frequency.exponentialRampToValueAtTime(0.01, time + 0.5);
        gain.gain.setValueAtTime(3, time);
        gain.gain.exponentialRampToValueAtTime(0.01, time + 0.5);
        osc.connect(gain);
        gain.connect(context.destination);
        osc.start(time);
        osc.stop(time + 0.5);
    };

    for (var i=0; i<4; i++)
    {
        kick(context, i);
    }

Groovy! But not enough, we need to add more sounds to make it interesting. Let's do the hihat sound that's also quite simple, because it's essentially just some high-frequency noise. This can be obtained fairly simply by applying a high-pass filter to (so just keeping the higher frequencies of) white noise. And white noise is the simplest thing to generate: it's just a sequence of random numbers sent to your speakers. So at the beginning of our code file (right after getting the audio context) let's create an audio buffer and fill it with random data - the only caveat is that the data must be in the [-1, 1] range, so we need to rescale the output of Math.random() which would normally be in the [0, 1] range:

    // make 1 second worth of noise
    var noiseBuffer = context.createBuffer(1, 44100, 44100);  // 44100 is a standard sample rate for most sound cards
    var noiseBufferOutput = noiseBuffer.getChannelData(0);
    for (var i = 0; i < 44100; i++)
    {
        noiseBufferOutput[i] = Math.random() * 2 - 1;
    }

And we start writing our hihat function by creating a bufferSource (that is an audio node that can play the sound stored in a buffer like our noise buffer) and connecting it to a gain node, like we did before for our kick sound. This time I'm going to manipulate the volume through the gain node in a slightly more interesting way - but you can be creative about this; this is just how I like my hihat sound, you should experiment with different values until you find something that suits your taste.

    
    var hihat = function(context, time)
    {
        var noise = context.createBufferSource();
        noise.buffer = noiseBuffer;
        var noiseEnvelope = context.createGain();
        noiseEnvelope.connect(context.destination);
        noiseEnvelope.gain.setValueAtTime(0.7, time);
        noiseEnvelope.gain.exponentialRampToValueAtTime(0.5, time + 0.05);
        noiseEnvelope.gain.exponentialRampToValueAtTime(0.01, time + 0.1);
        noise.connect(noiseEnvelope);
        noise.start(time);
        noise.stop(time + 0.1);
    };

And to hear it in action, let's add it to our for loop together with the kick sound:

    for (i=0; i<4; i++)
    {
        kick(context, i);
        hihat(context, i + 0.25);
        hihat(context, i + 0.5);
        hihat(context, i + 0.75);
    }

It sounds alright-ish, but I said we would be using high-frequency noise, and we're just using white noise. Let's apply a simple highpass filter to it: we connect the bufferSource to the noise filter, the noise filter to the gain node, and the gain node to the output, so our hihat function now looks like this (and sounds so much better):

    // just high frequency noise for a short duration
    var hihat = function(context, time)
    {
        var noise = context.createBufferSource();
        noise.buffer = noiseBuffer;
        var noiseFilter = context.createBiquadFilter();
        noiseFilter.type = 'highpass';
        noiseFilter.frequency.value = 10000;
        noise.connect(noiseFilter);
        var noiseEnvelope = context.createGain();
        noiseFilter.connect(noiseEnvelope);
        noiseEnvelope.connect(context.destination);
        noiseEnvelope.gain.setValueAtTime(0.7, time);
        noiseEnvelope.gain.exponentialRampToValueAtTime(0.5, time + 0.05);
        noiseEnvelope.gain.exponentialRampToValueAtTime(0.01, time + 0.1);
        noise.start(time);
        noise.stop(time + 0.1);
    };

The final sound that we're missing is the drum snare. Now that's a very complex and rich sound in reality, but we're going to approximate it in a clever way. Here's my personal recipe for the snare sound: take some high-frequency noise (like we did for the hihat, but with a lower frequency filter), and mix it with a dash of very low-frequency triangular waves. Again, feel free to experiment! Here's what mine looks like:

    // make noise, filter it (we only want the high frequencies) and add it to a low-frequency triangular wave
    var snare = function(context, time)
    {
        var noise = context.createBufferSource();
        noise.buffer = noiseBuffer;
        var noiseFilter = context.createBiquadFilter();
        noiseFilter.type = 'highpass';
        noiseFilter.frequency.value = 1000;
        noise.connect(noiseFilter);
        var noiseEnvelope = context.createGain();
        noiseFilter.connect(noiseEnvelope);
        noiseEnvelope.connect(context.destination);
        var osc = context.createOscillator();
        osc.type = 'triangle';
        var oscEnvelope = context.createGain();
        osc.connect(oscEnvelope);
        oscEnvelope.connect(context.destination);
        noiseEnvelope.gain.setValueAtTime(1, time);
        noiseEnvelope.gain.exponentialRampToValueAtTime(0.01, time + 0.2);
        noise.start(time);
        osc.frequency.setValueAtTime(100, time);
        oscEnvelope.gain.setValueAtTime(0.7, time);
        oscEnvelope.gain.exponentialRampToValueAtTime(0.01, time + 0.1);
        osc.start(time);
        osc.stop(time + 0.2);
        noise.stop(time + 0.2);
    }

To hear it in action, let's change our for loop slightly:

    for (i=0; i<4; i++)
    {
        kick(context, i);
        hihat(context, i + 0.25);
        hihat(context, i + 0.5);
        snare(context, i + 0.5);
        hihat(context, i + 0.75);
    }

Now can you feel the rhythm? Yeah, do a little dance, then we can turn our cool experiment into an interactive app (you know, with those buttons like the demo above). I'm a bit too lazy to write lots of words about it, but I'm including the full source code for the WADE project, which you can freely use as you see fit. Do something cool with it!

Post a Comment
Add Attachment
Submit Comment
Please Login to Post a Comment