0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

`setTimeout` でメトロノームを書くと 2 小節で音が飛ぶ — Web Audio で sample-accurate にする ~60 行のスケジューラ

0
Posted at

setInterval(fireClick, 60000/bpm) で web メトロノームを書いたら 2 小節以内に音が聞いて分かるレベルでズレた。なぜズレるか と、これを置き換えた ~60 行のスケジューラがどうやって永遠に sample-accurate を保つか を書く。

📦 GitHub: https://github.com/sen-ltd/metronome
🎵 Demo: https://sen.ltd/portfolio/metronome/

metronome のスクリーンショット

何度も踏んだバグ

「1 拍ごとにクリック音を鳴らしたい」素朴版は 3 行:

const ctx = new AudioContext();
const everyBeat = () => click(ctx, ctx.currentTime);
setInterval(everyBeat, 60000 / bpm);

120 BPM なら 500 ms ごとにクリック。本物のメトロノームと並べると 十数拍以内 に目で見えるレベルでズレる。240 BPM なら 4 拍以内。

理由は単純で、setInterval のコールバックスケジュールは オーディオハードウェアのクロックと一切連動していない。ブラウザは「だいたい 500 ms 後」にメインスレッドで callback を発火するが、メインスレッドはレイアウト・GC・別タスク・拡張機能の content script など他のことで忙しい。1 発の発火が 5–50 ms 遅れることがあり、その誤差が 累積する。一方でオーディオクロックは正確に sample rate で進む。

修正方針、1 文で

各クリックを いつ鳴らすか をメインスレッドに決めさせない。Web Audio に「AudioContext.currentTime 基準で時刻 t に鳴らせ」と渡して、オーディオスレッドにその sample で render させる。

osc.start(t)  // t は AudioContext.currentTime クロック上の時刻、wall clock ではない

メインスレッドにどれだけ jitter があろうと、offset t の sample は audio-clock の t に出力される。それだけ。

lookahead スケジューラ

パターンは Chris Wilson の 2013 年の A Tale of Two Clocks。ノブ 2 つ + ループ 1 つ:

  • scheduleAheadSec — 何秒先までの拍を Web Audio に予約しておくか。本実装は 0.1 秒
  • lookaheadMs — JS タイマがどのくらいの間隔で起きてキューを覗くか。本実装は 25 ms

不変条件: 今後 scheduleAheadSec 秒以内に再生される拍は、すべてすでに Web Audio に渡してある。

export function createScheduler({ audioCtx, bpm, beatsPerBar, onBeat }) {
  let nextBeatTime = 0;
  let beat = 0;
  let timer = null;

  const secondsPerBeat = () => 60 / bpm;

  function tick() {
    while (nextBeatTime < audioCtx.currentTime + 0.1) {
      onBeat({ beat, time: nextBeatTime });
      nextBeatTime += secondsPerBeat();
      beat = (beat + 1) % beatsPerBar;
    }
  }

  return {
    start() {
      beat = 0;
      nextBeatTime = audioCtx.currentTime + 0.05;
      tick();
      timer = setInterval(tick, 25);
    },
    stop() { clearInterval(timer); timer = null; },
    setBpm(next) { bpm = next; },
  };
}

onBeat が実音を出す場所:

function click(ctx, time, accent) {
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.frequency.value = accent ? 1500 : 1000;
  osc.type = "square";
  gain.gain.setValueAtTime(0, time);
  gain.gain.linearRampToValueAtTime(0.4, time + 0.001);
  gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.05);
  osc.connect(gain).connect(ctx.destination);
  osc.start(time);
  osc.stop(time + 0.06);
}

time がそのまま流れているのがポイント。JS タイマの起床が 20 ms 遅かろうと 80 ms 遅かろうと、100 ms の lookahead 窓の中にある拍はすでに予約済なので、オーディオスレッドには困る理由がない。メインスレッド側も次の tick まで 75 ms の余裕がある。

なぜ全部一気に予約しないのか

「次の小節 (or 次の 10 秒) を全部予約しちゃえばいいのでは」が出る当然の質問。答え: ユーザーが何かを変える から。

