本文へ移動
myowchick

BPM 120だけまともに鳴るメトロノーム — 先読み予約と丸め誤差

メトロノームを置いた。

作ってレビューに出したら、BPM 120では正しく鳴るのに100にすると音量が飛ぶ、と言われた。手元で数えてみたら、BPM 100で30秒鳴らすあいだに拍は50回しか無いのに、音の予約は130回入っていた。50拍のうち20拍が5重ねで鳴っていた計算になる。

原因は浮動小数の丸め誤差1つで、しかもそれが混入するのは、setIntervalのぶれを音に出さないために入れた先読みの仕組みのほうだった。

setIntervalで鳴らすと、止めるまで遅れ続ける

まず前提のほう。setIntervalが起きた瞬間に音を出す作りにすると、テンポは必ず遅れていく。手元のNodeで、500ミリ秒間隔(BPM 120)の割り込みを60回起こして、n回目が理想のn * 500ミリ秒からどれだけずれるかを測った。

10拍目(5秒後):  ずれ  7.5ms
20拍目(10秒後): ずれ 14.6ms
30拍目(15秒後): ずれ 20.0ms
40拍目(20秒後): ずれ 26.6ms
50拍目(25秒後): ずれ 33.7ms
60拍目(30秒後): ずれ 41.0ms

1拍あたり0.7ミリ秒くらいの遅れで、これが減ることはない。タイマーは「前回起きた時刻から500ミリ秒後」に次を積むので、遅れは毎回そのぶん持ち越しになる。30秒後の41ミリ秒は1拍の8%で、他の音と合わせて鳴らせば分かる。

鳴る時刻はAudioContextの時計で決める

なのでWeb Audioでは、割り込みを「予約の継ぎ足し」だけに使う。音を鳴らす時刻はAudioContext.currentTimeの時計で決めて、oscillator.start(time)で未来の時刻を指定して予約する。あとはオーディオスレッドが自分の時計でその時刻に鳴らす。JavaScript側が多少もたついても、予約済みのぶんは正確に鳴る。

oscillator.start(beat.time)
oscillator.stop(beat.time + CLICK_SEC)

25ミリ秒ごとの割り込みは、100ミリ秒先までの拍を予約し足すためだけに回っている。割り込みが数十ミリ秒遅れても、その先は予約済みなので音には出ない。この二重構造が定石になっている。

「どこまで予約したか」を時刻で持つと壊れる

問題は、予約の進み具合をどう持つかだった。最初の実装は「ここまで予約済み」を時刻で持っていて、次に予約する拍の番号をそこから割り戻していた。

// 壊れていたころ。scheduledUntil の次に来る拍から数え始める
let index = Math.max(0, Math.floor((scheduledUntil - startTime) / interval) + 1)

素直に見える。拍の時刻はstartTime + index * intervalで作っているので、逆に(time - startTime) / intervalすれば番号に戻る、はずだった。

戻らない。BPM 100(intervalは0.6秒)で、鳴らし始めが300.132秒だったときの実測がこれ。

番号拍の時刻割り戻した商floor + 1
0300.132000000000005000.00000000000000000001
1300.732000000000027741.00000000000003796962
2301.331999999999993631.99999999999998112622
3301.932000000000016373.00000000000001909584
4302.531999999999982263.99999999999996225244

番号2の行を見る。商が2をわずかに下回るのでMath.floorが1に落ち、+1して2に戻る。次に予約する拍として、いま予約したばかりの拍2がまた出てくる。

そして先読み窓があるせいで、これが1回では済まない。拍は「予約された時刻の100ミリ秒前」から窓に入り、25ミリ秒ごとの割り込みのたびに何度でも拾われる。窓の幅を変えて最多の重複数を測ると、そのまま比例した。

先読み幅割り込み何回ぶんか同じ拍を予約した回数
50ms23
100ms45
200ms89
400ms1617

ドリフト対策で入れた先読みが、そのままバグの増幅器になっている。既定の100ミリ秒だと5重ね。gainは1拍0.5で作っているので、5つ重なれば2.5になり、destinationで±1にクリップされる。音量が飛ぶというより、飛んだあと潰れている。

