2 つの時計のお話 - Web Audio の正確なスケジューリングについて

HTML5 Rocks

はじめに

Webのプラットフォームを使ってオーディオ/ミュージック関連のすばらしいソフトを作成する際に、最も困難なのは「時間」の扱いです。ここで言っているのはコードを書くのにかかる時間ではなく、クロックの時間のことです。オーディオクロックを正しく扱う方法は、Web Audioについて最も知られていないことのひとつです。Web Audio でオーディオクロックを扱うには AudioContext オブジェクトの currentTime プロパティを使用します。

Web Audioを使った音楽系のアプリケーション (単なるシーケンサーやシンセサイザーではなく、例えばドラムマシーンゲームその他のオーディオイベントを律動的に扱うアプリケーション) にとって、安定した正確なタイミングのオーディオイベントは非常に重要です。それは単に音を出力したり止めたりするタイミングだけではなく、周波数やボリュームを変更するタイミングも含みます。時には少々ランダムに発生するイベントが必要とされる場合もありますが (例えばこちらのマシンガンのデモ)、通常は安定した正確なタイミングの音楽の音が求められます。

前回、Getting Started with Web Audio および Developing Game Audio with the Web Audio API にて、noteOn / noteOffメソッド (現在はstart / stopに変更されました) にパラメータを指定して音をスケジューリングする方法を紹介しましたが、もっと複雑なケース、例えば尺の長い楽曲やリズムの再生に関しては詳しく触れませんでした。では、本題に入る前に、クロックについて少々説明することと致しましょう。

精度が高い Web Audio クロック

Web Audio APIは端末のハードウェアクロックへのアクセスを提供します。このクロックはAudioContextの.currentTimeプロパティにて取得可能で、AudioContextが生成されてからの秒数を浮動小数点数で表現します。このクロック (以後、「オーディオクロック」と呼びます) は非常に高精度であり、高いサンプリングレートであっても個々のサウンドサンプルを特定できるようにデザインされています。オーディオクロックは10進数15桁の倍精度を持つので、数日間動作してもサンプルを特定できるだけのビットが割り当てられています。

オーディオクロックはWeb Audio APIの全体を通じてスケジューリングパラメーターとオーディオイベントを処理するために使用されています。例えば start() および stop() はもちろんのこと、AudioParamsインターフェイスの set*ValueAtTime() methods でも使用されています。このインターフェイスは非常に正確な時間のオーディオイベントを事前に組み立てることを可能にします。事実、Web Audio で扱う時間すべてをstart/stop で設定したい誘惑に駆られますが、しかし、実際にそのようにすると問題が生じます。

例えば、以下のWeb Audio Introからの抜粋コードを見てください。ここでは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);
  }
}

このコードは問題なく動作します。しかしながら、1小節目と2小節目の間でテンポが変わったり、2小節が完了する前に再生が停止された場合、たちまち問題が生じます。(私はgainノードをAudioBufferSourceNodeと出力の間に接続して音を止める開発者を一人となく見てきました。)

端的に言えば、テンポや周波数/ボリューム等のパラメーターを柔軟に変更できるように、あまり多くのオーディオイベントをキューに詰め込みすぎないようにしなければいけません。もしくは、より正確に表現すると、後でスケジュールを全面的に変更できるように、あまり先の予定を立てすぎないようにしなければいけません。

精度が低い JavaScript クロック

JavaScriptにはとても敬愛されると同時に中傷される Date.now()setTimeout() などのクロックがあります。特にwindow.setTimeout()window.setInterval() はコールバック関数を時間を指定して登録することで、システム側から呼び出してもらえるため、非常に便利なメソッドです。

ただ、これらのクロックの欠点として、あまり精度が良くないことがあげられます。ご存知かもしれませんが、Date.now() はミリ秒を整数の戻り値として返すので、期待できる分解能は1ミリ秒です。音が1ミリ秒ずれただけであれば、通常気付かないので問題になりませんが、相対的に性能の低いハードウェアでも44.1kHzのクロックレートを持つので、それに比べれば44.1倍遅く動作するわけで、オーディオのスケジューリングに使用するにはスローすぎます。1サンプルでも取りこぼすと音とびが発生するので、サンプルを連続したものとして扱うには、それらを正確にシーケンシャルに処理する必要があるのです。

現在策定中の High Resolution Time specification では、window.performance.now() が定義されており、より高い精度で現在時刻が取得できるようになります。(プリフィックス付きですが) すでに多くのブラウザで実装されています。これにより、一定の状況においては問題が解決しますが、JavaScriptのタイミングAPIにはこれでもまだ解決しない深刻な問題があります。

