オーディオ ワークレットの設計パターン

オーディオ ワークレットに関する以前の記事で、基本的なコンセプトと使用方法を詳細に説明しました。Chrome 66 でのリリース以降、実際のアプリケーションでどのように使用できるかについて、多数の要望が寄せられています。オーディオ ワークレットは WebAudio の可能性を最大限に引き出すものですが、複数の JS API でラップされた同時実行プログラミングの理解が必要なため、これを活用するのは簡単ではありません。WebAudio に詳しいデベロッパーであっても、オーディオ ワークレットを他の API(WebAssembly など)と統合することは難しい場合があります。

この記事では、実際の環境でオーディオ ワークレットを使用する方法をより深く理解し、その機能を最大限に活用するためのヒントを提供します。コードサンプルとライブデモもぜひご確認ください。

まとめ: オーディオ ワークレット

詳細に入る前に、以前にこちらの投稿で紹介したオーディオ ワークレット システムに関する用語と事実を簡単に復習しましょう。

  • BaseAudioContext: Web Audio API のプライマリ オブジェクト。
  • オーディオ ワークレット: オーディオ ワークレット オペレーション用の特別なスクリプト ファイル ローダー。BaseAudioContext に属します。BaseAudioContext には、オーディオ ワークレットを 1 つ含めることができます。読み込まれたスクリプト ファイルは AudioWorkletGlobalScope で評価され、AudioWorkletProcessor インスタンスの作成に使用されます。
  • AudioWorkletGlobalScope: オーディオ ワークレット オペレーション用の特別な JS グローバル スコープ。WebAudio 専用のレンダリング スレッドで実行されます。BaseAudioContext には、1 つの AudioWorkletGlobalScope を設定できます。
  • AudioWorkletNode: オーディオ ワークレット オペレーション用に設計された AudioNode。BaseAudioContext からインスタンス化されます。BaseAudioContext には、ネイティブ AudioNode と同様に、複数の AudioWorkletNode を設定できます。
  • AudioWorkletProcessor: AudioWorkletNode に対応するコンポーネント。ユーザーが指定したコードによってオーディオ ストリームを処理する AudioWorkletNode の実際の機能。AudioWorkletNode の作成時に、AudioWorkletGlobalScope でインスタンス化されます。AudioWorkletNode には、対応する AudioWorkletProcessor を 1 つ含めることができます。

設計パターン

WebAssembly でオーディオ ワークレットを使用する

WebAssembly は AudioWorkletProcessor に最適なコンパニオンです。これら 2 つの機能を組み合わせることで、ウェブ上のオーディオ処理にさまざまなメリットをもたらしますが、最大の 2 つのメリットは、a)既存の C/C++ オーディオ処理コードを WebAudio エコシステムに取り込むこと、b)JS JIT コンパイルと音声処理コード内のガベージ コレクションのオーバーヘッドを回避することです。

前者は、オーディオ処理コードとライブラリにすでに投資しているデベロッパーにとって重要ですが、後者は、API のほぼすべてのユーザーにとって重要です。WebAudio の世界では、安定したオーディオ ストリームのタイミング バジェットが非常に厳しいため、44.1 Khz のサンプルレートでわずか 3 ミリ秒です。音声処理コードのわずかな中断でも、グリッチが発生することがあります。デベロッパーは、処理を高速化するためにコードを最適化すると同時に、生成される JS ガベージの量を最小限に抑える必要があります。WebAssembly を使用すると、両方の問題を同時に解決できます。WebAssembly は高速で、コードからガベージを生成しません。

次のセクションでは、WebAssembly をオーディオ ワークレットで使用する方法について説明します。付属のコード例については、こちらをご覧ください。Emscripten と WebAssembly の使用方法に関する基本的なチュートリアル(特に Emscripten グルーコード)については、こちらの記事をご覧ください。

設定

どれも素晴らしいことですが、適切にセットアップするには構造が必要です。最初に検討すべき設計上の質問は、WebAssembly モジュールをインスタンス化する方法と場所です。Emscripten のグルーコードを取得した後、モジュールをインスタンス化する方法は 2 つあります。

  1. audioContext.audioWorklet.addModule() を介して AudioWorkletGlobalScope にグルーコードを読み込み、WebAssembly モジュールをインスタンス化します。
  2. メイン スコープで WebAssembly モジュールをインスタンス化し、AudioWorkletNode のコンストラクタ オプションを介してモジュールを転送します。

