ウェブ音声の正確なスケジュール設定
はじめに
ウェブ プラットフォームを使用して優れた音声や音楽ソフトウェアを構築する際の最大の課題の一つは、時間の管理です。「コードを記述する時間」ではなく、時計の時間です。Web Audio で最も理解されていないトピックの一つが、オーディオ クロックを適切に扱う方法です。Web Audio AudioContext オブジェクトには、この音声時計を公開する currentTime プロパティがあります。
特に、ウェブオーディオの音楽アプリケーション(シーケンサーやシンセサイザーの作成だけでなく、ドラムマシン、ゲーム、その他のアプリケーションなど、オーディオ イベントをリズミカルに使用するアプリケーション)では、音声の開始と停止だけでなく、音声の変更(周波数や音量の変更など)のスケジュール設定も含め、オーディオ イベントのタイミングを一定かつ正確にすることが非常に重要です。Web Audio API によるゲーム サウンドの開発のマシンガンのデモなど、イベントのタイミングを少しランダムにしたい場合もありますが、通常は、楽音のタイミングを一定かつ正確にする必要があります。
Web Audio のスタートガイドと Web Audio API によるゲーム サウンドの開発では、Web Audio の noteOn メソッドと noteOff メソッド(現在は start メソッドと stop メソッドに改名)の time パラメータを使用して音符をスケジュールする方法について説明しました。しかし、長い音楽シーケンスやリズムの再生など、より複雑なシナリオについては詳しく説明してきませんでした。詳しく説明するには、まずクロックの背景を少し説明する必要があります。
最高の時代 - ウェブ オーディオ クロック
Web Audio API は、オーディオ サブシステムのハードウェア クロックへのアクセスを公開します。このクロックは、.currentTime プロパティを通じて、AudioContext が作成されてからの浮動小数点数として、AudioContext オブジェクトで公開されます。これにより、この時計(以下「オーディオ クロック」)が非常に高精度になります。高いサンプルレートであっても、個々のサウンド サンプル レベルでアライメントを指定できるように設計されています。「double」の精度は約 15 桁の 10 進数であるため、オーディオ クロックが何日も動作している場合でも、高いサンプルレートであっても特定のサンプルを指すには十分なビットが残っているはずです。
オーディオ クロックは、Web Audio API 全体でパラメータとオーディオ イベントのスケジュール設定に使用されます。もちろん、start() と stop() だけでなく、AudioParam の set*ValueAtTime() メソッドにも使用されます。これにより、非常に正確なタイミングのオーディオ イベントを事前にセットアップできます。実際、Web Audio ですべてを開始時間と停止時間として設定したくなりますが、実際には問題があります。
たとえば、Web Audio 入門のコード スニペットを短縮したものを以下に示します。これは、8 分音符のハイハット パターンの 2 小節を設定します。
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 小節が終わる前に演奏を停止したりすることはできません。(事前にスケジュールされた AudioBufferSourceNodes と出力の間にゲイン ノードを挿入して、独自のサウンドをミュートするデベロッパーもいます)。
つまり、テンポや周波数、ゲインなどのパラメータを柔軟に変更する(またはスケジュールを完全に停止する)必要があるため、キューにあまり多くの音声イベントをプッシュしないでください。より正確には、スケジュールを完全に変更する可能性があるため、あまり先を見越さないでください。
最悪の時代 - JavaScript クロック
Date.now() と setTimeout() で表される、非常によく練られた JavaScript クロックもあります。JavaScript クロックの長所は、非常に便利な call-me-back-later window.setTimeout() メソッドと window.setInterval() メソッドが 2 つあり、システムが特定の時間にコードを呼び戻すことができる点です。
JavaScript クロックの欠点は、精度があまり高くないことです。まず、Date.now() はミリ秒単位の値(ミリ秒の整数値)を返すため、期待できる精度は 1 ミリ秒です。音楽の文脈によっては、音符が 1 ミリ秒早かったり遅れたりしても気付かないこともありますが、比較的低い音声ハードウェア レートの 44.1 kHz でも、音声スケジューリング クロックとして使用するには 44.1 倍遅すぎます。サンプルをドロップすると音声のグリッチが発生する可能性があるため、サンプルを連結する場合は、正確に連続させる必要があります。
今後リリースされる高精度時間仕様では、window.performance.now() を使用して、現在の時刻をより正確に取得できます。この仕様は、多くの現在のブラウザに実装されています(接頭辞付き)。これは状況によっては役立つ場合がありますが、JavaScript のタイミング API の最悪の部分にはあまり関係ありません。
JavaScript タイミング API の最も悪い点は、Date.now() のミリ秒単位の精度は許容範囲内のように思えますが、JavaScript のタイマー イベントの実際のコールバック(window.setTimeout() または window.setInterval を介して)は、レイアウト、レンダリング、ガベージ コレクション、XMLHTTPRequest などのコールバックによって、簡単に数十ミリ秒以上ずれる可能性があることです。つまり、メインの実行スレッドで発生するさまざまな事象によってずれる可能性があるということです。Web Audio API を使用してスケジュール設定できる「オーディオ イベント」について説明しましたね。それらはすべて別のスレッドで処理されます。そのため、複雑なレイアウトや長いタスクの実行中にメインスレッドが一時的に停止していても、音声は発生が指示されたタイミングで発生します。実際、デバッガのブレークポイントで停止しても、オーディオ スレッドはスケジュールされたイベントを再生し続けます。
音声アプリでの JavaScript setTimeout() の使用
メインスレッドは一度に数ミリ秒間だけ停止する可能性があるため、JavaScript の setTimeout を使用してオーディオ イベントの再生を直接開始することはおすすめしません。メモは、せいぜい本来は 1 ミリ秒以内に配信され、最悪でもさらに長く遅延するからです。最悪なのは、リズミカルなシーケンスが、メインの JavaScript スレッドで発生する他の処理の影響を受け、正確な間隔で発動されないことです。
それをデモするために、サンプルの「不適切な」メトロノーム アプリケーションを作成しました。これは、setTimeout を直接使用してメモをスケジュールし、レイアウトも多用するアプリケーションです。このアプリを開き、[再生] をクリックして、再生中にウィンドウのサイズをすばやく変更すると、タイミングが著しくジッターする(リズムが一定に保たれない)ことがわかります。「でも、これは不自然だ!」と思われるかもしれません。もちろん、そうはいっても、現実世界でも起こり得ます。比較的静的なユーザー インターフェースでも、再レイアウトが原因で setTimeout のタイミングに問題が生じます。たとえば、ウィンドウのサイズをすばやく変更すると、優れた WebkitSynth のタイミングが著しく途切れることがわかりました。では、オーディオに合わせて楽譜全体をスムーズにスクロールしようとするとどうなるかを想像してみてください。複雑な音楽アプリにどのような影響を与えるかは容易に想像できるでしょう。
よくある質問の一つが、「オーディオ イベントからコールバックを取得できないのはなぜですか?」というものです。このようなコールバックには用途があるかもしれませんが、現在直面している特定の問題を解決することはできません。これらのイベントはメインの JavaScript スレッドで発生するため、setTimeout と同じ遅延が発生する可能性があります。つまり、スケジュールされた正確な時刻から、実際に処理されるまでに数ミリ秒の遅延が発生する可能性があります。
では、どうすればよいでしょうか。タイミングを処理する最善の方法は、JavaScript タイマー(setTimeout()、setInterval()、requestAnimationFrame() - 後で説明)とオーディオ ハードウェアのスケジューリングの連携を設定することです。
先を見越して確実なタイミングを実現する
メトロノームのデモに戻りましょう。実は、このシンプルなメトロノームのデモの最初のバージョンを正しく記述し、この協調スケジューリング手法のデモを行いました。(コードは GitHub でも入手できます)このデモでは、16 分音符、8 分音符、4 分音符ごとに高精度でビープ音(オシレーターによって生成)を再生し、ビートに合わせて音程を変更します。また、演奏中にテンポや音符の間隔を変更したり、いつでも再生を停止したりできます。実世界のリズミック シーケンサーで重要な機能です。このメトロノームがその場で使用するサウンドを変更するコードを追加するのは非常に簡単です。
確実なタイミングを維持しながら一時的なコントロールを可能にするのは、setTimeout タイマーが一定の間隔で発動し、個々の音符の今後の Web Audio のスケジュールを設定するコラボレーションです。基本的に、setTimeout タイマーは現在のテンポに基づいて「すぐに」スケジュールする必要がある音符がないかを確認し、次のようにスケジュールします。
実際には、setTimeout() 呼び出しが遅延する可能性があるため、スケジュール呼び出しのタイミングが時間の経過とともにジッター(および setTimeout の使用方法によってはずれ)する可能性があります。この例のイベントは約 50 ミリ秒間隔で発生しますが、多くの場合、それより少し長くなります(場合によっては大幅に長くなることもあります)。ただし、各呼び出しで、今すぐ再生する必要がある音符(最初の音符など)だけでなく、今から次の間隔の間に再生する必要がある音符についても、Web Audio イベントがスケジュールされます。
実際、setTimeout() 呼び出し間の間隔を正確に予測するだけでなく、メインスレッドの動作のワーストケース(メインスレッドで発生するガベージ コレクション、レイアウト、レンダリングなどのコードがワーストケースで次回のタイマー呼び出しを遅らせる)に対応するために、このタイマー呼び出しと次のタイマー呼び出しの間にスケジュールの重複が必要です。また、オーディオ ブロックのスケジューリング時間(オペレーティング システムが処理バッファに保持するオーディオの量)も考慮する必要があります。これは、オペレーティング システムとハードウェアによって、1 桁台のミリ秒から 50 ミリ秒程度まで異なります。上記の各 setTimeout() 呼び出しには、イベントのスケジュール設定を試行する期間全体を示す青色の区間があります。たとえば、上の図でスケジュール設定されている 4 番目のウェブ オーディオ イベントは、次の setTimeout 呼び出しが発生するまで再生を待っていて、その setTimeout 呼び出しがわずか数ミリ秒後に行われた場合、「遅れて」再生された可能性があります。実際の状況では、これらの時間のジッターはさらに極端になる可能性があります。アプリの複雑さが増すにつれて、この重複はさらに重要になります。
全体的な先読みレイテンシは、テンポの制御(およびその他のリアルタイム制御)の精度に影響します。スケジューリング呼び出し間の間隔は、最小レイテンシとコードがプロセッサに影響する頻度とのトレードオフです。先読みが次の間隔の開始時間と重複する程度によって、さまざまなマシン間でのアプリの復元力と、複雑さが増すにつれてレイアウトとガベージ コレクションにかかる時間が長くなるかどうかが決まります。一般的に、低速なマシンやオペレーティング・システムに対する耐障害性を確保するためには、全体的な先見性を広く、間隔を適度に短くするのが最善です。コールバックを処理する回数を減らすために、重複を短くして間隔を長くするように調整できますが、レイテンシが長くなるとテンポの変更などがすぐに反映されなくなることがあります。逆に、先読みを短くしすぎると、ジッターが発生することがあります(スケジュール呼び出しで、過去に発生するはずだったイベントを「補う」必要があるため)。
次のタイミング図は、メトロノーム デモコードの実際の動作を示しています。setTimeout 間隔は 25 ミリ秒ですが、重複はより復元性が高く、各呼び出しは次の 100 ミリ秒のスケジュールでスケジュールされます。この先読み時間の短所は、テンポの変更などが 10 分の 1 秒で有効になることです。ただし、中断に対する耐性ははるかに優れています。
実際、この例では途中で setTimeout が中断されています。setTimeout コールバックは約 270 ミリ秒で発生するはずですが、何らかの理由で約 320 ミリ秒まで遅延しています。これは、本来の発生時刻から 50 ミリ秒遅れています。ただし、長い先読みレイテンシにより、タイミングは問題なく維持され、直前にテンポを上げて 240 bpm で 16 分音符を演奏しても、拍を外すことはありませんでした(ハードコアなドラム&ベースのテンポを超えています)。
各スケジューラ呼び出しで複数の音符がスケジュールされる可能性もあります。スケジュール間隔を長く(250 ミリ秒のルックアヘッド、200 ミリ秒の間隔)し、途中でテンポを上げた場合の動作を見てみましょう。
この例では、各 setTimeout() 呼び出しで複数のオーディオ イベントがスケジュールされる可能性があります。このメトロノームは 1 つの音符を 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;
}
}
実に簡単です。ただし、このスケジュール設定の例では、「シーケンス タイム」、つまりメトロノームを開始してからの経過時間を追跡していないことを理解することが重要です。必要なのは、最後の音を鳴らした時刻を覚えておき、次の音を鳴らすタイミングを計算することだけです。テンポを変更したり、再生を停止したりするのが簡単になります。
このスケジューリング手法は、ウェブ オーディオ ドラムマシン、非常に楽しいAcid Defender ゲーム、グラニューラル エフェクトのデモなど、ウェブ上の他の多くのオーディオ アプリケーションで使用されています。
もう一つのタイミング システム
優れたミュージシャンなら誰でも知っているように、あらゆるオーディオ アプリケーションに必要なのは、より多くのタイマー、つまりより多くのタイマーです。視覚的な表示を行う正しい方法は、サードパーティのタイミング システムを使用することです。
なぜ、なぜ、なぜ、もう 1 つのタイミング システムが必要なのか?これは、requestAnimationFrame API を介して、視覚ディスプレイ(つまりグラフィックのリフレッシュ レート)と同期されます。メトロノームの例でボックスを描画する場合、これは大したことないように思えますが、グラフィックが複雑になるにつれて、requestAnimationFrame() を使用して視覚的な更新レートと同期することがますます重要になります。実際には、setTimeout() を使用する場合と同じように、最初から簡単に使用できます。非常に複雑な同期グラフィック(楽譜パッケージで再生される密集した音符を正確に表示するなど)では、requestAnimationFrame() を使用すると、グラフィックと音声の同期が最もスムーズで正確になります。
キュー内のビートをスケジューラで追跡しました。
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() の代用品に過ぎないことを理解しておくことが重要です。実際の音符には、Web Audio タイミングのスケジューリングの精度が必要です。
まとめ
このチュートリアルが、時計やタイマーについて説明するとともに、優れたタイミングをウェブ オーディオ アプリケーションに組み込む方法を説明するのに役立つことを願っています。これらと同じ手法を簡単に外挿して、シーケンス プレーヤーやドラムマシンなどを作成できます。次回もよろしくお願いいたします。