JavaScriptのタイミングAPIが持つ深刻な問題というのは、タイマーイベントのコールバック関数の呼び出しタイミングです。Date.now() のミリ秒の精度だけならまだ許容できますが、window.setTimeout() もしくは window.setInterval() で登録したコールバック関数の呼び出しタイミングに関しては、数10ミリ秒かそれ以上のズレが簡単に生じ得ます。要因としては、レイアウト処理、レンダリング処理、ガベージコレクション、XMLHttpRequest、そして他のコールバックの実行等があげられます。簡単に言えば、メインスレッドで実行されるすべての処理が要因になり得るのです。では、Web Audio APIでスケジューリングする「オーディオイベント」はどうなのでしょう? 実はそれらはすべて、別のスレッドで処理されます。ですので、もしメインスレッドが複雑なレイアウトやその他の長い処理で一時的に止まっていたとしても、オーディオは設定された時刻きっかりに実行されます。たとえデバッガーを使ってブレイクポイントで処理を止めていたとしても、オーディオスレッドはスケジューリングされた通りイベントを実行します。

オーディオ・アプリケーションで JavaScript の setTimeout() を使うとどうなるか

メインスレッドは簡単に数ミリ秒遅延するので、JavaScriptのsetTimeoutを直接オーディオ再生イベントに使用するのは得策とは言えません。正常に動作したとしても、1ミリ秒かそこらの誤差が生じ、最悪の場合、際限なく遅延するのですから。リズムのある楽曲の場合、正確な時間間隔で発生すべきイベントが他のメインスレッドの処理により発火しないことは、何よりも致命的です。

これを再現するために、「残念な」メトロノームのサンプルアプリケーションを書きました。ここでは setTimeout を音のスケジューリングに直接使用すると同時に、たくさんのレイアウトを走らせています。アプリを開いて "play"をクリックし、アプリのウィンドウを素早くリサイズしてみてください。音の鳴るタイミングが乱れるのに気付くと思います。(つまりリズムが一定ではなくなります。) 「作為的じゃないか!」とおっしゃるかもしれませんが、現実世界で同様の現象が発生しないとも限りません。比較的静的なUIであっても、再レイアウトによる setTimeout のタイミング問題の影響を受けます。例えば、私は試しに素晴らしい WebkitSynth でウィンドウを素早くリサイズしてみたところ、認識できる程度に音が乱れました。このように、現実世界の複雑なアプリケーションにおいて、例えば楽曲を再生しながら楽曲のフルスコアをスクロールするといったような操作をすれば、どのような影響があるか容易に想像できると思います。

「なぜオーディオイベントのコールバックを取得できないの?」とよく聞かれますが、そのようなコールバックは利用価値はあるものの、現実の問題は解消されないでしょう。結局のところそれらのイベントはJavaScriptのメインスレッドで発火されるでしょうから、setTimeout と同様の潜在的な遅延の問題の対象となります。つまり、実際にスケジュールされた正確な時間から、何ミリ秒になるのか予測できないくらい遅れて処理される可能性があります。

では、どうすればいいのでしょうか。実は、タイミングを扱う最善の方法は、JavaScript のタイマー (setTimeout(), setInterval() もしくは requestAnimationFrame() - その他、追加される予定のもの) とオーディオのハードウェアスケジューリングを協調させることなのです。

「先読み」により安定したタイミングを得る方法

先ほどのメトロノームのデモに話を戻します。私はこのデモを協調スケジューリングのテクニックを使って書き直しました。(コードはGithubにあります。) このデモはOscillatorノードで生成されたビープ音を再生し、16分音符/8分音符/4分音符ごとにそれぞれのビートの音程を変えています。また、再生中にテンポや音の間隔を変更したり、再生中のいかなる時点でも停止することが可能です。それらは実世界でのリズムシーケンサーでは通常サポートされている機能です。また、再生中にメトロノームの音を変えるコードを追加することも簡単に出来ます。

安定したタイミングを保ちつつテンポのコントロールを可能にしているのは協調です。つまり、setTimeout のタイマーが定期的に実行されて、Web Audioのスケジューリングを行うことで、個々の音を設定しています。setTimeout のタイマーは現在のテンポを基準に、「今すぐ」スケジューリングされるべき音が存在するかチェックします。以下を参照ください:

setTimeout() and audio event interaction