この決定は、設計と設定に大きく依存しますが、WebAssembly モジュールは AudioWorkletGlobalScope 内で WebAssembly インスタンスを生成し、これが AudioWorkletProcessor インスタンス内のオーディオ処理カーネルになるというものです。

WebAssembly モジュールのインスタンス化パターン A: .addModule() 呼び出しの使用
WebAssembly モジュールのインスタンス化パターン A: .addModule() 呼び出しの使用

パターン A を正しく動作させるには、Emscripten が構成に適した WebAssembly グルーコードを生成するいくつかのオプションが必要です。

-s BINARYEN_ASYNC_COMPILATION=0 -s SINGLE_FILE=1 --post-js mycode.js

これらのオプションにより、AudioWorkletGlobalScope 内の WebAssembly モジュールの同期コンパイルが保証されます。また、AudioWorkletProcessor のクラス定義を mycode.js に追加して、モジュールの初期化後に読み込めるようにします。同期コンパイルを使用する主な理由は、audioWorklet.addModule() の Promise 解決が AudioWorkletGlobalScope の Promise の解決を待機しないためです。メインスレッドでの同期読み込みまたはコンパイルは、同じスレッド内の他のタスクをブロックするため、通常は推奨されません。ただし、コンパイルはメインスレッドで実行される AudioWorkletGlobalScope で行われるため、このルールをバイパスできます。(詳しくはこちらをご覧ください)。

WASM モジュールのインスタンス化パターン B: AudioWorkletNode コンストラクタのスレッド間転送の使用
WASM モジュールのインスタンス化パターン B: AudioWorkletNode コンストラクタのスレッド間転送の使用

パターン B は、非同期の負荷の大きい作業が必要な場合に役立ちます。メインスレッドを利用して、サーバーからグルーコードを取得し、モジュールをコンパイルします。次に、AudioWorkletNode のコンストラクタを介して WASM モジュールを転送します。AudioWorkletGlobalScope がオーディオ ストリームのレンダリングを開始した後にモジュールを動的に読み込む必要がある場合、このパターンはさらに理にかなっています。モジュールのサイズによっては、レンダリングの途中でモジュールをコンパイルすると、ストリームに不具合が生じる可能性があります。

WASM ヒープおよびオーディオ データ

WebAssembly コードは、専用の WASM ヒープ内に割り当てられたメモリでのみ機能します。これを利用するには、WASM ヒープとオーディオ データ配列の間でオーディオ データのクローンを作成する必要があります。このオペレーションは、サンプルコードの HeapAudioBuffer クラスで適切に処理できます。

WASM ヒープを簡単に使用するための HeapAudioBuffer クラス
WASM ヒープを簡単に使用するための HeapAudioBuffer クラス

WASM ヒープをオーディオ ワークレット システムに直接統合するための早期提案が検討中です。JS メモリと WASM ヒープ間のこのような冗長なデータのクローン作成をなくすことは自然なように思えますが、具体的な調整が必要です。

バッファサイズの不一致の処理

AudioWorkletNode と AudioWorkletProcessor のペアは、通常の AudioNode と同様に動作するように設計されています。AudioWorkletNode が他のコードとのインタラクションを処理し、AudioWorkletProcessor が内部の音声処理を行います。通常の AudioNode は一度に 128 フレームを処理するため、AudioWorkletProcessor がコア機能になるには同じ処理を行う必要があります。これはオーディオ ワークレット設計の利点の一つであり、AudioWorkletProcessor 内に内部バッファリングによる追加のレイテンシが導入されないようにするオーディオ ワークレット設計の利点の一つです。ただし、処理関数で 128 フレーム以外のバッファサイズが必要な場合は問題が発生する可能性があります。このような場合の一般的な解決策は、リングバッファ(循環バッファまたは FIFO)を使用することです。

次の図は、内部で 2 つのリングバッファを使用して、512 フレームの入出力を行う WASM 関数に対応する AudioWorkletProcessor を示しています。(ここでは 512 という数値が任意に選択されています)。

AudioWorkletProcessor の「process()」メソッド内で RingBuffer を使用する
AudioWorkletProcessor の「process()」メソッド内で RingBuffer を使用する

