Skip to content

Game music that stays on the beat when your frames don't ​

Many web games start their sounds from the frame loop: on each update, play whatever is due. For music, that ties every note to when a frame happens to run. We measured it. Starting steady sixteenth notes from requestAnimationFrame, the median note landed 9-15 ms off the beat on an idle page, and after a 300 ms freeze the next notes came up to 259 ms late, in a burst. The same notes scheduled ahead on the audio clock, the way plonk plays music, landed exactly on the beat in every run where the page didn't freeze, and during the freezes plonk skipped one note per freeze instead of playing any late.

Timing of every note in three conditions: the frame loop scatters 5 to 30 ms off the beat and jumps to 250 ms after each freeze; plonk stays on the line, skipping one note per freeze

ConditionFrame loop: median off the beatFrame loop: worstplonk: worstplonk: notes skipped
Idle9-15 ms16-29 ms0 msnone
Jank: a 60 ms block every 500 ms5-6 ms20-30 ms0 msnone
Stall: a 300 ms block every 2 s5-14 ms249-259 ms1.3 ms6 of 100 (one per stall)

Ranges across 5 rounds, 12 seconds per engine and condition, desktop Chrome 155 on an AMD Ryzen 7 260 laptop. Every note's timing is in the results file.

Two ways to start a note ​

Web Audio can start a sound at an exact time on its own clock: oscillator.start(when). The question is when your code calls it.

From the frame loop, a note due at 1.000 s starts on the first frame after 1.000 s, so it's late by however long that frame took to arrive. At 60 fps that's anything up to about 17 ms. If a frame is late (garbage collection, a heavy frame, loading a level), every note due in that gap waits for it and then starts at once.

Ahead of time, a scheduler wakes up every few tens of milliseconds and books every note due in the next window with its exact when. The audio thread plays them at those times whatever the main thread is doing, as long as the scheduler booked them before they were due. This is the technique Chris Wilson described in A Tale of Two Clocks, and it's how Tone.js's Transport works. plonk is that technique in under 4 kB (3.7 kB brotli for plonkjs 0.2): its scheduler wakes every 40 ms and books 150 ms ahead.

The test ​

The harness plays the same thing two ways in headless Chrome: steady sixteenth notes at 120 bpm (one every 125 ms), first with plonk's own Player, then with a minimal frame-loop sequencer that starts each due note from requestAnimationFrame. It wraps OscillatorNode.prototype.start to record when each note really sounds on the audio clock: its when, or the current time if when had already passed. Each note's error is that time minus when it was due.

Three conditions, interleaved, five rounds:

  • Idle: nothing else running.
  • Jank: the main thread blocks for 60 ms every 500 ms, like a garbage-collection pause or a heavy frame.
  • Stall: it blocks for 300 ms every 2 s, like parsing a level or decoding an image on the main thread.

What happened ​

Idle, the frame loop was never on time: its median note was 9-15 ms late, and 28-86% of notes were more than 10 ms off, depending on the round. A note can't start before the frame that notices it, so the error follows the phase between the frame clock and the beat, which is why it moves between rounds. plonk's notes were exactly on the beat in every round.

Jank didn't make the frame loop worse on average: its median was 5-6 ms, lower than idle, because the blocks shift the frame clock's phase against the beat, and its worst notes were 20-30 ms off. plonk was still exact: a 60 ms block holds up its 40 ms scheduler for at most about 100 ms, and it had already booked 150 ms ahead.

Stall is where the two split. After each 300 ms freeze, the frame loop found two notes overdue and played them in a burst, the worst 249-259 ms late: on a hat pattern you hear a stumble and a double hit. plonk booked the notes it could see before the freeze, so the first part of each freeze was covered, and then it skipped the one note that came due while it was blocked, because plonk drops a note that's more than 10 ms overdue rather than playing it late. Every note it played was within 1.3 ms. You hear a gap of one sixteenth, still on the grid.

The trade-off in the lookahead ​

How much freeze plonk survives without a gap depends on how far ahead it books: 150 ms. A longer lookahead covers longer freezes, but music reacts later. player.tempo() and player.layer() take effect on notes not yet booked, so with 150 ms ahead a layer fades in up to 150 ms after the game asks. For games, where music should answer the action, we think 150 ms is the right side of that line. Freezes longer than that are a bug worth fixing on their own: the Long Animation Frames API finds them.

Skipping a late note instead of playing it is a choice too. A late note sounds like a mistake and shifts the groove; a missing one in a hat or arpeggio pattern mostly goes unnoticed. For a melody note it's a closer call, and we may make it an option.

What this doesn't show ​

  • When sound leaves the speakers. We measured scheduled start times on the audio clock, not output. Output latency adds a fixed delay on top (192 ms on headless Chrome's virtual device; tens of milliseconds on typical hardware), the same for every note, so it shifts the music but doesn't change its rhythm.
  • Recorded music. A music file played as one buffer (Howler and most engines' music players) has no per-note timing at all, so none of this applies. It's sequenced music, notes started by code, that has to choose a strategy, and generated music like plonk's is sequenced by definition.
  • Background tabs. Browsers slow timers in hidden tabs to about once a second, which starves any ahead-of-time scheduler with a lookahead under a second. Games usually pause when hidden; plonk's stop() is there for that.
  • Other machines. One laptop, one browser, headless. One cold first run (outside these five) skipped a single note on an idle page, which we put down to warm-up and couldn't reproduce. The harness is small: run it on yours.
bash
git clone https://github.com/debuffjp/debuff && cd debuff && npm ci
node bench/music-timing/run.mjs

Try plonk in the playground, or read Music that reacts for the API that stays on the beat.