実際には setTimeout() の呼び出しが遅れる可能性があるので、スケジューリング処理の走るタイミングは時間とともに不安定になります。(setTimeout の使い方によっては徐々にずれが蓄積します。) 上記の例では約50ミリ秒ごとにイベントが発火していますが、それよりも少し間隔が長くなります。(時折、かなり長くなります。) しかしながら、それぞれの呼び出しにおいて、現在再生するべき音 (つまり直近の音) だけではなく、次回のイベント発火までに再生されるべき音もスケジューリングしています。

実際には、setTimeout() の呼び出し間隔の長さ分だけ先読みするのではなく、それぞれのタイマー呼び出しの間で、ある程度スケジューリングの範囲を重複させることが必要となります。これはメインスレッドが最悪のケースで実行された場合、つまりガベージコレクション、レイアウト、レンダリング、その他の次回タイマー実行を遅らせる要因となるメインスレッド上のコードが最悪のケースで実行された場合に対処するためです。我々はまた、オーディオブロックのスケジューリング時間、つまりOSが処理バッファに持つことができるオーディオのサイズも考慮に入れなければなりません。それはOSおよびハードウェアごとに異なり、数ミリ秒から50ミリ秒に渡ります。上記の例では、各 setTimeout() 呼び出しごとに青色で記された部分がスケジュールの対象範囲となります。例えば上図の先頭から4番目にスケジュールされたWeb Audioイベントに着目してください。もしこれを次のsetTimeout 呼び出しでスケジューリングしたならば、そのsetTimeout 呼び出しが2, 3ミリ秒遅れただけで、音が遅れて再生されてしまいます。実世界ではこれらの時間のズレはより顕著なものとなるので、アプリケーションが複雑になればなるほど、スケジュール範囲の重複は重要になります。

先読みの時間範囲は、どれほどテンポ (およびその他のリアルタイム制御) が厳密なのかに影響します。つまり、スケジューリングの間隔は、遅延の最小化と実行コードがCPUに与える影響とのトレードオフとなります。先読みの時間範囲が次の呼び出しの開始時間とどれだけ重なるかは、アプリケーションの移植性と複雑さ (複雑であるほどレイアウトやガベージコレクションに時間がかかる) によって決まります。一般的には遅いマシンやOSをサポートするためには、先読みの範囲を広げて十分短い時間間隔で実行します。コールバックの呼び出し頻度を低くするために、スケジューリングの重複範囲を短くしたり、時間間隔を長くしたりすることも可能ですが、ある閾値を超えると、遅延が大きくなってテンポが揺らいだり、エフェクトが即座に反映されなかったりといった現象が起きるかもしれません。また逆に先読みの時間間隔を短くしすぎると、音ズレが起きるかもしれません。(過去に起きるべきイベントがスケジューリングされた場合そうなります。)

以下の図は実際にメトロノームのデモアプリが行っていることを図示しています。ここでは setTimeout が25ミリ秒間隔で呼ばれていますが、各イベントでのスケジューリングの範囲はもっと幅を持たせて 100ミリ秒先までチェックするようになっています。このように先読みの範囲を長くした場合のデメリットとして、テンポ変更等への対応、つまり効果が現れるまで1/10秒ぐらいかかってしまう、ということがあげられます。しかしながら、我々のデモアプリは割り込みに対しては十分に柔軟に対処できています。

scheduling with long overlaps

上記の例では途中で setTimeout の割り込みが入ります。約270ミリ秒の時点で発生するはずの setTimeout のコールバックが、何らかの理由で320ミリ秒に遅延しています。つまり、本来のタイミングより50ミリ秒遅れたことになります。しかしながら、先読みの時間範囲が大きく取られているので、スケジューリングのタイミングは問題なく設定されており、テンポを240に上げてもビートを取りこぼすことなく再生できます。(テンポ240というのはハードコアのドラム&ベースのテンポを超えてますが!)

また、それぞれの呼び出しにおいて複数の音をスケジューリングする方式というのも考えられます。では、試しにスケジューリングの間隔を長くして (250ミリ秒の先読み範囲を200ミリ秒間隔でに配置)、テンポを途中で上げてみましょう。

setTimeout() with long lookahead and long intervals

この方式ではそれぞれの setTimeout() 呼び出しが複数のオーディオイベントをスケジューリングしています。このメトロノームアプリは一度にひとつの音しか鳴らしませんが、ドラムマシーン (複数の音を同時に鳴らす) もしくはシーケンサー (しばしば音を非周期的に鳴らす) においてこの方式はうまく動作します。

