この動画で解説されている呪文の効果音を AudioWorklet を使って生成してみた。エンベロープの計算など細かいところは省いたがそれっぽく聞こえると思う。
コードは Github に置いた。
Chrome Web Audio Samples に AudioWorklet のサンプルがいくつかあるけど、ソースノードを作る例があまりない。この記事では入力を受け取らない、ソースノードを書くときに調べたことをちょっと紹介してみようと思う。
AudioWorklet
AudioWorklet は JS で書かれた音の生成ロジックをオーディオスレッド上で実行してしまおう、というもの。メインスレッド上で実行される ScriptProcessorNode が持っていた問題点を解決する目的で導入された。最終的に音を生成するのはオーディオスレッドなので、その上で JS 走らせられれば遅延や同期ずれを軽減できるんじゃないか、というのがアイデア。
AudioWorklet を使うには独自の AudioWorkletNode と AudioWorkletProcessor を定義しなければならず、ScriptProcessorNode と比較して書くのがかったるい。だけど、オーディオグラフ (AudioNode たちをつなげたもの) はメインスレッドの実行コンテキスト上で管理されるから、「オーディオスレッド上で動くロジック」をメインスレッド側で表現するノードが必要になる。その、メインスレッド側でオーディオグラフを構成する役割をもつのが AudioWorkletNode であり、その内部処理を担うのが AudioWorkletProcessor となる。
方針
今回作るのはある種のカスタム OscillatorNode みたいなもの。基本は OscillatorNode で作れる矩形波なのだけどファミコンの矩形波が持つ3つのモードを周期的に切り替えるような波形を作りたい。だとしたら OscillatorNode とできるだけ使用感を統一できるとよさそう、という方針を立てた。
OscillatorNode は生成する波の周波数を frequency パラメータで受け取る。frequency パラメータの型は AudioParam であり、これは時間経過とともに変更できるパラメータとなっている。また、OscillatorNode は start() と stop() メソッドを持っていて、開始と終了のタイミングを指定できる。
これらの振る舞いに似せた AudioWorketNode/AudioWorkletProcessor を作る。
周波数を設定する
OscillatorNode に周波数を指定する時のコードはこんな感じになる。
// |osc| is an OscillatorNode
osc.frequency.value = 440; // A4, 440Hz
frequency は AudioParam の一種で時間経過とともに変わりうるパラメータ。例えば frequency.setValueAtTime(880, t)
を設定すれば時刻 t が来たタイミングで周波数が440Hzから880Hzに切り替わる。これと似たようなパラメータを AudioWorklet で定義したい。
時刻とともに変化するパラメータを定義するには AudioWorkletProcessor に parameterDescriptors() を指定する。
// processor.js
class FooWorkletProcessor extends AudioWorketProcessor {
static get parameterDescriptors() {
return [
{ name: 'frequency', defaultValue: 440 }
]
}
}
上記のように定義するとメインスレッド側から以下のように渡すことができて、
// main.js
node.parameters.get('frequency').value = 220; // A3, 220Hz
プロセッサ側では 音を生成するときに呼ばれる process() の parameters 引数から参照することができる。
// processor.js
class FooWorkletProcessor extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
let output = outputs[0][0];
for (let i = 0; i < output.length; i++) {
const freq = parameters.frequency[i]; // <-- 220
}
}
}
時間と共に変わるのを表現するため、parameters.frequency は出力バッファのサイズと同じ数を持った配列としてわたってくる。
start() と stop()
Web Audio API で定義されている Audio Node のいくつかは AudioScheduledSourceNode を継承していて、OscillatorNode もその一つ。このインターフェースが start() と stop() を提供しているが、AudioWokrletNode はこれを継承していない。その代わりに MessageChannel を使って同等の機能を提供してね、というのが AudioWorklet の設計者の意図らしい。
MessageChannel は実行コンテキストが異なるオブジェクト間での通信手段を提供するもの。AudioWorkletNode と AudioWorkletProcessor はそれぞれメインスレッドとオーディオスレッド上で動くので、インタラクティブに音の生成を制御する場合には MessageChannel が必要になる。AudioParam も動的に変化する情報を渡すのに使えるけれど、こちらは例えばユーザが停止ボタンを押したら音の生成を止める、みたいな用途には向かない。
AudioWorkletNode/AudioWorkletProcessor はそれぞれ MessageChannel のエンドポイントである MessagePort を持っていて、一方の MessagePort から postMessage() するともう片方の onmessage ハンドラで受け取ることができる。
// main.js
class FooWorkletNode extends AudioWorkletNode {
start(when) {
this.port.postMessage({ action: 'start', when: when });
}
}
// processor.js
class FooWorkletProcessor extends AudioWorkletProcessor {
this.port.onmessage = (e) => {
if (e.data.action === 'start') {
// logic to start
}
};
}
上のコード片はメインスレッドからオーディオスレッド側へ情報を送る例。逆も同じように書ける。
時間変化を伴わないパラメータを渡したい
一度設定したら変化しないパラメータを指定したい時はどうしたらいいんだろうか。parameterDescriptors() で指定するパラメータは毎回 process() に渡されるものだけど、今回作った AudioWorkletProcessor ではメインスレッドから渡されるパラメータで時間と共に変わるものは使っていない。だとしたらコンストラクタでパラメータを受け取れば多少効率が良さそうに思える。
AudioWorkletNode のコンストラクタ引数には省略可能な options パラメータがある。その一部に processorOptions というのがあってこれにオブジェクトを渡すと AudioWorkletProcessor の options の processorOptions として受け取れる。具体的にはこう。
// main.js
class FooWorkletNode extends AudioWorkletNode {
constructor(context) {
const options = {
// Passed as |options.processorOptions| in AudioWorkletProcessor.
processorOptions: {
barProp: 'bar'
}
}
super(context, 'foo-processor', options);
}
}
// processor.js
class FooWorkletProcessor {
constructor(options) {
options.processorOptions.barProp; // <-- 'bar'
}
}
今回は OscillatorNode に使い勝手を寄せたのでこのやり方は使わなかったが、AudioWorkletProcessor の生存期間の間に不変のパラメータを定義したい場合はこちらを使うとよいかもしれない。
参考文献
- AudioWorklet :: What, Why, and How - Hongchan Choi: Chrome の AudioWorklet 開発者によるプレゼン動画。
- JavaScript のスレッド並列実行環境: Worklet とは何かについて。
- Web Audio API: 仕様の草案。過去のバージョンをさかのぼるとオーディオスレッド上に用意する JS の実行コンテキストを Worker として定義しようとしたけど Worklet として定義しよう、という流れに変わったんだな、というのが垣間見れる。