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

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

この記事では、実際の設定で Audio Worklet を使用する方法と、その機能を最大限に活用するためのヒントについて説明します。コードサンプルとライブデモもぜひご覧ください。

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

本題に入る前に、こちらの投稿で紹介した音声ワークレット システムに関する用語と事実を簡単におさらいしておきましょう。

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

設計パターン

WebAssembly で Audio Worklet を使用する

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

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

次のセクションでは、WebAssembly を Audio Worklet で使用する方法について説明します。付属のコード例は こちらにあります。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 ヒープと Audio Worklet システムを直接統合する初期プロポーザルが検討されています。JS メモリと WASM ヒープ間のこの冗長なデータ クローンを排除することは自然なことに思えますが、具体的な詳細を検討する必要があります。

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

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

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

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

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

  1. AudioWorkletProcessor は、入力から 128 フレームを入力リングバッファにプッシュします。
  2. 次の手順は、入力 RingBuffer が 512 フレーム以上の場合のみ行います。
    1. 入力 RingBuffer から 512 フレームを引き出します。
    2. 指定された WASM 関数を使用して 512 フレームを処理します。
    3. 512 フレームを Output RingBuffer に push します。
  3. AudioWorkletProcessor は、出力 RingBuffer から 128 フレームを引き出して、出力を埋めます。

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

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

ただし、この設計はバッファサイズの不一致のみを調整し、特定のスクリプトコードの実行時間を長くするわけではないことに注意してください。コードがレンダリング クォンタムのタイミング バジェット(44.1 Khz で約 3 ミリ秒)内にタスクを完了できない場合、後続のコールバック関数の開始タイミングに影響し、最終的にグリッチが発生します。

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

RingBuffer クラスについては、こちらをご覧ください。

WebAudio Powerhouse: Audio Worklet と SharedArrayBuffer

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

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

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

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

Web Audio API の純粋主義者の視点から見ると、この設計は、Audio Worklet を単純な「オーディオ シンク」として使用し、Worker ですべてを行うため、最適ではないと見なされる可能性があります。ただし、C/C++ プロジェクトを JavaScript で書き換えるコストが非常に高く、場合によっては不可能になる可能性があることを考慮すると、このようなプロジェクトでは、この設計が最も効率的な実装パスになる可能性があります。

共有状態とアトミック

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

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

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

States 配列の各フィールドは、共有バッファに関する重要な情報を表します。最も重要なフィールドは同期フィールド(REQUEST_RENDER)です。Worker は、AudioWorkletProcessor によってこのフィールドがタップされるのを待機し、起動時に音声を処理します。Atomics API は、SharedArrayBuffer(SAB)とともに、このメカニズムを可能にします。

2 つのスレッドの同期は比較的緩やかです。Worker.process() の開始は AudioWorkletProcessor.process() メソッドによってトリガーされますが、AudioWorkletProcessor は Worker.process() が完了するまで待機しません。これは設計上のものです。AudioWorkletProcessor は音声コールバックによって駆動されるため、同期的にブロックされないようにする必要があります。最悪の場合、音声ストリームが重複したり途切れたりする可能性がありますが、レンダリング パフォーマンスが安定すると最終的には復元されます。

設定と実行

上の図に示すように、この設計には、DedicatedWorkerGlobalScope(DWGS)、AudioWorkletGlobalScope(AWGS)、SharedArrayBuffer、メインスレッドの複数のコンポーネントがあります。次の手順では、初期化フェーズで行うべきことを説明します。

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

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

レンダリング ループ
SharedArrayBuffer を使用したマルチスレッド レンダリング
SharedArrayBuffers を使用したマルチスレッド レンダリング
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) がレンダリング クォンタムごとに呼び出されます。
    1. inputs入力 SAB にプッシュされます。
    2. outputs は、出力 SAB で音声データを消費することで入力されます。
    3. 新しいバッファ インデックスで States SAB を更新します。
    4. 出力 SAB がアンダーフローしきい値に近づくと、ワーカーを起動してより多くのオーディオ データをレンダリングします。
  2. [DWGS] ワーカーは AudioWorkletProcessor.process() からのウェイクアップ シグナルを待機(スリープ)します。起動時:
    1. States SAB からバッファ インデックスを取得します。
    2. 入力 SAB のデータを使用してプロセス関数を実行し、出力 SAB に入力します。
    3. 必要に応じてバッファ インデックスで States SAB を更新します。
    4. スリープ状態になり、次のシグナルを待機します。

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

まとめ

Audio Worklet の最終的な目標は、Web Audio API を真に「拡張可能」にすることです。残りの Web Audio API を Audio Worklet で実装できるように、数年にわたる設計作業が行われました。一方で、設計の複雑さが増し、予期しない問題が発生する可能性があります。

幸い、このような複雑さは、デベロッパーの能力を高めるためのものです。AudioWorkletGlobalScope で WebAssembly を実行できるようになったことで、ウェブでの高パフォーマンスな音声処理の可能性が大きく広がりました。C または C++ で記述された大規模なオーディオ アプリケーションの場合、SharedArrayBuffers と Worker を使用した Audio Worklet は検討に値するオプションです。

クレジット

この記事の下書きをレビューして、有益なフィードバックを提供してくれた Chris Wilson、Jason Miller、Joshua Bell、Raymond Toy の各氏に感謝します。