30秒ぶんを通して数えるとこうなる。

鳴った回数(重複込み): 130   実際の拍: 50
1重ね: 30拍 / 5重ね: 20拍

BPM 120だけ助かっていた

60 / 120は0.5で、二進で厳密に持てる。そこにテストのstartTime: 0が重なると、0 + index * 0.5も、それを0.5で割り戻すのも誤差なしで往復する。バグの踏みようがない。

どのくらい特殊なのかを測った。鳴らし始めの時刻を100通りに散らして、30秒ぶん回して同じ拍を二度予約したかを数える。

BPM100通りのうち壊れた回数
605
90100
100100
1205
2405

BPM 100と90は鳴らし始めがいつでも壊れる。120は95回まで無事。intervalが二進で厳密になるBPM(30, 32, 40, 48, 60, 64, 80, 96, 120, 128, 160, 192, 240, 256)だけが、たまたま生き残る側に居た。ツールの既定値がそのうちの1つなので、開いて開始を押すだけでは気づけない。

テストのほうも見てみる。startTime: 0に固定したまま30〜300のBPMを総当たりすると、271個中212個で重複が出た。つまりBPMを120以外にしてさえいれば、startTime: 0のままでも落ちていた。素通りしたのは、テストのbaseが二進で厳密なBPMと厳密な鳴らし始めを同時に踏んでいたからだった。

// 旧テストの base。この2つの定数が誤差を隠していた
const base = { bpm: 120, beatsPerBar: 4, startTime: 0, scheduledUntil: -0.05, currentTime: 0 }

都合のいい定数はテストを読みやすくするが、誤差の話では隠蔽にもなる。0と2の冪は特に危ない。

直し方は、状態を番号で持つこと

やったことは、時刻から番号を割り戻すのをやめただけ。「次に予約する拍の番号」を返り値に足して、呼ぶ側が持ち回る。

export interface BeatSchedule {
  beats: ScheduledBeat[]
  /** 次に呼ぶときに渡す拍の番号 */
  nextBeatIndex: number
}

let index = Number.isFinite(nextBeatIndex) ? Math.max(0, Math.floor(nextBeatIndex)) : 0

for (
  let time = startTime + index * interval;
  time <= until;
  time = startTime + index * interval
) {
  // ……
  index += 1
}

return { beats, nextBeatIndex: index }

浮動小数の誤差自体は消えていない。startTime + index * intervalは相変わらず厳密ではないし、鳴る時刻は1ミリ秒に満たない誤差を持っている。消えたのは、誤差を含んだ値を整数に落とすMath.floorのほう。丸め誤差そのものはたいてい耐えられて、それを離散値に変換するところで壊れる。連続量を状態にせず、離散量のほうを状態にする、と言い換えてもいい。

回帰テストは旧実装で赤くなることを確かめてから書く

このバグはBPM 120で書いたテストをすり抜けたので、テストのほうも直した。条件は3つで、鳴らし始めを割り切れない時刻にすること、intervalが二進で厳密にならないBPMを選ぶこと、そして割り込みを何百回も回して時間を進めること。1回呼んだだけでは出ない。

it('鳴らし始めが割り切れない時刻でも同じ拍を二度予約しない', () => {
  const startTime = 300.132
  const bpm = 100
  // ……25ms 刻みで 400 回、拍の時刻を集める
  expect(new Set(times).size).toBe(times.length)
  expect(times.length).toBe(Math.floor(((ticks - 1) * stepSec) / interval) + 1)
})

同じ手順を旧実装のロジックで回すと130対50で落ちる。テストを書いたら一度旧実装に差し戻して赤を見ておくのは、この手のバグでは特に効く。条件が1つでも足りない(BPMを120にする、startTimeを0にする、割り込みを1回で済ませる)とただ緑になるテストなので、赤を見ないと何を確かめたのか分からないまま通る。

