DEV Community

Michael Lip
Michael Lip

Posted on • Originally published at zovo.one

Building a Precise Metronome in JavaScript Is Harder Than You Think

setTimeout is not accurate enough for music. A metronome needs sub-millisecond timing precision, and JavaScript's event loop cannot guarantee that. Here is how the Web Audio API solves the problem.

The setTimeout problem

The naive approach to a metronome:

function naiveMetronome(bpm) {
  const interval = 60000 / bpm;
  setInterval(() => {
    playClick();
  }, interval);
}
Enter fullscreen mode Exit fullscreen mode

This does not work for music. setTimeout and setInterval are not precise. They guarantee a minimum delay, not an exact one. The JavaScript event loop processes timers only when the call stack is empty. If any other code is running (DOM updates, garbage collection, other event handlers), the timer fires late.

The inaccuracy is typically 1-15 milliseconds, sometimes more. At 120 BPM, the interval between beats should be exactly 500ms. A 10ms jitter means some beats arrive at 490ms and others at 510ms. Musicians can perceive timing irregularities as small as 5ms. A metronome with 10ms jitter is unusable for practice.

The Web Audio API solution

The Web Audio API has its own high-precision clock that runs on a separate thread from the main JavaScript event loop. This clock is not affected by DOM rendering, garbage collection, or other main-thread work.

class PreciseMetronome {
  constructor(bpm) {
    this.audioCtx = new AudioContext();
    this.bpm = bpm;
    this.nextNoteTime = 0;
    this.scheduleAheadTime = 0.1; // seconds
    this.lookahead = 25; // ms
    this.isPlaying = false;
  }

  scheduleNote(time) {
    const osc = this.audioCtx.createOscillator();
    const gain = this.audioCtx.createGain();

    osc.connect(gain);
    gain.connect(this.audioCtx.destination);

    osc.frequency.value = 1000;
    gain.gain.setValueAtTime(1, time);
    gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);

    osc.start(time);
    osc.stop(time + 0.05);
  }

  scheduler() {
    while (this.nextNoteTime < this.audioCtx.currentTime + this.scheduleAheadTime) {
      this.scheduleNote(this.nextNoteTime);
      this.nextNoteTime += 60.0 / this.bpm;
    }

    if (this.isPlaying) {
      setTimeout(() => this.scheduler(), this.lookahead);
    }
  }

  start() {
    this.isPlaying = true;
    this.nextNoteTime = this.audioCtx.currentTime;
    this.scheduler();
  }

  stop() {
    this.isPlaying = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

The key technique is called "lookahead scheduling." Instead of trying to play each note exactly when it should sound, we use setTimeout (imprecise) to wake up the scheduler every 25ms, and the scheduler queues notes up to 100ms into the future using the Audio API's precise clock.

The setTimeout might fire 5ms late, but that does not matter because we are scheduling notes ahead of time. As long as the scheduler runs at least once every 100ms (our ahead buffer), every note will be scheduled with sample-accurate timing.

BPM ranges

Standard BPM ranges for musical practice:

  • Largo: 40-60 BPM
  • Adagio: 66-76 BPM
  • Andante: 76-108 BPM
  • Allegro: 120-156 BPM
  • Presto: 168-200 BPM

Most practice metronomes should support at least 30-300 BPM to cover all use cases including subdivisions.

For a precise, browser-based metronome built on the Web Audio API, I have one at zovo.one/free-tools/metronome. It uses the lookahead scheduling pattern described above and provides accurate timing across all browsers and devices.


I'm Michael Lip. I build free developer tools at zovo.one. 500+ tools, all private, all free.

Top comments (0)