ケーススタディ - The Sounds of Racer

はじめに

Racer は、マルチプレーヤー、マルチデバイスの Chrome 試験運用版です。画面をまたいでプレイするレトロなスタイルのスロットカー ゲーム。スマートフォンまたはタブレット(Android または iOS)。誰でも参加できます。アプリはありません。ダウンロードは不要です。モバイルウェブのみ。

Plan8 は、14islands の協力を得て、ジョルジオ モロダーのオリジナル楽曲に基づくダイナミックな音楽とサウンドを制作しました。Racer には、レスポンシブなエンジン音やレースの効果音が搭載されていますが、さらに重要なのは、レーサーが参加するにつれて複数のデバイスに配信されるダイナミックな音楽ミックスです。スマートフォンで構成されたマルチスピーカー システムです。

複数のデバイスを接続することは、しばらく前から検討していたことです。以前、音声を複数のデバイスに分割したり、デバイス間でジャンプしたりする音楽のテストを行っていたので、そのアイデアを Racer に適用したいと考えていました。

具体的には、参加するプレイヤーが増えるにつれて、デバイス間で音楽トラックを構築できるかどうかをテストしました。まずドラムとベースから始め、ギターやシンセサイザーを追加していくという流れです。音楽のデモをいくつか行い、コーディングに取り組みました。マルチスピーカーの効果は本当に素晴らしいものでした。この時点では、すべての同期が正しく行われていませんでしたが、デバイスに広がる音の層を聞いたとき、これは良い方向に進んでいると確信しました。

音声の作成

Google Creative Lab は、サウンドと音楽のクリエイティブな方向性を定めました。実際の音を録音したり、サウンド ライブラリに頼ったりせず、アナログ シンセサイザーを使用して効果音を作成したいと考えました。また、出力スピーカーはほとんどの場合、小さなスマートフォンやタブレットのスピーカーであるため、スピーカーの歪みを防ぐために音声の周波数スペクトルを制限する必要がありました。これはかなりの課題でした。Giorgio から最初の音楽の下書きを受け取ったときは、彼の楽曲が私たちが作成したサウンドと完璧にマッチしていたので、安心しました。

エンジン音

サウンドのプログラミングで最大の課題となったのは、最適なエンジン音を見つけ、その動作を調整することでした。レーストラックは F1 や NASCAR のトラックを模倣しているため、車は速くて爆発的な印象を与える必要がありました。一方で、車は非常に小さかったため、大きなエンジン音では映像と音が結びつかず、モバイル スピーカーで重厚なエンジン音を再生することはできないため、別の方法を考えなければなりませんでした。

インスピレーションを得るために、友人の Jon Ekstrand のモジュラー シンセのコレクションをいくつか接続して、試し始めました。ご意見を拝見いたしました。2 つのオシレーター、優れたフィルタ、LFO を使ったサウンドは次のとおりです。

以前、Web Audio API を使用してアナログ機器を再現し、大きな成功を収めたため、Web Audio でシンプルなシンセサイザーの作成を開始しました。生成された音声は最もレスポンスが速いですが、デバイスの処理能力を消費します。ビジュアルをスムーズに実行するために、可能な限りすべてのリソースを節約し、非常にスリムな状態にする必要がありました。そこで、音声サンプルを再生する方法に切り替えました。

エンジン音のモジュラー シンセサイザー

サンプルからエンジン音を生成するために使用できる手法はいくつかあります。コンソール ゲームで最も一般的なアプローチは、異なる RPM(負荷あり)でエンジンの複数のサウンド(多いほど良い)のレイヤを用意し、それらをクロスフェードしてクロスピッチする方法です。次に、同じ RPM で(負荷なしで)エンジンが回転している複数のサウンドのレイヤを追加し、それらの間でクロスフェードとクロスピッチを行います。ギアチェンジ時にこれらのレイヤをクロスフェードすると、適切に処理すれば非常にリアルなサウンドになりますが、大量の音声ファイルが用意されている場合に限られます。クロスピッチングが大きすぎると、合成音のように聞こえます。読み込み時間を長くできないため、このオプションは適切ではありませんでした。各レイヤに 5 ~ 6 個の音声ファイルを使用することも試みましたが、音質が期待に沿うものではありませんでした。ファイルの数を減らす方法を見つける必要がありました。