図のアルゴリズムは次のようになります。

  1. AudioWorkletProcessor は、Input から 128 フレームを Input RingBuffer に push します。
  2. 以下の手順は、Input RingBuffer のフレームが 512 以上である場合にのみ行ってください。
    1. Input RingBuffer から 512 フレームを pull します。
    2. 指定された WASM 機能で 512 フレームを処理します。
    3. 512 フレームを Output RingBuffer に push します。
  3. AudioWorkletProcessor は、Output RingBuffer から 128 フレームを pull して、Output を埋めます。

図に示すように、入力フレームは常に Input RingBuffer に蓄積され、バッファ内の最も古いフレーム ブロックを上書きすることでバッファ オーバーフローを処理します。これは、リアルタイム オーディオ アプリの場合、妥当な方法です。同様に、出力フレーム ブロックも常にシステムによって pull されます。Output RingBuffer でバッファ アンダーフロー(データ不足)が発生すると、無音が発生し、ストリームにグリッチが発生します。

このパターンは、ScriptProcessorNode(SPN)を AudioWorkletNode に置き換える場合に便利です。SPN では 256 ~ 16, 384 フレームのバッファサイズを選択できるため、SPN を AudioWorkletNode にドロップイン置換するのは困難です。リングバッファを使用すれば、適切な回避策を実現できます。音声レコーダーは、この設計上に構築できる優れた例です。

ただし、この設計ではバッファサイズの不一致を調整するだけで、特定のスクリプト コードを実行する時間がこれ以上ないということを理解することが重要です。コードがレンダリング量子のタイミング バジェット(44.1 kHz で約 3 ミリ秒)内にタスクを完了できない場合、後続のコールバック関数の開始タイミングに影響を及ぼし、最終的にグリッチが発生します。

WASM ヒープのメモリ管理のため、この設計と WebAssembly を組み合わせると複雑な作業になることがあります。執筆時点で、WASM ヒープに出入りするデータのクローンを作成する必要がありますが、HeapAudioBuffer クラスを使用すると、メモリ管理が少し簡単になります。ユーザー割り当てメモリを使用して冗長データ クローン作成を減らす方法については、今後説明します。

RingBuffer クラスはこちらにあります。

強力な WebAudio: オーディオ ワークレットと SharedArrayBuffer

この記事の最後の設計パターンは、複数の最先端の API(Audio Worklet、SharedArrayBufferAtomicsWorker)を 1 か所にまとめることです。この簡単な設定により、スムーズなユーザー エクスペリエンスを維持しながら、C/C++ で記述された既存のオーディオ ソフトウェアをウェブブラウザで実行できるようになります。

最後の設計パターン: オーディオ ワークレット、SharedArrayBuffer、Worker の概要。
最後の設計パターン: オーディオ ワークレット、SharedArrayBuffer、ワーカーの概要

この設計の最大のメリットは、DedicatedWorkerGlobalScope を音声処理専用にできることです。Chrome では、WorkerGlobalScope は WebAudio レンダリング スレッドよりも優先度の低いスレッドで実行されますが、AudioWorkletGlobalScope よりも多くの点で優れています。DedicatedWorkerGlobalScope は、スコープで使用可能な API サーフェスに関する制約が緩和されます。また、Worker API は数年前から存在しているため、Emscripten のサポートも強化されます。

SharedArrayBuffer は、この設計が効率的に機能するために重要な役割を果たします。Worker と AudioWorkletProcessor はどちらも非同期メッセージング(MessagePort)を備えていますが、メモリ割り当てとメッセージング レイテンシが繰り返されるため、リアルタイムの音声処理には最適ではありません。両スレッドからアクセス可能なメモリブロックを 事前に割り当てて 双方向のデータ転送を高速化します

ウェブ オーディオ API の純粋な視点から見ると、この設計は最適とは言えません。オーディオ ワークレットを単純な「オーディオ シンク」として使用し、Worker ですべてを行うためです。ただし、JavaScript で C/C++ プロジェクトを書き換えるコストは複雑で、不可能な場合があることを考慮すると、このようなプロジェクトでは、この設計が最も効率的な実装パスと言えます。

共有状態とアトミック

オーディオ データに共有メモリを使用する場合は、両側からのアクセスを慎重に調整する必要があります。アトミックにアクセス可能な状態を共有すると、このような問題を解決できます。この目的のために、非店舗型ビジネスを基盤とする Int32Array を利用できます。

同期メカニズム: SharedArrayBuffer とアトミック
同期メカニズム: SharedArrayBuffer とアトミック

同期メカニズム: SharedArrayBuffer とアトミック

