本文へスキップ

フレームが乱れてもビートを外さないゲーム音楽 ​

多くの Web ゲームは、フレームループから音を鳴らしています。更新のたびに、鳴らすべき音を鳴らす方式です。音楽の場合、これでは各音がフレームの実行タイミングに縛られます。私たちはそれを計測しました。一定の 16 分音符を requestAnimationFrame から鳴らすと、アイドル状態のページでも音の中央値はビートから 9〜15 ms ずれ、300 ms のフリーズの後には次の音が最大 259 ms 遅れてまとめて鳴りました。同じ音を、plonk が音楽を再生するのと同じようにオーディオクロック上で先行予約すると、ページがフリーズしなかったすべての実行でビートぴったりに鳴りました。フリーズ中、plonk は遅れた音を鳴らす代わりに、フリーズ 1 回につき 1 音をスキップしました。

3 つの条件における各音のタイミング:フレームループはビートから 5〜30 ms ばらつき、フリーズのたびに 250 ms まで跳ね上がる。plonk はライン上にとどまり、フリーズ 1 回につき 1 音をスキップする

条件フレームループ:ずれの中央値フレームループ:最悪plonk:最悪plonk:スキップした音
アイドル9〜15 ms16〜29 ms0 msなし
ジャンク:500 ms ごとに 60 ms のブロック5〜6 ms20〜30 ms0 msなし
ストール:2 秒ごとに 300 ms のブロック5〜14 ms249〜259 ms1.3 ms100 音中 6 音(ストール 1 回につき 1 音)

5 ラウンドの範囲。エンジンと条件ごとに 12 秒、デスクトップ Chrome 155、AMD Ryzen 7 260 搭載ノート PC。各音のタイミングは結果ファイルにあります。

音を鳴らす 2 つの方法 ​

Web Audio は、自分のクロック上の正確な時刻に音を鳴らせます:oscillator.start(when)。問題は、コードがそれをいつ呼ぶかです。

フレームループから鳴らす場合、1.000 秒に鳴るべき音は 1.000 秒より後の最初のフレームで鳴るので、そのフレームが来るまでにかかった分だけ遅れます。60 fps なら最大で約 17 ms です。フレームが遅れると(ガベージコレクション、重いフレーム、レベルの読み込み)、その間に鳴るべきだった音はすべてそのフレームを待ち、一斉に鳴ります。

先行予約する場合、スケジューラーが数十ミリ秒ごとに起きて、次の区間に鳴るべき音をすべて正確な when 付きで予約します。スケジューラーが期限前に予約さえしていれば、メインスレッドが何をしていても、オーディオスレッドがその時刻に音を鳴らします。これは Chris Wilson が A Tale of Two Clocks で紹介した手法で、Tone.js の Transport もこの仕組みです。plonk はこの手法を 4 kB 未満(plonkjs 0.2 で brotli 3.7 kB)で実装したもので、スケジューラーは 40 ms ごとに起きて 150 ms 先まで予約します。

テスト ​

ハーネスは、ヘッドレス Chrome で同じ内容を 2 通りの方法で再生します。120 bpm の一定の 16 分音符(125 ms ごとに 1 音)を、まず plonk 自身の Player で、次に requestAnimationFrame から期限の来た音を鳴らす最小のシーケンサーで再生します。OscillatorNode.prototype.start をラップして、各音がオーディオクロック上で実際に鳴る時刻を記録します。その時刻は when、または when がすでに過ぎていた場合は現在時刻です。各音の誤差は、その時刻から鳴るべき時刻を引いたものです。

3 つの条件を交互に、5 ラウンド実行しました。

  • アイドル: ほかには何も動いていない。
  • ジャンク: メインスレッドが 500 ms ごとに 60 ms ブロックする。ガベージコレクションの停止や重いフレームに相当。
  • ストール: 2 秒ごとに 300 ms ブロックする。メインスレッドでのレベルのパースや画像のデコードに相当。

結果 ​