最も効果的なソリューションは次のとおりです。

  • 車の加速とギアシフトが、車の視覚的な加速と同期され、最高音程 / RPM でプログラムされたループで終わる 1 つのサウンドファイル。Web Audio API は正確なループに非常に優れているため、グリッチやポップ音なしでループを実現できました。
  • 減速 / エンジン回転数の低下音を含む 1 つの音声ファイル。
  • 最後に、静止 / アイドル状態の音をループで再生するサウンドファイルが 1 つ。

次のような表示になります。

エンジン音のグラフィック

最初のタップ イベント / 加速では、最初のファイルを最初から再生します。プレイヤーがスロットルを放すと、スロットルを放した時点でのサウンドファイルの位置から時間を計算し、スロットルが再びオンになったときに、2 番目のファイル(回転数を下げる)が再生された後、加速ファイル内の適切な位置にジャンプします。

function throttleOn(throttle) {
    //Calculate the start position depending 
    //on the current amount of throttle.
    //By multiplying throttle we get a start position 
    //between 0 and 3 seconds.
    var startPosition = throttle * 3;

    var audio = context.createBufferSource();
    audio.buffer = loadedBuffers["accelerate_and_loop"];

    //Sets the loop positions for the buffer source.
    audio.loopStart = 5;
    audio.loopEnd = 9;

    //Starts the buffer source at the current time
    //with the calculated offset.
    audio.start(context.currentTime, startPosition);
}

試してみる

エンジンを始動し、[スロットル] ボタンを押します。

<input type="button" id="playstop" value = "Start/Stop Engine" onclick='playStop()'>
<input type="button" id="throttle" value = "Throttle" onmousedown='throttleOn()' onmouseup='throttleOff()'>

そのため、3 つの小さなサウンド ファイルと良い音のエンジンのみで、次の課題に進むことにしました。

同期の取得

Google は 14islands の David Lindkvist 氏と協力して、デバイスを完全に同期して再生する方法について詳しく調査を開始しました。基本的な理論はシンプルです。デバイスはサーバーに時刻を問い合わせ、ネットワーク レイテンシを考慮してローカル クロックのオフセットを計算します。

syncOffset = localTime - serverTime - networkLatency

このオフセットにより、接続された各デバイスが同じ時間概念を共有します。簡単ですよね。(あくまでも理論上です)。

ネットワーク レイテンシの計算

レイテンシは、サーバーからリクエストしてレスポンスを受信するまでの時間の半分であると仮定できます。

networkLatency = (receivedTime - sentTime) × 0.5

この前提には問題があります。サーバーとのラウンドトリップが常に対称的であるとは限りません。つまり、リクエストにレスポンスよりも時間がかかる場合や、その逆の場合もあります。ネットワーク レイテンシが高いほど、この非対称性の影響が大きくなり、音声が遅れて再生され、他のデバイスと同期が取れなくなります。

幸い、人間の脳は音声が少し遅れても気付かないようになっています。研究によると、脳が音を別々に認識するまでに 20 ~ 30 ミリ秒(ms)の遅延が発生します。ただし、12 ~ 15 ms になると、遅延したシグナルの影響を「感じ」始めます。確立された時間同期プロトコルと、よりシンプルな代替プロトコルをいくつか調査し、そのうちのいくつかを実装してみました。最終的には、Google の低レイテンシ インフラストラクチャのおかげで、リクエストのバーストをサンプリングし、レイテンシが最も低いサンプルを参照として使用できました。

クロックのドリフトを防ぐ

うまくいきました。5 台以上のデバイスで心拍数を同時に再生しましたが、しばらくすると同期がずれるようになりました。非常に正確な Web Audio API コンテキスト時間を使用して音声をスケジュールしても、数分再生するとデバイスがずれてしまいます。遅延は一度に数ミリ秒ずつゆっくりと蓄積され、最初は検知できませんが、長時間再生すると音楽レイヤが完全にずれてしまいます。時刻のずれについてお問い合わせいただきありがとうございます。

解決策として、数秒ごとに再同期し、新しいクロック オフセットを計算して、それを音声スケジューラにシームレスにフィードするようにしました。ネットワーク ラグによる音楽の著しい変化のリスクを軽減するため、最新の同期オフセットの履歴を保持して平均を計算することで、変化を緩和することにしました。

曲のスケジュール設定とアレンジの切り替え

