GJ
Coding

Creating a simple metronome using Javascript and the Web Audio API

How to accurately schedule audio events when writing a time sensitive Javascript application.

Try out the demo or view the code on GitHub

I've been practising guitar a lot more lately and trying to build up speed - so keeping in time with a metronome is vital.

There are already hundreds (thousands?) of metronomes available on the internet but I wanted to be able to tinker and get the click sound and the UI to my liking.

"This will be easy!", I thought. But how wrong I was. As I'll explain, you can't just use setInterval() and be done with it.

So what we'll be creating is a simple metronome that will click in 4/4 time (4 beats to a bar) and will accent the first beat of each bar. I won't be going over creating the UI since it's pretty simple and only interacts with the start/stop and tempo set values of the metronome.

Things to note

There are two main articles that I read (and read again, and again...) when researching how to create this metronome. Both of them go into a lot mote detail than I do here and so are a lot more complicated. I highly recommend reading them but perhaps read mine as a "beginner's introduction" to ease you in first.

I should mention there's a great javascript library called Tone.js that would have made this project easier, but I didn't use it for a couple of reasons. Firstly because I'd already fallen down the rabbit hole of figuring it out with just vanilla JS and also because I'm always adverse to referencing a complete library when I only need one or two simple features from it that I can implement myself.

Why you can't we just use setInterval()?

setInterval() can be used to repeatedly call a function x milliseconds apart. However, it's not accurate to the millisecond and can be early or late by some milliseconds depending on how much other stuff is going on since it runs on the main thread.

You might wonder if we're being overly picky here and whether we'll notice when those intervals fire late. The answer is yes - according to this article, musicians start to notice at around 40ms of latency.

Web Audio API

Using the Web Audio API, we can use the AudioContext's currentTime property to accurately get an oscillator to play a note at that time:

// Create the audio context
const audioContext = this.audioContext = new (window.AudioContext || window.webkitAudioContext)();

// create an oscillator
const osc = this.audioContext.createOscillator();

// Set the sound frequency
osc.frequency.value = 800;

// Connect the oscillator to the output destination
osc.connect(audioContext.destination);

// Start the oscilator in 1 second and stop after a short time
osc.start(audioContext.currentTime + 1);
osc.stop(audioContext.currentTime + 1.03);

The problem is, there's no time-accurate setInterval() equivalent for the Web Audio API. We could write code to schedule a certain number of clicks ahead, but that way we wouldn't be able to have the metronome run indefinitely until we want to stop it. We also wouldn't be able to alter the tempo whilst it's running.

The solution

The solution is to use setInterval() and the Web Audio API together. We can use setInterval() to constantly add events (oscillator clicks/beeps) to the audio context. We'll have a "look ahead" where, at each interval, we'll schedule in any clicks that we need. The interval at which the click scheduling function is called and the length of the look ahead should overlap so that when setInterval() has its inevitable variance in timing, there should be no missed clicks.

If that didn't make sense (and I don't blame you - it took a while to get my head around it), check out the diagrams in this article that help to visualise what's happening.

scheduleNote(beatNumber, time)
{
    // create an oscillator
    const osc = this.audioContext.createOscillator();
    const envelope = this.audioContext.createGain();
        
    osc.frequency.value = (beatNumber % 4 == 0) ? 1000 : 800;
    envelope.gain.value = 1;
    envelope.gain.exponentialRampToValueAtTime(1, time + 0.001);
    envelope.gain.exponentialRampToValueAtTime(0.001, time + 0.02);

    osc.connect(envelope);
    envelope.connect(this.audioContext.destination);
    
    osc.start(time);
    osc.stop(time + 0.03);
}

scheduler()
{
    // while there are notes that will need to play before the next interval, schedule them and advance the pointer.
    while (this.nextNoteTime < this.audioContext.currentTime + this.scheduleAheadTime ) {
        this.scheduleNote(this.currentQuarterNote, this.nextNoteTime);

        // Advance current note and time by a quarter note (crotchet if you're posh)
        this.nextNoteTime += (60.0 / this.tempo); // Add beat length to last beat time
    
        this.currentQuarterNote++;    // Advance the beat number, wrap to zero
        if (this.currentQuarterNote == 4) {
            this.currentQuarterNote = 0;
        }
    }
}

That's all there is to it. I've only shown code snippets here, so be sure to check out the full project on GitHub.