はじめに
第1回ではWeb Audio / Web MIDIを扱う際に出てくる3つの時刻について、そして第2回では第4の時刻について紹介し、最終的にこれらの時刻は絶対時間と演奏時間に分類できる、という話を書きました。
今回はこれらの時刻をJavaScriptから管理する際の課題と解決法を紹介しつつ、紹介した手法をまとめたPolymerのライブラリ、tp-midi-clockについて紹介したいと思います。
Web Audioを使って適切なタイミング(絶対時間)で演奏する
クリック音を単発で鳴らしてみる
まずは単純なクリック音を生成する方法を考えてみます。構成としてはオシレーター⇒ゲイン⇒出力と接続しておき、オシレーターからは一定周波数で「ピー……」と鳴らし続けておき、ゲインを操作して「ピッ」という音に加工します。コードは以下のようになります。
//
// まずは構成通りに接続して準備
//
const context = new AudioContext();
const osc = context.createOscillator();
const gain = context.createGain();
// 1200Hz: 敢えてドレミの音階から外れた周波数を選びました
osc.frequency.value = 1200;
// 最初は音を消しておきます
gain.gain.value = 0;
// 一気に接続: osc => gain => dest.
osc.connect(gain).connect(context.destination);
// 再生開始
osc.start();
//
// 以上の構成を使ってオートメーションで単発のクリックを鳴らします
//
const now = context.currentTime;
gain.gain.setValueAtTime(1, now);
gain.gain.linearRampToValueAtTime(0, now + 0.05);
最初の接続は特に説明はいらないでしょう。後半はゲインに対してオートメーションを指定する事で現在時刻から0.05秒後にかけて、100%から0%へ向かって線形的に音量を変化させています。非常にシンプルなエンベロープです。これにより「ピッ」という音を実現してみました。
JSFiddle にて実際のコードを動かして確認できます。
単純な方法で継続的に鳴らしてみる
さて、次はこれを単純な方法で継続的に鳴らしてみます。テンポ120でクリックを刻み続けるにはどうしたら良いでしょうか?
// 事前準備は同じなので省略
//
// setIntervalを使って先ほどのコードを定期的に呼び出してみます
//
setInterval(() => {
// まったく同じコード
const now = context.currentTime;
gain.gain.setValueAtTime(1, now);
gain.gain.linearRampToValueAtTime(0, now + 0.05);
}, 60 * 1000 / 120);
こちらもJSFiddleで動くコードを確認できます。単純なコードですが、何もないページ内であれば十分実用的に動作します。ただし、サンプルコードを別のタブで動かし、元のタブに戻ってみると異常に気づくでしょう。ブラウザの実装によって違いがありますが、Chromeを使っているならバックグラウンドに回った途端、同じコードで刻むクロックがテンポ120から60に落ちるはずです。
これはバックグラウンドではスケジューリングがスロットリングされるために発生します。setInterval/setTimeoutにどのような小さな遅延を指定しても、1秒置きにしかイベントは処理されません。
また上の図のように、アクティブな時でさえ、タイマー発火時にJavaScriptが他のイベントを処理していたり、GCが動いていたりすると正確な時間で処理されないでしょう。
理想的な方法で継続的に鳴らしてみる
これらの問題への対処として、第1回でWeb MIDI向けに軽く紹介したように、近い未来に起こるであろう音声処理を事前に予約発効しておく方法が用意されています。Web MIDIではsendの第2引数で渡すtimestampでしたが、Web AudioではAudioParamのオートメーションを指定する時間を使って予約発効できます。
図のように一定時間の余裕を持って処理を予約しておくことで、仮にイベントの処理が遅延したとしても演奏への影響を最小限に抑えることができます。
Performance.now()の実時間とAudioParamを通して予約に使う時間の相互変換については第2回で紹介した方法を使います。
// 事前準備は今回も省略
//
// 秒とミリ秒の単位の違いに気をつけつつ第2回で紹介した方法を使って
// DOMHighResTimeStampとcurrentTimeを相互変換する関数を用意します
//
// currentTime=0に対応するDOMHighResTimeStampを覚えておきます
// この時点でcontext.currentTimeは0かそれに近い値をとっているはず
// お手軽にbaseTimeStamp = performance.now()と書いてしまうのもアリです
const baseTimeStamp = performance.now() - context.currentTime * 1000;
// currentTimeをDOMHighResTimeStampに変換して返す
function currentTimeStamp() {
return baseTimeStamp + context.currentTime * 1000;
}
// 逆にDOMHighResTimeStampをcurrentTime形式に変換して返す
function timeStampToAudioContextTime(timeStamp) {
return (timeStamp - baseTimeStamp) / 1000;
}
// スケジュール済みのクリックのタイミングを覚えておきます。
// まだスケジュールしていませんが、次のクリックの起点として現在時刻を記録
let lastClickTimeStamp = performance.now();
// ♩=120における四分音符長(ミリ秒)
const beatTick = 60 * 1000 / 120;
// インターバルはテンポとは無関係に1秒おきに発生させます。
setInterval(() => {
// DOMHighResTimeStampで考えながらループを回します
// 未スケジュールのクリックのうち1.5秒後までに発生予定のものを予約
const now = currentTimeStamp();
for (let nextClickTimeStamp = lastClickTimeStamp + beatTick;
nextClickTimeStamp < now + 1500;
nextClickTimeStamp += beatTick) {
// 予約時間をループで使っていたDOMHighResTimeStampからAudioContext向けに変換
const nextClickTime = timeStampToAudioContextTime(nextClickTimeStamp);
// 変換した時刻を使ってクリックを予約
gain.gain.setValueAtTime(1, nextClickTime);
gain.gain.linearRampToValueAtTime(0, nextClickTime + 0.05);
// スケジュール済みクリックの時刻を更新
lastClickTimeStamp = nextClickTimeStamp;
}
}, 1000);
JSFiddleでサンプルコードを確認してみて下さい。バックグラウンドに回っても安定してテンポ120のクリックを刻み続けることがわかると思います。
今回は1秒おきに予約処理、各予約処理では1.5秒後までのクリックを予約する、というバランスになっています。この設定であれば、次のタイマー発火が0.5秒以上遅れない限りは安定してクロックを刻めることになります。一方で、最大で1.5秒後まで予約を入れてしまっているため、ユーザの要求でリアルタイムにテンポを変更する、といった事が難しくなります。必要であれば「バックグラウンド期間をジェントルに過ごす」の記事を参考に、バックグランドの遷移を検出し、動的にタイマー間隔を調整する事も検討すると良いでしょう。あるいはオートメーションはキャンセルもできますので、テンポ変更のタイミングで予約し直す方法も考えられると思います。
クリックを元にMIDIデータをMBT時刻で記録する
原理
さて、いよいよ総まとめです。ここまでの説明でWeb Audioを使った正確なクリック=メトロノームが作れるようになりました。またクリックは四分音符を淡々と刻んでいるだけなので、予約時間の計算はつまりDOMHighResTimeStampをBeatでカウントしている事に相当します。BeatがわかればMeasureもTickも計算できるので、同じ計算の応用でDOMHighResTimeStampをMBT時刻に変換できます。
// baseTimeStampまでは前回のコードを流用します
function timeStampToMBT(timeStamp) {
const duration = timeStamp - baseTimeStamp;
const beat = duration / beatTick;
return {
measure: (beat / 4) | 0, // ここでは4拍子固定
beat: (beat % 4) | 0, // 同上
tick: (beat % 1) * 480 // beatあたりの分解能は480固定
};
}
// MIDIInputを取得済みなら以下のコードで受信したMIDIのMBT時間がわかる
midiin.onmidimessage = e => {
console.log(timeStampToMBT(e.timeStamp);
};
tp-midi-clock
で、ようやくtp-midi-clockの登場。実は10月末にグーグル本社にてAndroidとChrome合同で招待制のMIDIハッカソンがありました。その中で初日のアイデアソン的なイベントの後、翌日の開発本番に備えて夜中に細々と準備したのがこちらのPolymerモジュールになります。ちなみに僕や河合さんの所属したチームはPolymerでDAW向けのコンポーネントを作りました。正確にはDAWを作ろうとして当然のごとく間に合わずに部品だけ何点か完成した、という事になりますが(笑)
Polymerのセットアップやbuild方法は他の記事(例えば今回のAdvent Calendarでは河合さんの「WebAudio-Controlsの紹介」や「ブラウザとMIDIデバイスをHTMLタグで接続する」)などを参考にしてください。
利用例
<!doctype html>
<html>
<head>
<!-- 適宜Polymerの設定を入れてください -->
<link rel="import" href="tp-midi-clock.html">
<head>
<body>
<tp-midi-clock id='clock'></tp-midi-clock>
<script>
const clock = document.getElementById('clock');
clock.start();
navigator.requestMIDIAccess().then(a => {
for (var port of a.inputs.values()) {
// 全てのMIDIInputに対してリスナーを設定
port.addEventListener('midimessage', e => {
const status = e.data[0] & 0xf0;
if (status == 0x90) {
console.log('noteOn', clock.convertTimeStampToMidiTime(e.timeStamp));
}
});
}
})
</script>
</body>
</html>
attributeを使ってテンポや拍子を指定できます。またテンポはリアルタイムに変更する事が可能です。
attribute名 | 型 | 説明 |
---|---|---|
tempo | number | テンポ(拍/分) |
beat | number | 拍 |
resolution | number | 1拍あたりのTICK数 |
click | boolean | クリック音の有無 |
countdown | number | 開始時間までの小節数 |
countdownは端的に説明するのが難しいのですが、ようするにDAWで言うところの録音開始までの空クリックの小節数です。省略時には2が指定され、2小節分の空カウントの後、MBT=0:0:0になります。つまり-2:0:0からスタートします。
番外編: ScriptProcessorNodeにおける課題
ScriptProcessorNodeを使う場合、今まで紹介した知識だけではうまくいきません。ただし、ここでの課題はWeb Audio特有のものではなくNative APIを使う場合にも言える一般的な課題と対策になります。番外編として軽く紹介したいと思います。
波形生成と波形再生のタイミング
ScriptProcessorNodeではバッファサイズ単位で再生が終わるたびにaudioprocessイベントが発生し、次に再生するデータの生成をJavaScriptから行います。バッファサイズはcreateScriptProcessor()
の第1引数で指定しますが、例えば標準的な2048などの値を指定した場合、48kHzで再生しているとすれば2048/48kで、ざっくり1/24秒。40ミリ秒ちょっとあります。描画と対で考えると2-3フレーム毎の更新となり頻繁と言えば頻繁ですが、音楽を扱う上では40ミリ秒は知覚可能な遅延であり、荒いタイミングとも言えます。この場合の波形生成のタイミング、波形再生のタイミング、音楽情報更新のタイミングについて考えてみましょう。
onaudioprocessの中では次に再生するbufferSize分の波形を生成します。波形生成に必要な時間は十分短い必要がありますし、JavaScriptでは波形生成中に他のコードが走る余地はありません。Workerは例外的に並行して実行されますが、相互のインタラクションはメッセージ経由であり、メッセージ処理は別のイベントタスクで処理されます。つまりおよそ40ミリ秒に相当する波形は瞬時に作成され、外部で発生した音楽イベントを反映させるタイミングは40ミリ秒=波形生成単位で離散した時刻に限られます。この図で考えると、何も考えずに処理した場合、オレンジ色のNoteOnイベントを反映できるのは3つ目の一番右に見える青いブロックとなります。さらにこのイベントが反映された波形が再生されるのは1ブロック遅れて、となります。また、図ではonaudioprocessは理想的なタイミングで実行されていますが、当然別の処理が実行されている場合にはタイミングにばらつきも発生します。このように不規則に離散化した時間の中では、安定した音楽データの再生は非常に困難なのが理解できるのではないでしょうか。
演奏データの反映
この問題を解決するため、波形生成ルーチンで疑似的なタイマー割り込みを発生させ、その中で音楽データの再生を行う方法があります。例えばテンポ120で48kHzで再生していれば、4分音符の解像度は48000×60÷120=24000。TICKの解像度を480とすれば、5サンプル生成する事に音楽演奏コードを呼び出してTICKを進めていくことで、オーディオストリーム生成の中で正確なタイミングを刻むことが可能となります。
もし演奏データが事前に記録された物ではなくリアルタイムの演奏だった場合には、一定の遅延を乗せた時刻で演奏データを登録しておき、疑似タイマーの中で再生予定時刻と遅延を載せた演奏時刻を照らし合わせながら波形生成を進め、ジッタ低減を図ります。
同様の複雑な処理はゲーム機のエミュレータ作成などでも発生します。昔のゲーム機ではVSYNCで演奏の同期をとっていることが多いのですが、VSYNCとオーディオ、理屈では周波数比を計算すれば同期がとれそうなものですが、実際には別々のオシレータからクロックが刻まれているために誤差があります。VSYNCから計算される演奏時間に遅延を微量の積み、疑似タイマー内で適切な波形生成位置にて演奏データの反映を行いつつ、VSYNCとオーディオの間に発生する理論と実際の誤差をmonotonic timerと照らし合わせながら定期的に補正していく。真面目に実装すると結構大変です(*1)。なので、昔のWindowsアプリなんかでは、オーディオの時間を基準にして画面更新も行い、VSYNCとのずれは我慢(そもそも昔のGUIプログラミングでは生のVSYNCに合わせて処理をする事はできなかった)って事が多かったように思えます。あるいは波形生成に伴う時刻の離散化は受け入れ、画面更新はVSYNCと同期、その間発生した音楽イベントは次の波形生成処理で一斉に反映、という安易な方法をとります。requestAnimationFrame()の導入されたJavaScriptでも、同様の苦しみを考える時代になったのかもしれません。
(*1) SEGA AGESなどではPSGを使った疑似PCMなどを再現するため、実際これと同等の同期処理をEEとIOP間で行っています。
まとめ
- 実コードを交えながらWeb Audioで正確なクリックを刻むための方法を紹介しました。同じ原理でWeb MIDIの正確な再生も可能です。
- 正確なクリックを元にMBT時刻でMIDIイベントを取得する方法を解説し、参考実装としてtp-midi-clockを紹介しました。
- おまけとしてScriptProcessorNodeで生成する波形を正確なタイミングで制御する方法を紹介しました。