二つの時計についての物語

ウェブ音声の正確なスケジューリング

クリス・ウィルソン(Chris Wilson)氏
Chris Wilson

はじめに

ウェブ プラットフォームを使用して優れたオーディオ ソフトウェアや音楽ソフトウェアを構築する際の最大の課題の 1 つは、時間の管理です。「コードを記述する時間」とは異なり、時計の時間と同様に、ウェブ オーディオについてあまり理解されていないトピックの 1 つは、オーディオ クロックを適切に扱う方法です。ウェブ オーディオの AudioContext オブジェクトには、このオーディオ クロックを公開する currentTime プロパティがあります。

特にウェブ オーディオの音楽用途では、シーケンサーやシンセサイザーの作成だけでなく、ドラムマシンゲームその他の アプリケーションなどのオーディオ イベントをリズミカルに利用する場合、音声イベントの開始と停止だけでなく、サウンドの変更(周波数や音量の変更など)も一貫した正確なタイミングであることが非常に重要です。Web Audio API を使用したゲーム用オーディオの開発のマシンガンのデモのように、イベントを少しランダム化した方がよい場合もありますが、通常は音符のタイミングが一貫して正確なものであることが望まれます。

ウェブオーディオの noteOn メソッドと noteOff メソッド(現在は start と start という名称に変更されました)の time パラメータを使用してメモのスケジュールを設定する方法は、ウェブ オーディオのスタートガイドWeb Audio API を使用したゲーム音声の開発ですでに説明しましたが、長い音楽シーケンスやリズムの再生など、より複雑なシナリオについては詳しく説明していません。詳しく調べるには、まず時計について少し背景が必要です。

ベスト オブ タイム - ウェブ オーディオ時計

Web Audio API は、オーディオ サブシステムのハードウェア クロックへのアクセスを公開します。この時計は、.currentTime プロパティを介して、AudioContext が作成されてからの浮動小数点数(秒単位)で AudioContext オブジェクトで公開されます。これにより、このクロック(以降「オーディオ クロック」)は非常に高精度になります。サンプルレートが高くても、個々のサウンド サンプルレベルでアライメントを指定できるように設計されています。「double」の精度は 10 進で約 15 桁であるため、オーディオ クロックが何日も稼働している場合でも、サンプルレートが高くても、特定のサンプルを指すために十分なビットが残っているはずです。

音声クロックは、Web Audio API 全体でパラメータや音声イベントのスケジューリングに使用されます。start()stop() はもちろん、AudioParams の set*ValueAtTime() メソッドでも使用されます。これにより、非常に正確な時刻の音声イベントを事前に設定できます。ウェブ オーディオでは、すべてをスタート/ストップ タイムとして設定したいと思うのですが、実際には問題があります。

例として、8 分音符の 2 小節のハイハット パターンを設定する「Web Audio Intro」のコード スニペットを見てみましょう。

