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秒に1回程度まで遅くする(MDN)。0.1秒のlookaheadでは持たない。ライブラリがこれをどう避けているか(Workerタイマーなど)は、まだ確かめていない。

ブラウザで音やタイミングが絡む機能を作っていて詰まったら、negabaroに聞いてみてほしい。この実験も自分たちのアプリを作りながら回したものだ。

キーポイント

1

タイマーのコールバックでosc.start(ctx.currentTime)とすぐ鳴らすと、タイマーの遅れがリズムの遅れになる

2

25msごとに起きて、0.1秒先までの音をnextTimeで予約する

3

Tone.jsではコールバックが受け取るtimeをそのまま渡す。Tone.now()は使わない

4

バックグラウンドタブのタイマー間引きは0.1秒のlookaheadでは防げない

ユースケース

ブラウザのメトロノーム・ドラムマシン・ステップシーケンサー リズムゲームで画面と音を合わせる
← event.target vs currentTarget — アイコンの線の上を押した時だけ起きるバグ イヤホンの「左だけ」は二種類 — StereoPanner(寄せる)とChannelSplitter(選ぶ) →