I'm building Pianory, a browser-based piano learning and practice app. One small feature has a useful implementation lesson: a metronome needs both an audio clock and a JavaScript scheduler.
A repeating timer can wake up late when rendering or other JavaScript work occupies the main thread. If each callback immediately plays a click, that lateness becomes part of the rhythm. The approach in Pianory is to schedule clicks slightly ahead on the Web Audio clock.
Two clocks, two jobs
The JavaScript interval fills a short queue. AudioContext.currentTime determines when the queued sounds should start.
Pianory currently uses a 25 ms scheduler interval and a 120 ms lookahead window. These are implementation choices, not guarantees of uninterrupted playback: a sufficiently long stall or a suspended context can still interrupt audio.
This is the core pattern, simplified from the implementation:
const lookahead = 0.12; // seconds
const intervalMs = 25;
let nextBeatAt = context.currentTime + 0.04;
let beat = 0;
function fillSchedule() {
const now = context.currentTime;
while (nextBeatAt < now + lookahead) {
scheduleClick(nextBeatAt, beat);
nextBeatAt += 60 / bpm;
beat = (beat + 1) % beatsPerBar;
}
}
fillSchedule();
const timer = setInterval(fillSchedule, intervalMs);
Here, scheduleClick(at, beat) creates the audio nodes and calls oscillator.start(at). The interval does not need to fire at the exact instant of every audible beat; it needs to replenish the queue before it runs out.
Make a short sound, then clean it up
Pianory uses a sine oscillator with a short gain envelope for each click. The first beat uses 1320 Hz; the others use 880 Hz. The gain rises over about 4 ms and decays toward a small positive value by 45 ms, with the oscillator stopped at 50 ms.
A small positive gain value matters when using exponentialRampToValueAtTime: zero is not a valid target for an exponential ramp. Disconnecting the oscillator and gain node in onended also keeps finished clicks from accumulating connections.
Stop means cancel the queue too
Clearing the interval only prevents new scheduling. It does not cancel nodes that have already been scheduled.
The implementation keeps a set of scheduled oscillators. Stop clears the interval, stops those oscillators, clears pending visual-beat timers, and resets the beat counter. Dispose also closes the context.
That distinction is easy to miss when testing only the Start button. A useful manual check is to press Stop immediately after Start and listen for any extra click.
Keep visual feedback separate
The beat indicator uses a timeout with a delay computed from the difference between the scheduled audio time and context.currentTime. This makes it approximate the audio schedule, but the indicator still depends on main-thread availability.
Treat the visual pulse as feedback rather than a timing measurement. For tighter audio-to-screen alignment, a further improvement to investigate is output-latency-aware mapping with getOutputTimestamp() where supported.
Tempo changes have a tradeoff
Updating BPM affects the next intervals added to the queue. It does not move clicks that are already queued. A larger lookahead tolerates more scheduler delay, but it also leaves more previously scheduled audio to handle when tempo or meter changes.
For a more responsive design, consider explicitly cancelling and rebuilding the queued beats when settings change. That adds state-management work; it is a different choice from simply changing the BPM variable.
Test on the devices people actually practise with
Audio should start from a user gesture. Pianory creates or resumes the context from Start and checks that it reaches the running state. Unsupported or blocked playback should leave the control stopped.
My practical test checklist for this pattern is:
- Start, stop immediately, then restart several times.
- Change tempo and meter during playback.
- Navigate away and confirm disposal stops the sound.
- Try a busy main thread and a background/foreground transition.
- Try mobile Safari, including device audio and silent-mode behavior.
This checklist describes what to verify, not a claim that browsers guarantee background metronome playback.
You can try the Pianory metronome without an account. I'm the maker; feedback about timing, controls, or mobile audio behavior is welcome.
For further reading, MDN documents AudioContext.currentTime and AudioScheduledSourceNode.start().
Top comments (0)