← Journal

Mai affidare un beat a setTimeout

2 min readweb audio, timing

Perché il nostro sequencer programma il suono 120 ms nel futuro, e perché le luci seguono l'audio e non il contrario.

The first version of our step sequencer was ten lines long and sounded drunk. Every sixteenth note was triggered by a setInterval, and every time the page did something else (a layout, a garbage collection, a tab switch) the groove stumbled.

The problem is not JavaScript being slow. The problem is asking the wrong clock.

Two clocks, one of them honest

A browser gives you two notions of time. The main thread runs timers, events and rendering, and it promises nothing: a 25 ms timeout fires at least 25 ms later, sometimes much more. The audio thread runs on its own high priority clock, AudioContext.currentTime, and it never stutters, because a stutter there is an audible click.

So the trick is to let the unreliable clock do the planning and the reliable clock do the playing.

Look ahead, schedule ahead

Every 25 ms, a plain timer wakes up and asks one question: which notes fall inside the next 120 ms? Those notes are handed to the audio engine with an exact start time. If the timer is late, it does not matter, as long as it is less than 120 ms late.

const schedule = () => {
  while (nextTime < audio.currentTime + 0.12) {
    for (const row of rowsOnAt(step)) pluck(NOTES[row], 0.55, nextTime)
    queue.push([step, nextTime])
    nextTime += 60 / bpm / 4
    step = (step + 1) % 16
  }
}
setInterval(schedule, 25)

Notice that nextTime is never computed from "now". It is the previous beat plus one sixteenth. Time advances by arithmetic, not by observation, so errors cannot accumulate.

The lights follow the sound

The second mistake was moving the playhead in the same timer. The light would happen when the note was scheduled, 120 ms before it was heard. It felt wrong in a way nobody could name.

Now every scheduled step goes into a small queue together with its audio time. On each animation frame we pop the steps whose time has passed and light them up:

tick(() => {
  while (queue.length && queue[0][1] <= audio.currentTime) {
    highlight(queue.shift()![0])
  }
})

The eye is forgiving about a frame of delay. The ear is not forgiving about anything.

What we took away

The same three rules show up whenever we build anything that has to happen on time: training devices, robot cells, video calls. The sequencer is just the version you can play with.

Scritto da umani. Altre note →