実際には、レイアウトやガベージコレクションやその他のJavaScriptメイン実行スレッドの処理がどれくらい影響するのかによって、また、テンポ等のコントロールの粒度によって、スケジューリングの実行間隔や先読みの範囲をチューニングします。例えばもし複雑なレイアウトが頻繁に発生するのであれば、おそらく先読みの範囲を広げた方がいいでしょう。要は、音の遅延を避けるためにスケジューリングの先読みの範囲を十分に大きく取りたいが、テンポ変更の遅延が体感できてしまうまでには大きくしたくない、ということです。上記の例ではごく短い時間しかスケジューリング範囲が重複していないので、遅いマシンや複雑なWebアプリケーションには適していません。おそらく100ミリ秒の先読み時間を25ミリ秒間隔、というところから始めるのが良いと思います。複雑なアプリケーションを遅いオーディオシステムで動作させる場合は先読みの時間範囲を広げるといいでしょう。もしくは、移植性を犠牲にしてコントロールを即座に反映させるには、先読みの時間範囲を短くします。

スケジューリング処理のコードは scheduler() 関数で定義されています。

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

この関数はオーディオクロックの時間を取得し、次に鳴らすべき音の発音時刻と比較します。ほとんどの場合、スケジュールされるメトロノームの音が存在せずに無処理で抜けますが、もし存在した場合、Web Audio APIを使ってその音をスケジューリングし、次の音へと進みます。 scheduler() 関数は25ミリ秒ごとに呼ばれます。標準テンポでは四分音符は1分に120回発生しますが、我々のアプリでは、16分音符を一秒に8回再生します。つまり125ミリ秒ごとに音を鳴らします。ということは、だいたい60%の呼び出しがスケジューリングする音を見つけられずに終了することになります。一回の scheduler() 関数呼び出しで複数の音がスケジューリングされるには、テンポを300 (四分音符が1分に300回) まで上げなければなりません。我々の単純な16分音符のパターンではそうですが、ひとたび複雑なリズム (例えば32分音符や、さらに短い音、flams、任意の音の連続、等) を扱うと、複数スケジューリングのための while 文が重要になってきます。また、当たり前ですが、先読み範囲を長くして、テンポを速くした場合、おそらく最初の呼び出しで複数の音をバッファリングします。

scheduleNote() 関数は実際に次に鳴らすべきWeb Audio の音をスケジューリングします。このサンプルでは、ビープ音を異なる周波数で鳴らすためにOscillatorノードを使っていますが、単に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 );

Oscillatorノードは、ひとたびスケジューリングされ、接続されると、後は完全に放置されます。自動的にスタートし、ストップし、そしてガベージコレクションの対象になります。

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オーディオアプリケーションで使用されています。例えば Web Audio Drum Machine や、とても楽しい Acid Defender game や、また、さらに複雑なオーディオの例として Granular Effects demo などがあります。

もうひとつのタイミングシステム

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

おぉ神様、なぜもう一つのタイミングシステムが必要なのでしょう? それはビジュアル表示、つまりグラフィックスのリフレッシュレートと同期を取るためです。それには、 requestAnimationFrame API を使います。我々のメトロノームアプリのdrawing box 程度であれば特に必要ありませんが、グラフィックスが複雑化するにつれて、requestAnimationFrame() でリフレッシュレートと同期を取ることはますます重要になってきます。そして、実は setTimeout() と同じくらい簡単に扱えるんです! とても複雑な同期グラフィックス (例えばたくさんの音を鳴らすと正確なタイミングで音符を表示する、等) を扱う場合、requestAnimationFrame() により、滑らかで正確なグラフィックスとオーディオの同期を実現できます。

scheduler() 関数にて、スケジューリングした音を記録しています。

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
}

実際に音が鳴るのに合わせて新しいboxを描画するため、オーディオクロックを参照してます。実は、ここでは一切 requestAnimationFrame のタイムスタンプを参照してません。オーディオクロックを使えば相対的な時間が分かるからです。

もちろん、最初から setTimeout() を使わずに、requestAnimationFrame のコールバック関数に音のスケジューリングを記述することも可能です。そうすればまた2つのタイマーに戻ります。それは全然かまわないのですが、重要な点は、requestAnimationFrame は setTimeout() の代用品であるということです。その場合も、実際の音をスケジューリングするにはWeb Audioの正確なタイミングが必要になります。

おわりに

このチュートリアルがクロックやタイマー、そしてWeb Audioのアプリケーションでどのように素晴らしいタイミングを実現するかについての解説として、お役に立てれば幸いです。これらのテクニックはシーケンサーやドラムマシーン等を実装してみれば、簡単に得られるものです。ではまた次回まで。

Comments

0