About

setInterval 로 드럼을 치면 박자가 흔들린다 — 「지금 울려」 말고 「몇 초에 울려」

JS 타이머는 깨우기만 하고, 소리는 오디오 시계로 미리 예약하는 lookahead 스케줄러

브라우저에서 박자를 맞추려면 「지금 울려」 가 아니라 「몇 초에 울려」 로 짠다. JS 타이머는 깨우는 역할만 하고, 소리는 AudioContext 의 시계로 미리 예약한다. 이걸 lookahead 스케줄러라고 부른다. 이 글을 읽으면 30줄 남짓한 코드로 흔들리지 않는 메트로놈을 만들 수 있다.

둘을 나란히 돌려 봤다

학습용 앱에 「리듬」 장을 만들면서, 메인 스레드를 0〜30ms 씩 랜덤으로 막는 방해를 걸고 두 방식을 같이 돌렸다. 목표는 120 BPM 16분음표, 125ms 간격이다. 로컬 Chrome 에서 2026-09-19 에 잰 값이다.

setInterval: "play now" osc.start(ctx.currentTime) inside the timer callback gaps: 114, 118, 126, 133, 115, 136, 119, 127 ms lookahead: "play at 0.55 s" osc.start(nextTime), timer only wakes the scheduler small marks = 25 ms timer wake-ups (they can drift, it does not matter) dashed lines = target grid, 125 ms (16th notes at 120 BPM)
  • 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 에 물어봐도 된다. 이 실험도 우리 앱을 만들면서 직접 돌린 것이다.

핵심 포인트

1

타이머 콜백 안에서 osc.start(ctx.currentTime) 으로 바로 울리면 타이머 지각이 박자 지각이 된다

2

25ms 마다 깨어나 앞으로 0.1초 안의 음을 nextTime 으로 예약한다

3

Tone.js 에서는 콜백이 받는 time 을 그대로 넘긴다 — Tone.now() 금지

4

백그라운드 탭의 타이머 스로틀은 lookahead 0.1초로 못 막는다

사용 사례

브라우저 메트로놈 · 드럼 머신 · 스텝 시퀀서 리듬 게임에서 화면과 소리 맞추기
← event.target vs currentTarget — 아이콘 선 위를 누를 때만 터지던 버그 이어폰 「왼쪽만」 은 두 가지다 — StereoPanner(옮기기)와 ChannelSplitter(고르기) →