setInterval 로 드럼을 치면 박자가 흔들린다 — 「지금 울려」 말고 「몇 초에 울려」
JS 타이머는 깨우기만 하고, 소리는 오디오 시계로 미리 예약하는 lookahead 스케줄러
브라우저에서 박자를 맞추려면 「지금 울려」 가 아니라 「몇 초에 울려」 로 짠다. JS 타이머는 깨우는 역할만 하고, 소리는 AudioContext 의 시계로 미리 예약한다. 이걸 lookahead 스케줄러라고 부른다. 이 글을 읽으면 30줄 남짓한 코드로 흔들리지 않는 메트로놈을 만들 수 있다.
둘을 나란히 돌려 봤다
학습용 앱에 「리듬」 장을 만들면서, 메인 스레드를 0〜30ms 씩 랜덤으로 막는 방해를 걸고 두 방식을 같이 돌렸다. 목표는 120 BPM 16분음표, 125ms 간격이다. 로컬 Chrome 에서 2026-09-19 에 잰 값이다.
setInterval 로 바로 울리기: 간격 114, 118, 126, 133, 115, 136, 119, 127 ms
lookahead 로 예약: 예약 시각 0.55, 1.05, 1.55, 2.05, 2.55 … 초 (4스텝마다 찍었다. 정확히 0.5초 간격)
솔직히 말하면 이 비교는 방해를 일부러 넣은 조건이다. 방해 없이 setInterval 이 얼마나 흔들리는지는 아직 안 쟀다. 그리고 setInterval 쪽 숫자는 콜백이 불린 시각이지, 스피커에서 소리가 난 시각을 녹음해서 잰 게 아니다. 그래도 실제 앱에서는 렌더링 · GC · 다른 스크립트가 메인 스레드를 막는 게 일상이라, 방해가 있는 쪽이 현실에 가깝다고 본다.
왜 흔들리나
setInterval(fn, 125) 은 「125ms 뒤쯤 불러 줄게」 일 뿐이다. 메인 스레드가 다른 일을 하고 있으면 그만큼 늦게 불린다. 콜백 안에서 osc.start(ctx.currentTime) 을 하면 「불린 그 순간」 에 울리니까, 타이머의 지각이 그대로 박자의 지각이 된다.
AudioContext 의 시계(ctx.currentTime)는 오디오 스레드에서 돈다. 메인 스레드가 막혀도 이 시계는 밀리지 않는다. 그러니 「1.55초에 울려」 라고 미리 걸어 두면, 그 사이 JS 가 좀 늦어도 소리는 제때 난다.
최소 코드
const ctx = new AudioContext()
const step = 60 / 120 / 4 // 120 BPM 16분음표 = 0.125초
let nextTime = ctx.currentTime + 0.05
function click(time) {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
gain.gain.setValueAtTime(0.2, time)
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05)
osc.connect(gain).connect(ctx.destination)
osc.start(time)
osc.stop(time + 0.05)
}
setInterval(() => {
while (nextTime < ctx.currentTime + 0.1) { // 앞으로 0.1초 안에 칠 음을
click(nextTime) // 정확한 시각으로 예약
nextTime += step
}
}, 25)
타이머는 25ms 마다 깨어나지만 그 간격이 흔들려도 상관없다. 0.1초 앞까지 미리 걸어 두니까 타이머가 수십 ms 늦어도 버퍼 안이다. 25ms 와 0.1초는 Chris Wilson 의 「A Tale of Two Clocks」 가 출발점으로 권하는 값이다. 자동재생 정책 때문에 AudioContext 는 버튼 클릭 안에서 만들어야 한다.
이미 이렇게 돌고 있는 것들
| 소리 시각을 누가 정하나 | 메인 스레드가 바쁘면 | |
|---|---|---|
setInterval + osc.start(ctx.currentTime) |
타이머가 불린 시각 | 그만큼 늦게 울린다 |
| 직접 짠 lookahead | nextTime 계산 |
0.1초 안이면 영향 없음 |
Tone.js Transport |
콜백이 받는 time |
time 을 넘기면 영향 없음 |
Strudel s("bd*4") |
라이브러리 안의 스케줄러 | 한 줄이 소리 + 박자 계산 + 예약까지 |
Tone.js 를 쓰면 이미 다 해 주는 거 아니냐고 할 수 있다. 맞는데, 콜백이 받는 time 이 바로 이 nextTime 이다. 그걸 안 넘기고 Tone.now() 로 울리면 똑같이 흔들린다. 라이브러리를 쓰든 안 쓰든 원리를 알아야 실수를 안 한다.
효과음 하나 띄우는 정도라면 그냥 바로 울려도 된다. 박자가 이어지는 소리(드럼 · 시퀀서 · 메트로놈)부터 이 방식이 필요하다.
아직 남은 숙제
백그라운드 탭에서는 브라우저가 타이머를 1초에 한 번 정도로 늦춘다(MDN). 0.1초 lookahead 로는 못 버틴다. 라이브러리들이 이걸 어떻게 피하는지(Worker 타이머 등)는 아직 확인 못 했다.
브라우저에서 소리 · 타이밍이 걸린 기능을 만들다 막혔다면 negabaro 에 물어봐도 된다. 이 실험도 우리 앱을 만들면서 직접 돌린 것이다.
핵심 포인트
타이머 콜백 안에서 osc.start(ctx.currentTime) 으로 바로 울리면 타이머 지각이 박자 지각이 된다
25ms 마다 깨어나 앞으로 0.1초 안의 음을 nextTime 으로 예약한다
Tone.js 에서는 콜백이 받는 time 을 그대로 넘긴다 — Tone.now() 금지
백그라운드 탭의 타이머 스로틀은 lookahead 0.1초로 못 막는다