インタラクティブなサウンド エクスペリエンスを実現すると、ユーザーの操作に応じて現在の状態を変更するため、曲の一部を再生するタイミングを制御できなくなります。曲内のアレンジを適切なタイミングで切り替えられるようにする必要がありました。つまり、スケジューラは、次のアレンジに切り替える前に、現在再生中の小節の残り時間を計算できなければなりませんでした。最終的なアルゴリズムは次のようになります。

  • Client(1) は曲を開始します。
  • Client(n) は、最初のクライアントに曲の開始時間を尋ねます。
  • Client(n) は、syncOffset と、オーディオ コンテキストの作成から経過した時間を考慮して、Web Audio コンテキストを使用して曲が開始されたときの参照ポイントを計算します。
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n) は、playDelta を使用して曲の再生時間を計算します。曲のスケジューラは、これを使用して、現在のアレンジメントで次に再生する小節を判断します。
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

作業を効率化するために、アレンジは常に 8 小節の長さで、テンポ(1 分あたりの拍数)は同じに制限しました。

前を向きましょう

JavaScript で setTimeout または setInterval を使用する場合は、事前にスケジュールを設定することが重要です。これは、JavaScript クロックが非常に正確ではなく、スケジュールされたコールバックが、レイアウト、レンダリング、ガベージ コレクション、XMLHTTPRequest によって数十ミリ秒以上ずれやすいためです。私たちの場合は、すべてのクライアントがネットワーク経由で同じイベントを受信するまでの時間も考慮する必要がありました。

音声スプライト

音声を 1 つのファイルにまとめることは、HTML Audio と Web Audio API の両方で HTTP リクエストを減らすための優れた方法です。また、再生前に新しい Audio オブジェクトを読み込む必要がないため、Audio オブジェクトを使用して音声をレスポンシブに再生する最適な方法でもあります。すでにいくつかの優れた実装が存在しており、Google はそれを起点として使用しました。スプライトを拡張し、iOS と Android の両方で確実に動作するようにしました。また、デバイスがスリープ状態になるという特殊なケースにも対応しています。

Android では、デバイスをスリープモードにした場合でも、音声要素は再生し続けます。スリープ モードでは、バッテリーを節約するために JavaScript の実行が制限されるため、requestAnimationFramesetIntervalsetTimeout を使用してコールバックを呼び出すことはできません。これは、オーディオ スプライトが JavaScript を使用して再生を停止する必要があるかどうかを継続的にチェックするため、問題となります。さらに、音声が再生されているにもかかわらず、Audio 要素の currentTime が更新されないこともあります。

Chrome Racer で Web Audio 以外の代替として使用した AudioSprite の実装を確認してください。

オーディオ要素

Racer の開発を開始した当時、Chrome for Android では Web Audio API がまだサポートされていませんでした。一部のデバイスでは HTML Audio を使用し、他のデバイスでは Web Audio API を使用するというロジックと、実現したい高度なオーディオ出力により、興味深い課題がいくつか生じました。幸い、現在はすべて過去のことです。Web Audio API は Android M28 ベータ版に実装されています。

  • 遅延/タイミングに関する問題。Audio 要素は、再生するように指示したときに必ずしも正確に再生されるとは限りません。JavaScript はシングル スレッドであるため、ブラウザがビジー状態になり、再生が最大 2 秒遅れることがあります。
  • 再生の遅延により、スムーズなループ再生が常に可能であるとは限りません。パソコンではダブル バッファリングを使用して、ある程度ギャップレス ループを実現できますが、モバイル デバイスでは次の理由により、この方法は選択できません。
    • ほとんどのモバイル デバイスでは、一度に複数の Audio 要素を再生することはできません。
    • 音量を固定しました。Android と iOS のどちらでも、Audio オブジェクトの音量を変更することはできません。
  • プリロードなし。モバイル デバイスでは、touchStart ハンドラで再生が開始されない限り、Audio 要素はソースの読み込みを開始しません。
  • 問題を探しています。サーバーが HTTP バイト範囲をサポートしていない場合、duration の取得または currentTime の設定は失敗します。私たちのように音声スプライトを作成する場合は、この点に注意してください。
  • MP3 の Basic 認証が失敗します。一部のデバイスでは、使用しているブラウザに関係なく、基本認証で保護された MP3 ファイルを読み込めないことがあります。

まとめ

ウェブの音声を扱う最善の方法としてミュートボタンを押す時代から、長い道のりを歩んできました。しかし、これは始まりに過ぎず、ウェブ音声はこれからさらに進化していきます。ここまでは、複数のデバイスの同期でできることのほんの一部を紹介しました。スマートフォンやタブレットでは、シグナル処理やエフェクト(リバーブなど)に十分な処理能力がありませんでしたが、デバイスのパフォーマンスが向上するにつれて、ウェブベースのゲームでもこれらの機能が利用されるようになります。音の可能性を追求するうえで、今はエキサイティングな時代です。