裏に回ったら、遅れた拍は捨てる

タブが裏に回るとsetIntervalが絞られて、割り込みが数秒止まる。復帰した瞬間、予約されていない拍が大量に溜まっている。

取り戻すか、捨てるか。メトロノームでは捨てる一択で、取り戻すと復帰の瞬間に早回しの連打が鳴るだけになる。実装としては、過ぎた拍はbeatsに積まずに番号だけ進める。

// 裏に回って割り込みが止まると、過ぎた拍がまとめて出てくる。鳴らさず番号だけ進める
if (time >= currentTime) {
  beats.push({ time, beatInBar, isAccent: bar > 1 && beatInBar === 0 })
}
index += 1

BPM 100の4拍子で5秒止めてみた実測がこれ。

凍る前: 0.00s(1拍目) 0.60s(2拍目)
復帰直後に鳴らした拍: 6.00s(3拍目)
捨てた拍の数: 8

番号だけ進めているので、8拍捨てても小節の中の位置は合っている。復帰後は3拍目から続く。時刻で管理していると、この「鳴らさないが進める」が書きにくい。

音と絵で時計が2つある

拍の点灯を予約と同時にやると、100ミリ秒早く光る。先読みしているぶんそのままずれる。なので点はrequestAnimationFrameで回し、予約済みの拍を待ち行列に積んで、音の時計が追いつくまで光らせない。

while (queueRef.current.length > 0 && queueRef.current[0].time <= context.currentTime) {
  played = queueRef.current.shift()
}
if (played !== undefined) setCurrentBeat(played.beatInBar)

画面の時計(requestAnimationFrame)で回しながら、判定は音の時計(context.currentTime)でやる。この2つを混ぜないのがコツで、混ぜると60fpsが出ない環境で点だけずれる。

演奏中のテンポ変更は予約の乗せ替え

鳴っている最中にBPMを変えたとき、予約済みの拍を消すと不自然に途切れる。生かしたまま、その次の拍から新しい間隔にしたい。

// 予約済みの拍はそのまま鳴らし、その次の拍から新しいテンポで刻み直す
const interval = 60 / bpm
const anchor = Math.max(lastBeatTimeRef.current, context.currentTime) + interval
startTimeRef.current = anchor
nextBeatIndexRef.current = 0

最後に予約した拍の次を新しい起点に置いて、番号を0に戻す。番号で持っていると、起点の張り直しがこの2行で済む。時刻で持っていたころは、ここでも新旧のintervalを跨いだ換算が要った。

タップテンポは平均でなく中央値

タップからBPMを測るほうは、間隔の中央値を使っている。平均と比べた実測がこれ。BPM 120(500ミリ秒)を8回叩いた想定で、3通り。

タップの様子中央値平均
手のぶれだけ(±20ms)117.2 BPM119.6 BPM
途中で1回押し損ねる120.0 BPM105.0 BPM
途中で1回もたつく(+280ms)120.0 BPM111.1 BPM

ぶれしか無いなら平均のほうが近い。全部のタップを使うぶん、ばらつきが打ち消し合う。中央値は真ん中の1個か2個しか見ないので、そのぶん粗い。

それでも中央値にしたのは、押し損ねが1回混じった瞬間に平均が15BPM飛ぶから。間隔が2倍の値が1つ入るだけで、平均は引きずられる。タップテンポで実際に起きるのは、きれいにぶれることではなく、たまに1回落とすことのほう。精度を少し捨てて、事故で飛ばないほうを取った。

まとめ

  • 鳴る時刻はsetIntervalが起きた時刻ではなくAudioContextの時計で決める。割り込みは予約の継ぎ足しだけに使う
  • 予約の進み具合は時刻ではなく拍の番号で持つ。時刻から番号を割り戻すと、丸め誤差がMath.floorを1つ戻す
  • 丸め誤差は誤差のまま持っているぶんには耐えられて、整数に落とすところで壊れる
  • テストの0とBPM 120は誤差を隠す。割り切れない値を1つ混ぜておく
← 一覧へ戻る