State 配列の各フィールドは、共有バッファに関する重要な情報を表します。最も重要なフィールドは同期用のフィールド(REQUEST_RENDER)です。つまり、Worker は AudioWorkletProcessor によってこのフィールドがタッチされるのを待ち、復帰時に音声を処理します。このメカニズムは、SharedArrayBuffer(SAB)と併せて Atomics API によって実現されます。

2 つのスレッドの同期はかなり緩いことに注意してください。Worker.process() の開始は AudioWorkletProcessor.process() メソッドによってトリガーされますが、AudioWorkletProcessor は Worker.process() が完了するまで待機しません。これは仕様です。AudioWorkletProcessor はオーディオ コールバックによって駆動されるため、同期的にブロックされないようにする必要があります。最悪のシナリオでは、オーディオ ストリームが重複したり、ドロップアウトしたりすることがありますが、最終的にはレンダリング パフォーマンスが安定すると回復します。

設定と実行

上の図に示すように、この設計には、DedicatedWorkerGlobalScope(DWGS)、AudioWorkletGlobalScope(AWGS)、SharedArrayBuffer(メインスレッド)という複数のコンポーネントが配置されています。次の手順では、初期化フェーズでの処理内容について説明します。

初期化
  1. [Main] AudioWorkletNode コンストラクタが呼び出される。
    1. ワーカーを作成します。
    2. 関連する AudioWorkletProcessor が作成されます。
  2. [DWGS] ワーカーが 2 つの SharedArrayBuffer を作成します。(1 つは共有状態用、もう 1 つは音声データ用)
  3. [DWGS] ワーカーが SharedArrayBuffer 参照を AudioWorkletNode に送信します。
  4. [Main] AudioWorkletNode が SharedArrayBuffer 参照を AudioWorkletProcessor に送信します。
  5. [AWGS] AudioWorkletProcessor は、設定が完了したことを AudioWorkletNode に通知します。

初期化が完了すると、AudioWorkletProcessor.process() の呼び出しが開始されます。レンダリング ループの各反復処理で行われることは次のとおりです。

レンダリング ループ
SharedArrayBuffers を使用したマルチスレッド レンダリング
SharedArrayBuffers を使用したマルチスレッド レンダリング
  1. [AWGS] すべてのレンダリング量子に対して AudioWorkletProcessor.process(inputs, outputs) が呼び出されます。
    1. inputs入力 SAB に push されます。
    2. 出力 SAB で音声データを使用することで、outputs が入力されます。
    3. 状況に応じて新しいバッファ インデックスで States SAB を更新します。
    4. 出力 SAB がアンダーフローしきい値に近づくと、ウェイク ワーカーはより多くの音声データをレンダリングします。
  2. [DWGS] ワーカーは AudioWorkletProcessor.process() からの復帰信号を待機(スリープ)します。復帰後:
    1. States SAB からバッファ インデックスを取得します。
    2. 入力 SAB のデータを使用してプロセス関数を実行し、出力 SAB にデータを入力します。
    3. それに応じてバッファ インデックスで States SAB を更新します。
    4. スリープ状態になり、次の信号を待ちます。

サンプルコードはこちらにありますが、このデモを機能させるには SharedArrayBuffer 試験運用版フラグを有効にする必要があります。便宜上、コードは純粋な JS コードで記述されていますが、必要に応じて WebAssembly コードに置き換えることができます。このような場合は、メモリ管理を HeapAudioBuffer クラスでラップすることで、特に注意を払う必要があります。

おわりに

オーディオ ワークレットの最終的な目標は、Web Audio API を真に「拡張可能」にすることです。Web Audio API の残りの部分をオーディオ ワークレットで実装できるように、この設計には数年の労力が費やされました。その結果、設計の複雑さが増しており、これは予想外の課題となる可能性があります。

幸いなことに、このような複雑さが生じる理由は、単にデベロッパーを支援することにあります。AudioWorkletGlobalScope で WebAssembly を実行できるようになると、ウェブ上での高性能オーディオ処理の可能性が大幅に広がります。C または C++ で記述された大規模なオーディオ アプリケーションでは、SharedArrayBuffers と Worker でオーディオ ワークレットを使用すると、魅力的な選択肢になる可能性があります。

クレジット

この記事のドラフトにレビューを行い、洞察力に富んだフィードバックを提供してくれた Chris Wilson、Jason Miller、Joshua Bell、Raymond Toy に感謝します。