アイドルでは、フレームループは一度も時間どおりになりませんでした。音の中央値は 9〜15 ms 遅れ、ラウンドによって 28〜86% の音が 10 ms 以上ずれました。音はそれに気づくフレームより前には鳴らせないので、誤差はフレームクロックとビートの位相差に従います。ラウンドごとに誤差が変わるのはそのためです。plonk の音はすべてのラウンドでビートぴったりでした。

ジャンクでは、フレームループは平均的には悪化しませんでした。中央値は 5〜6 ms でアイドルより低くなりました。ブロックによってフレームクロックの位相がビートに対してずれるためです。最悪の音は 20〜30 ms ずれました。plonk は相変わらず正確でした。60 ms のブロックで 40 ms ごとのスケジューラーが止まるのは最大でも約 100 ms で、plonk はすでに 150 ms 先まで予約していたからです。

ストールで両者の差がはっきりしました。300 ms のフリーズのたびに、フレームループは期限切れの音を 2 つ見つけてまとめて鳴らしました。最悪で 249〜259 ms の遅れです。ハイハットのパターンなら、つまずいて二度打ちしたように聞こえます。plonk はフリーズ前に見えていた音を予約していたので、各フリーズの前半はカバーされました。そしてブロック中に期限が来た 1 音をスキップしました。plonk は 10 ms 以上期限を過ぎた音を、遅れて鳴らすのではなく捨てるからです。鳴らした音はすべて 1.3 ms 以内でした。聞こえるのは 16 分音符 1 つ分の空白で、グリッドには乗ったままです。

先行予約のトレードオフ ​

plonk が空白なしに耐えられるフリーズの長さは、どれだけ先まで予約するかで決まります。それが 150 ms です。先行予約を長くすればより長いフリーズをカバーできますが、音楽の反応は遅くなります。player.tempo() と player.layer() はまだ予約されていない音に効くので、150 ms 先まで予約していると、ゲームが要求してからレイヤーがフェードインするまで最大 150 ms かかります。音楽がアクションに応えるべきゲームでは、150 ms がその境界の正しい側だと私たちは考えています。それより長いフリーズは、それ自体が直すべきバグです。Long Animation Frames API で見つけられます。

遅れた音を鳴らさずにスキップするのも1つの選択です。遅れた音はミスのように聞こえ、グルーヴをずらします。一方、ハイハットやアルペジオのパターンで 1 音欠けても、ほとんど気づかれません。メロディーの音の場合はもっと判断が難しいので、オプションにするかもしれません。

この計測でわからないこと ​

  • スピーカーから音が出る時刻。 計測したのはオーディオクロック上の予約された開始時刻で、出力ではありません。出力レイテンシーはその上に一定の遅延を加えます(ヘッドレス Chrome の仮想デバイスでは 192 ms、一般的なハードウェアでは数十ミリ秒)。すべての音で同じなので、音楽全体はずれますが、リズムは変わりません。
  • 録音された音楽。 1 つのバッファとして再生される音楽ファイル(Howler や多くのエンジンの音楽プレイヤー)には音ごとのタイミングがまったくないので、この話は当てはまりません。戦略を選ぶ必要があるのはシーケンスされた音楽、つまりコードが音を鳴らす音楽で、plonk のような生成音楽は定義上シーケンスされた音楽です。
  • バックグラウンドタブ。 ブラウザは非表示タブのタイマーを約 1 秒に 1 回まで遅くするので、先行予約が 1 秒未満のスケジューラーはどれも予約が追いつかなくなります。ゲームはふつう非表示になると一時停止します。plonk の stop() はそのためにあります。
  • ほかのマシン。 ノート PC 1 台、ブラウザ 1 つ、ヘッドレスでの計測です。最初のコールドな実行(この 5 ラウンドとは別)では、アイドル状態のページで 1 音だけスキップしました。ウォームアップによるものと考えており、再現できませんでした。ハーネスは小さいので、ぜひ自分のマシンで実行してみてください。
bash
git clone https://github.com/debuffjp/debuff && cd debuff && npm ci
node bench/music-timing/run.mjs

プレイグラウンドで plonk を試すか、ビートを外さない API について ゲームに反応する音楽 を読んでみてください。