BPM スライダを 120 から 140 に動かしたとき、欲しいのは「次の拍は新しいテンポ」であって、「先に予約済の遅い拍が 10 個鳴り終わってからテンポ変更が反映される」ではない。100 ms 窓は、テンポ変更を即座に感じられるには十分短く、現実的なメインスレッドの jitter を吸収するには十分長い、その境界。

想定外だった効能

擬似 AudioContext と擬似 setInterval を使ってユニットテストを書いた:

function fakeCtx(startAt = 0) { return { currentTime: startAt }; }

test("拍の間隔が厳密に 60/bpm", () => {
  const ctx = fakeCtx(0);
  const beats = [];
  const sched = createScheduler({ audioCtx: ctx, bpm: 120, beatsPerBar: 4,
                                  onBeat: b => beats.push(b),
                                  setIntervalFn: () => 1, clearIntervalFn: () => {} });
  sched.start();
  ctx.currentTime = 2;    // オーディオクロックを早送り
  sched._tick();
  for (let i = 1; i < beats.length; i++) {
    const gap = beats[i].time - beats[i - 1].time;
    assert.ok(Math.abs(gap - 0.5) < 1e-9);
  }
});

差は マシン精度で厳密に 0.5およそ 500 ms ± jitter ではない。なぜなら wall clock を一切読まず、nextBeatTime への加算しかしないので、そもそも jitter の出所がない

このテストは「リファクタ中に誰かが Date.now()performance.now() をスケジューリング演算に紛れ込ませた瞬間に落ちる」ためのもの。1 秒でも紛れ込めば差が厳密でなくなる。

AudioContext.currentTime を使うことの裏の利点はこれ: 再生時刻と、計画に使う数値が同じ変数。だからスケジューリングロジックがそのままユニットテスト可能になる。

tap tempo はおまけで降ってくる

上記まで書けば tap tempo は数行:

const taps = [];
function tap() {
  const now = performance.now();
  taps.push(now);
  while (taps.length > 5) taps.shift();
  // 2.5 秒以上間が空いたら新しい計測の始まり扱い
  while (taps.length && taps[0] < now - 2500) taps.shift();
  if (taps.length < 2) return;
  const intervals = taps.slice(1).map((t, i) => t - taps[i]);
  const avgMs = intervals.reduce((a, b) => a + b) / intervals.length;
  setBpm(60000 / avgMs);
}

直近 4 区間の移動平均、長すぎるギャップは自動失効。ここでは performance.now() で全く問題ない: ユーザーの意図を測っている だけでオーディオを spawn しているわけではない。結果を setBpm() に流せば、走っているスケジューラが次の拍から新しい cadence で動く。

やらない方がよかったこと

  • タイマを Web Worker で動かす のはやりすぎ。「setInterval はバックグラウンドタブで throttle されるから worker が必要」と書かれている記事を見るが、AudioContext は タブがバックグラウンドに行くと suspend される のでメトロノームでは関係ない。タブが復帰すれば currentTime はそのまま続きから。worker のメリットは DAW グレードのアプリ向け、メトロノームには合わない
  • ctx.state === 'suspended' を無視する。ブラウザは user gesture が来るまで AudioContext を suspend している。最初の再生ボタン press で await ctx.resume() を忘れるとスケジューラは順調に tick するが何も鳴らない、を経験する
  • 0.05 秒「少し未来」のオフセットを省くosc.start(ctx.currentTime) を直接呼ぶと、最初のクリックがしばしば失われる: オーディオスレッドが拾うときには timestamp が既に過去になっている

まとめ

  • setTimeout / setInterval は UI ポーリングには十分。オーディオスケジューリングには合わない — JS イベントループとオーディオクロックの間に何の関係もないから
  • AudioContext.currentTime が「sample が実際に鳴る時刻」と「スケジューラが計算に使う変数」の両方を兼ねる
  • 2 クロック + lookahead 設計で、25 ms ポーリング + 100 ms スラックでドリフトのないタイミングが ~60 行
  • スケジューラの依存は audioCtx.currentTime だけなので、stub すればテストは時間早送り可能

コード (MIT、フレームワークなし、static page 1 枚): github.com/sen-ltd/metronome


🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?