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 |
|---|---|---|---|
| 0 | 300.13200000000000500 | 0.0000000000000000000 | 1 |
| 1 | 300.73200000000002774 | 1.0000000000000379696 | 2 |
| 2 | 301.33199999999999363 | 1.9999999999999811262 | 2 |
| 3 | 301.93200000000001637 | 3.0000000000000190958 | 4 |
| 4 | 302.53199999999998226 | 3.9999999999999622524 | 4 |
番号2の行を見る。商が2をわずかに下回るのでMath.floorが1に落ち、+1して2に戻る。次に予約する拍として、いま予約したばかりの拍2がまた出てくる。
そして先読み窓があるせいで、これが1回では済まない。拍は「予約された時刻の100ミリ秒前」から窓に入り、25ミリ秒ごとの割り込みのたびに何度でも拾われる。窓の幅を変えて最多の重複数を測ると、そのまま比例した。
| 先読み幅 | 割り込み何回ぶんか | 同じ拍を予約した回数 |
|---|---|---|
| 50ms | 2 | 3 |
| 100ms | 4 | 5 |
| 200ms | 8 | 9 |
| 400ms | 16 | 17 |
ドリフト対策で入れた先読みが、そのままバグの増幅器になっている。既定の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秒ぶん回して同じ拍を二度予約したかを数える。
| BPM | 100通りのうち壊れた回数 |
|---|---|
| 60 | 5 |
| 90 | 100 |
| 100 | 100 |
| 120 | 5 |
| 240 | 5 |
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 BPM | 119.6 BPM |
| 途中で1回押し損ねる | 120.0 BPM | 105.0 BPM |
| 途中で1回もたつく(+280ms) | 120.0 BPM | 111.1 BPM |
ぶれしか無いなら平均のほうが近い。全部のタップを使うぶん、ばらつきが打ち消し合う。中央値は真ん中の1個か2個しか見ないので、そのぶん粗い。
それでも中央値にしたのは、押し損ねが1回混じった瞬間に平均が15BPM飛ぶから。間隔が2倍の値が1つ入るだけで、平均は引きずられる。タップテンポで実際に起きるのは、きれいにぶれることではなく、たまに1回落とすことのほう。精度を少し捨てて、事故で飛ばないほうを取った。
まとめ
- 鳴る時刻は
setIntervalが起きた時刻ではなくAudioContextの時計で決める。割り込みは予約の継ぎ足しだけに使う - 予約の進み具合は時刻ではなく拍の番号で持つ。時刻から番号を割り戻すと、丸め誤差が
Math.floorを1つ戻す - 丸め誤差は誤差のまま持っているぶんには耐えられて、整数に落とすところで壊れる
- テストの
0とBPM 120は誤差を隠す。割り切れない値を1つ混ぜておく