描くのと、鳴らすのは別の時計
再生ヘッドをフレームごとに一定値だけ足して動かすと、見た目は回る。けれど本物の音を鳴らすとなると、これではリズムがよれる。フレームの間隔は一定でない。重い処理が挟まればカクつき、別タブに行けば止まる。その不安定な時計で「いま鳴らせ」を判断すると、発音のタイミングが揺れる。
描画クロックと音声クロックを別々に進めると、両者は時間とともにずれていく。下は同じステップ列の上を二本のヘッドが進む。上のヘッドはフレーム間隔がよれる描画クロックで、歩幅がばらつき、ときどき止まる。下のヘッドは一定の歩幅で進む音声クロック。上の印は通りかかったヘッドにつられて不規則に光り、下の印は時刻どおりに正確に光る。
上のヘッドは歩幅がばらついて時々止まり、下のヘッドから少しずつ離れていく。同じ列を進ませても、二本は時間とともにずれる。鳴らす印を描画クロックの位置で判定すると、このずれがそのままタイミングのよれになる。
音を扱うときは時計を二つに分ける。描画は requestAnimationFrame(rAF)で回り、これはモニタの都合で揺れる。発音は AudioContext が持つ専用の時計(currentTime)で測り、これは音声ハードウェアに直結していて正確。
先読みスケジューラ(look-ahead scheduler)の型を使う。rAF のループの中で、毎回少し先(たとえば 100ms 先)までを覗いて、その区間に来るべき音を AudioContext の正確な時刻に予約しておく。
// 25ms ごとに rAF/setInterval で呼ばれる想定
const lookahead = 0.1 // 何秒先までを予約するか
while (nextNoteTime < audioCtx.currentTime + lookahead) {
scheduleNote(nextNoteTime) // 正確な時刻を渡して予約
nextNoteTime += 60 / bpm / 4 // 次のステップへ
}
描く時計が多少よれても、予約済みの音は audio clock の通りに正確に鳴る。Chris Wilson の "A Tale of Two Clocks"(2013)で広まった定石。描画は揺れてよく、発音は揺らさない、という割り切り。