for (var bar = 0; bar < 2; bar++) {
  var time = startTime + bar * 8 * eighthNoteTime;

  // Play the hi-hat every eighth note.
  for (var i = 0; i < 8; ++i) {
    playSound(hihat, time + i * eighthNoteTime);
  }

このコードは正しく機能しますが、ただし、2 小節の真ん中にテンポを変えたい場合や、2 小節が終わる前に演奏を止めたい場合は、何もできません。(デベロッパーが自分のサウンドをミュートできるように、事前にスケジュールした AudioBufferSourceNode と出力の間にゲインノードを挿入するといったことを行っているのを見たことがあります)。

要するに、テンポや周波数、ゲインなどのパラメータを変更する(あるいはスケジューリングを完全に停止する)柔軟性が必要なため、あまりにも多くのオーディオ イベントをキューにプッシュすることや、より正確には、スケジュールを完全に変更することが必要になるため、あまりにも時間的に先を見越してはいけません。

最悪の時代 - JavaScript の時計

また、Date.now() と setTimeout() で表される、非常に評判のよい JavaScript クロックもあります。JavaScript クロックの良い点は、非常に便利な call-me-back-later window.setTimeout() メソッドと window.setInterval() メソッドがあり、システムによって特定の時間にコードを呼び戻すことができることです。

JavaScript の時計の欠点は、あまり正確でないことです。まず、Date.now() はミリ秒単位の値(ミリ秒単位の整数)を返すため、期待できる最高精度は 1 ミリ秒です。これは、音楽の文脈によってはそれほど悪いものではありません。音がミリ秒の早すぎるか遅く開始しても、気づかないかもしれませんが、比較的低いオーディオ ハードウェア レートである 44.1kHz でも、オーディオ スケジューリング クロックとして使用するには約 44.1 倍遅くなります。サンプルをまったくドロップすると、オーディオの不具合が発生する可能性があります。そのため、サンプルを連鎖させる場合、正確にシーケンシャルにする必要がある場合があります。

新たに導入された High Resolution Time 仕様では、window.performance.now(); を通じて現在の時刻の精度が大幅に向上しており、現在の多くのブラウザでも実装されています(ただし、接頭辞は付加されています)。これは状況によっては役立ちますが、JavaScript タイミング API の最悪の部分とはあまり関係ありません。

JavaScript タイミング API の最悪の点は、Date.now() のミリ秒単位の精度はそれほど悪く思われませんが、JavaScript のタイマー イベントの実際のコールバック(window.setTimeout() または window.setInterval を使用)は、レイアウト、レンダリング、ガベージ コレクション、XMLRequest などの HTTPRequest などのスレッドの実行によって数十ミリ秒以上簡単に偏ることです。前に、Web Audio API を使ってスケジュール設定できる「オーディオ イベント」についてお話ししました。これらはすべて別のスレッドで処理されます。そのため、複雑なレイアウトやその他の長いタスクの実行中にメインスレッドが一時的に停止した場合でも、指定されたタイミングでオーディオが発生します。実際、デバッガのブレークポイントで停止しても、オーディオ スレッドはスケジュールされたイベントを再生し続けます。

オーディオ アプリで JavaScript setTimeout() を使用する

メインスレッドは一度に数ミリ秒で簡単に滞留するため、JavaScript の setTimeout を使用してオーディオ イベントを直接再生することはおすすめしません。これは、音符が実際に発生するタイミングの約 1 ミリ秒以内に発せられることがあり、最悪の場合はさらに長く遅延することになるからです。最悪なことに、リズミカルなシーケンスの場合は、メインの JavaScript スレッドで行われる他の処理のタイミングに左右されるため、正確な間隔で配信されることはありません。

これを示すために、サンプルの「不適切な」メトロノーム アプリケーション、つまり setTimeout を直接使用してメモのスケジュールを設定するアプリケーションを作成しました。また、さまざまなレイアウトも行います。このアプリケーションを開いて [再生] をクリックし、再生中にウィンドウのサイズを素早く変更すると、タイミングが著しくジッターになっていることがわかります(リズムが安定しないことがわかります)。「でも、これは不自然だ!」って言った?もちろんですが、現実の世界でもそうならないわけではありません。比較的静的なユーザー インターフェースでも、再レイアウトにより setTimeout でタイミングの問題が発生します。たとえば、ウィンドウのサイズをすばやく変更すると、本来は優れた WebkitSynth のタイミングが著しく途切れることがわかりました。ここで、音声とともに楽譜全体をスムーズにスクロールしようとすると、どうなるかを想像してみてください。これが現実世界の複雑な音楽アプリにどう影響するかは、簡単に想像できます。

「音声イベントからコールバックを取得できないのはなぜか」という質問がよく聞かれます。このようなコールバックはあるかもしれませんが、目の前の特定の問題を解決することはできません。これらのイベントはメインの JavaScript スレッドで起動されるため、setTimeout と同じ潜在的な遅延(実際に予定された正確な時間(ミリ秒単位)から遅れる可能性がある)が発生する可能性があることを理解することが重要です。

どうすればよいでしょうかタイミングを処理する最善の方法は、JavaScript タイマー(setTimeout()、setInterval()、requestAnimationFrame() など)とオーディオ ハードウェアのスケジューリング間の連携を設定することです。

先を見越して確実なタイミングを把握する

先ほどのメトロノームのデモに戻ります。実際、私はこのシンプルなメトロノームのデモの最初のバージョンを正しく記述して、このコラボレーション スケジューリング手法のデモを行いました。(コードは GitHub でも公開されています。このデモでは、Oscillator で生成されたビープ音を 16 分、8 分、4 分音ごとに高精度に再生し、ビートに応じてピッチを変化させます。また、再生中にテンポや音符の間隔を変更したり、いつでも再生を停止したりできます。これは、現実世界のリズミック シーケンサーの重要な機能です。このメトロノームで使用する音をその場で変更するコードも簡単に追加できます。

確実なタイミングを保ちながら温度制御を可能にするのは、コラボレーションです。setTimeout タイマーが頻繁に発生し、後で個々のメモに対してウェブ音声のスケジュールを設定する。setTimeout タイマーは基本的に、現在のテンポに基づいて「すぐに」スケジュールする必要がある音がないかを確認し、次のようにスケジュールします。

setTimeout() とオーディオ イベントのインタラクション
setTimeout() と音声イベントのインタラクション。

実際には、setTimeout() の呼び出しが遅延することがあるため、スケジュール呼び出しのタイミングが時間の経過とともにジッター(および setTimeout の使用方法によっては偏り)する可能性があります。この例のイベントは約 50 ミリ秒間隔で発生しますが、多くの場合、それよりわずかに長く(場合によってはそれ以上)なります。ただし、各呼び出しでは、その時点で再生する必要がある音(最初の音など)だけでなく、現在から次の間隔までの間に再生する必要がある音についても、ウェブ オーディオ イベントをスケジュールします。

実際、setTimeout() 呼び出しの間隔を正確に把握するだけではなく、このタイマー呼び出しと次のタイマー呼び出しの間にスケジュールの重複も必要です。最悪の場合のメインスレッドの動作、つまりガベージ コレクション、レイアウト、レンダリングなどのコードがメインスレッドで次のタイマー呼び出しを遅らせる最悪の場合に対応できるよう、スケジュールの重複も必要になります。また、オーディオ ブロック スケジューリング時間、つまりオペレーティング システムが処理バッファに保持するオーディオの量も考慮する必要があります。これは、オペレーティング システムやハードウェアによって異なります。時間は 1 桁台前から 50 ミリ秒前後までです。上記の各 setTimeout() 呼び出しには、イベントのスケジュール設定を試行する期間全体を示す青色の間隔があります。たとえば、上の図でスケジュール設定されている 4 番目のウェブ オーディオ イベントは、次の setTimeout 呼び出しまで再生を待った場合(setTimeout の呼び出しがわずか数ミリ秒後)、「遅れて」再生された可能性があります。実際には、このような時間のジッターはそれよりもさらに極端なものになる可能性があり、アプリが複雑になるにつれてこの重複はますます重要になります。

全体的な先読みレイテンシは、テンポ コントロール(およびその他のリアルタイム コントロール)がどの程度厳しくなるかに影響します。スケジュール呼び出しの間隔は、最小レイテンシとコードがプロセッサに影響する頻度のトレードオフとなります。先読みが次の間隔の開始時間とどの程度重なるかによって、異なるマシン間でのアプリの復元性が決まります。複雑になりにくくなると、レイアウトとガベージ コレクションの処理時間が長くなる可能性があります。一般に、低速のマシンやオペレーティング システムに対する復元力を確保するためには、全体的な先読みを長くし、間隔を適度に短くするのが最善です。処理するコールバックを減らすためにオーバーラップを短くしたり、インターバルを長くしたりすることもできますが、ある時点でレイテンシが大きいとテンポの変化などがすぐに反映されなくなるという報告が寄せられる場合があります。逆に、先読みを少なくしすぎると、スケジューリング コールで過去に発生していたはずのイベントを「作り直す」必要が出てくるかもしれません。

以下のタイミング図は、メトロノームのデモコードの実際の動作を示しています。setTimeout の間隔は 25 ミリ秒ですが、復元性に優れたオーバーラップで、各呼び出しが次の 100 ミリ秒のスケジュールに設定されます。この方法の欠点は、テンポの変更などが有効になるまで 10 分の 1 秒かかることですが、中断に対する耐性はかなり向上しています。

スケジュールに長い重複がある。
長い重複を伴うスケジュール設定

実際、この例では、途中で setTimeout の中断が発生したことがわかります。約 270 ミリ秒の setTimeout コールバックがあるはずでしたが、なんらかの理由で本来よりも約 320 ミリ秒~ 50 ミリ秒遅れていました。しかし、先読みのレイテンシが大きいためにタイミングが問題なく進み、その直前でテンポを上げて 16 分音符 240 bpm で再生したにもかかわらず、ビートを逃すことはありませんでした(ドラムとベースのハードコアのテンポを超えて)。

また、スケジューラ呼び出しごとに複数のノートがスケジュールされる可能性があります。スケジューリング間隔を長くし(先読みを 250 ミリ秒、間隔を 200 ミリ秒)、中央のテンポを上げるとどうなるかを見てみましょう。

長い先読みと長い間隔を持つ setTimeout() を設定します。
長い先読みと長い間隔を指定した setTimeout()

このケースは、各 setTimeout() 呼び出しが複数のオーディオ イベントをスケジュールする可能性があることを示しています。実際、このメトロノームは 1 音ずつのシンプルなアプリケーションですが、ドラムマシン(複数の同時音符が多用される)やシーケンサー(音符の間隔が不規則になることも多い)では、このアプローチがどのように機能するかは簡単にわかります。

実際には、スケジュール間隔と先読みを調整し、レイアウトやガベージ コレクションなどのメインの JavaScript 実行スレッドで行われる処理の影響を確認したり、テンポの制御の粒度を調整したりする必要があります。たとえば、頻繁に発生する非常に複雑なレイアウトでは、先読みを大きくしたほうがよいでしょう。重要なのは、ここで行う「スケジュールの早め」は、遅延が発生しないように十分な大きさにし、テンポ コントロールを微調整する際に顕著な遅延が生じるほど大きくしないようにすることです。上記のケースでも重複が非常に小さいため、複雑なウェブ アプリケーションを搭載した低速マシンでは復元性が低くなります。まず、100 ミリ秒の「先読み」時間から始めて、間隔を 25 ミリ秒に設定するとよいでしょう。オーディオ システム レイテンシが大きいマシン上の複雑なアプリケーションでは、まだ問題が発生する可能性があります。その場合は、先読み時間を早める必要があります。復元力が低くても厳密な制御が必要な場合は、先読みを短くする必要があります。

スケジューリング プロセスのコアコードは scheduler() 関数にあります。

while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
  scheduleNote( current16thNote, nextNoteTime );
  nextNote();
}

この関数は現在のオーディオ ハードウェアの時刻を取得して、シーケンスの次の音符の時刻と比較します。この正確なシナリオでは、ほとんどの場合、何も実行されません(スケジュール待ちのメトロノームの「音符」はありませんが、成功すると、Web Audio API を使用してその音符がスケジュールされ、次の音符に進みます)。

scheduleNote() 関数は、次に再生するウェブ オーディオの「メモ」を実際にスケジュールする役割を担います。ここでは、オシレータを使用して、さまざまな周波数のビープ音を鳴らしました。AudioBufferSource ノードを作成して、そのバッファをドラム サウンドなど、必要なサウンドに設定するのも簡単です。

currentNoteStartTime = time;

// create an oscillator
var osc = audioContext.createOscillator();
osc.connect( audioContext.destination );

if (! (beatNumber % 16) )         // beat 0 == low pitch
  osc.frequency.value = 220.0;
else if (beatNumber % 4)          // quarter notes = medium pitch
  osc.frequency.value = 440.0;
else                              // other 16th notes = high pitch
  osc.frequency.value = 880.0;
osc.start( time );
osc.stop( time + noteLength );

これらのオシレータがスケジュールされて接続されると、このコードはそれらのオシレータを完全に消去する可能性があります。つまり、オシレータの開始と停止を行い、自動的にガベージ コレクションが行われます。

nextNote() メソッドは、次の 16 分音符に進む処理を行います。つまり、nextNoteTime 変数と current16thNote 変数を次の音に設定します。

function nextNote() {
  // Advance current note and time by a 16th note...
  var secondsPerBeat = 60.0 / tempo;    // picks up the CURRENT tempo value!
  nextNoteTime += 0.25 * secondsPerBeat;    // Add 1/4 of quarter-note beat length to time

  current16thNote++;    // Advance the beat number, wrap to zero
  if (current16thNote == 16) {
    current16thNote = 0;
  }
}

これは非常に簡単です。ただし、このスケジュール設定の例では、「シーケンス時間」、つまりメトロノームを開始してからの時間を追跡していないことを理解することが重要です。最後の音をいつ演奏したかを覚えて、次の音をいつ演奏するかを決めるだけで済みます。そうすれば、テンポを簡単に変更(または再生を停止)できます。

このスケジューリング手法は、ウェブ上の他の多くのオーディオ アプリケーションで使用されています。たとえば、Web Audio Drum Machine や、楽しい Acid Defender ゲームのほか、Granular Effects のデモのような詳細なオーディオ サンプルなどです。

別のタイミング システム

優れたミュージシャンなら誰でも知っているように、すべてのオーディオ アプリケーションに必要なものはもっとカウベル、さらにはタイマーです。ビジュアル表示の正しい方法は、3 つ目のタイミング システムを使用することです。

なんでもう一つの計時システムが必要なの?これは、requestAnimationFrame API を介してビジュアル表示、つまりグラフィックのリフレッシュ レートに同期されます。メトロノームの例でボックスを描画する場合、これはそれほど重要に思えないかもしれませんが、グラフィックがますます複雑になるにつれて、requestAnimationFrame() を使用してビジュアル リフレッシュ レートを同期することがますます重要になります。また、setTimeout() を使用するのと同じくらい最初から簡単に使用できます。非常に複雑な同期グラフィック(例: 同期された高密度の Animation では、高密度の音符を正確に表示できず、音符が滑らかに音符を再生できません)。

スケジュールのキューにあるビートを追跡していました。

notesInQueue.push( { note: beatNumber, time: time } );

メトロノームの現在の時刻とのインタラクションは draw() メソッドにあります。このメソッドは、グラフィック システムの更新の準備ができると(requestAnimationFrame を使用して)呼び出されます。

var currentTime = audioContext.currentTime;

while (notesInQueue.length && notesInQueue[0].time < currentTime) {
  currentNote = notesInQueue[0].note;
  notesInQueue.splice(0,1);   // remove note from queue
}

ここでも、オーディオ システムのクロックをチェックしているのがわかります。同期したいのは、このクロックです。実際に音が鳴るので、新しいボックスを描くべきかどうかを確かめます。実際、requestAnimationFrame タイムスタンプはまったく使用していません。これは、オーディオ システム クロックを使用して時間の経過を把握しているからです。

もちろん、setTimeout() コールバックの使用をすべてスキップして、メモ スケジューラを requestAnimationFrame コールバックに入れることもできます。そうすれば、タイマーもまた 2 つに短縮されます。そのように指定することもできますが、このケースでは requestAnimationFrame は setTimeout() の代用であることを理解することが重要です。実際のメモについては、ウェブ オーディオのタイミングのスケジューリング精度が必要です。

まとめ

このチュートリアルが時計やタイマーについて説明するとともに、ウェブ オーディオ アプリケーションに適切なタイミングを組み込む方法を説明するのに役立つことを願っています。これらの同じ手法を簡単に外挿して、シーケンス プレーヤーやドラムマシンなどを構築することもできます。またお会いしましょう...