🔊

WASM×Web Worker×AudioWorkletで実現するリアルタイム音声生成

に公開

私が開発している音色共有サイトOPNShareに音色のプレビュー機能を追加しました.これは音色ページに表示されている鍵盤を押すと,その音高で音色が鳴る機能です.

https://x.com/RerrahKRynn/status/1981723920760750514

音色のプレビュー機能ではリアルタイムで音声波形の生成・再生を行っています.これはWebAssembly (WASM)やWeb Worker API,Web Audio APIを組み合わせて実現しています.今回はこの機能の仕組みを解説します.

構成

音色プレビュー機能は以下3つの要素が連携して動作します.

  1. Webページのコンポーネント (Next.js)
  2. 波形生成器 (Web Worker API)
  3. オーディオプレーヤー (Web Audio API)

また波形生成器とオーディオプレーヤーの間での生成した波形サンプルのやり取りは,リングバッファーを介して行います.

リアルタイムに処理を実行するため,これらの要素を3つのスレッドに担当させて並列で実行させます.

WASMとWeb Worker APIを使った波形生成

WASMとWeb Workerの役割

今回生成する音声はC++で書かれた音源チップのエミュレーターを駆動させて生成します.C++のコードをそのままではWebブラウザー上で実行することはできないので,Emscriptenを使ってC++のコードをWebブラウザーで利用できるWASMへコンパイルしておきます.

https://zenn.dev/rerrah/articles/310d84e933b4e1

WASMにしたとはいえ,音声サンプルの生成はコストがかかります.この処理をユーザーからのイベントを扱うNext.jsのメインスレッドやオーディオプレーヤーのスレッドで実行すると,画面のフリーズや音声が途切れ途切れになるバッファーアンダーランが生じる可能性があります.

そこで今回はWeb Workerを使い,WASMを独立したスレッドで動作するワーカー内で使用して,サンプル生成のコストを直接ほかの処理に影響を与えないようにします.

WASMのロードとワーカーの起動

Next.jsのpublicディレクトリーに静的リソースとしてWASMとそれを読み込むワーカーのスクリプトを準備します.ワーカーのスクリプトではimportScripts関数を使ってEmscriptenで生成されたWASMをロードするスクリプトを実行します.そしてこのスクリプトによってワーカーのグローバルスコープに定義されたWASMのロード関数を実行し,WASMのモジュールオブジェクトを取得します.

importScripts("/wasm.js");

const wasm = await createModule({
  // publicディレクトリーをルートとした
  // パスに修正しないと読み込めない
  locateFile: (path) => `/${path}`,
});

ワーカーの呼び出し元 (Next.js) では,ワーカーのコンストラクターでスクリプトのパスを指定してワーカーを起動させます.

// publicディレクトリーをルートとしたパスにする
const wasmWorker = new Worker("/worker.js");

ユーザーイベント

ユーザーイベントはユーザーが鍵盤を弾いたときなどに発生します.イベントの情報はMessageEventの仕組みを使い,Next.jsとワーカーとのスレッド間通信で受け渡しを行います.

Next.js側ではワーカーのpostMessageメソッドでイベント情報をワーカーに渡します.

wasmWorker.postMessage({
  type: "keyOn",
  id,
  pitch,
});

ワーカー側では暗黙的に定義されているonmessageハンドラーでイベントの受け取り時の操作を制御します.このハンドラーに受け取ったメッセージを処理する関数を登録しておきます.

self.onmessage = async ({ data }) => {
  switch (data.type) {
    case "keyOn":
      // 鍵盤を弾いたときの処理
      handleKeyOn(data);
      break;

    default:
      break;
  }
};

SharedArrayBufferによるリングバッファー

異なるスレッド間でデータ共有

音声をリアルタイムで生成・再生するには,この両方を途切れなく実行する必要があります.今回は前者をWeb Worker,後者をAudioWorkletに担当させ,2つのスレッドを並列で動作させています.

異なるスレッド間で音声サンプルをやり取りする方法には,双方のpostMessageメソッド,onmessageハンドラーを使ったメッセージ通信が考えられます.しかしこの方法の場合,メッセージを送ったスレッドではそのメッセージのレスポンスを待つ間,処理が止まってしまいます.

そこでサンプルを複数のスレッドでメモリを共有できるSharedArrayBufferで管理し,スレッドにバッファーの状態のみを監視させて処理を行うようにします.こうすることで,ほかのスレッドの処理の実行状態を気にせず,「待ち」の状態を作らずに済みます.

リングバッファーの構成

SharedArrayBufferでは,サンプルを貯めこむバッファー部と現在の読み込み先頭位置・書き込み先頭位置を記録するヘッダー部からなるリングバッファーを構築します.

リングバッファーの構成
SharedArrayBufferにおけるリングバッファーの構成

AudioWorkletでのサンプルのpull

AudioWorkletではprocessメソッドによってサンプルが必要になったとき,リングバッファーの読み込み先頭位置から必要分だけサンプルをバッファーを取得します.このとき,リングバッファーから取得可能サンプルの数は書き込み先頭位置と読み込み先頭位置の差分になります.そのあと読み込み先頭位置を新しい位置に更新します.

// this.controlはリングバッファーのヘッダー部
// this.leftBufferとthis.rightBufferはリングバッファーの各チャンネルのバッファー

process(_, outputs) {
  const [leftChannel, rightChannel] = outputs[0];

  // 読み書き位置から取得可能サンプル数を計算
  const readPos = Atomics.load(this.control, 0);
  const writePos = Atomics.load(this.control, 1);
  const nReadables = (writePos + this.bufferSize - readPos) % this.bufferSize;

  // 実際に取得するサンプル数を計算
  let nReads = Math.min(leftChannel.length, nReadables);

  // バッファーからサンプルを取得
  let pos = readPos;
  while (nReads > 0) {
    const chunk = Math.min(nReads, this.bufferSize - pos);
    const end = pos + chunk;

    leftChannel.set(this.leftBuffer.subarray(pos, end));
    rightChannel.set(this.rightBuffer.subarray(pos, end));

    nReads -= chunk;
    pos = end % this.bufferSize;
  }

  // 読み込み先頭位置を更新
  Atomics.store(this.control, 0, pos);

  return true;
}

ワーカーでのサンプルのpush

ワーカーではsetTimeoutを使って定期的にリングバッファーの書き込み可能数(サンプル残存数)を確認します.ここで書き込み可能数は書き込み先頭位置と読み込み先頭位置の差分から1だけ引いた値になります.書き込み可能数が一定の数を超えたとき,つまりサンプル残存数が一定値を下回ったとき,新たにサンプルを生成してバッファーにpushします.そのあと書き込み先頭位置を新しい位置に更新します.

// 定期的にバッファーをチェック
function process() {
  // メッセージ処理
  consumeMessageQueue();
  // バッファー確認&サンプルpush
  handleGenerate();
  setTimeout(process);
}

setTimeout(process);

// controlはリングバッファーのヘッダー部
// leftBufferとrightBufferはリングバッファーの各チャンネルのバッファー

function handleGenerate() {
  const readPos = Atomics.load(control, 0);
  const writePos = Atomics.load(control, 1);

  // 書き込み可能サンプル数を計算
  let nWritables = (readPos + bufferSize - 1 - writePos) % bufferSize;

  if (nWritables < fillThreshold) {
    return;
  }

  let pos = writePos;
  while (nWritables > 0) {
    const chunk = Math.min(nWritables, bufferSize - pos);
    const end = pos + chunk;

    wasm.generate(
      leftBuffer.subarray(pos, end),
      rightBuffer.subarray(pos, end),
      chunk
    );

    nWritables -= chunk;
    pos = end % bufferSize;
  }

  Atomics.store(control, 1, pos);
}

処理の不可分性

ここで注意が必要なのは読み込み先頭位置と書き込み先頭位置の読み書き処理です.複数のスレッドで値を共有するため,タイミングによってはデータ競合が発生する可能性があります.操作の割り込みを禁止(不可分性を保証)させるため,これらの操作はAtomics.loadAtomics.storeを介して行います.

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Atomics

まとめ

今回はWASMとWeb Worker,AudioWorkletを使ったリアルタイム音声生成・再生を行いました.WASMによってほかの言語で書かれた資産を利用しつつ,並列に実行させたWeb WorkerとAudioWorkletでSharedArrayBufferを用いてサンプルをやり取りして実現しました.

今回WASMによるサンプル生成とリアルタイム再生ができたので,今後ユーザーがリアルタイムで操作して音・曲を編集するアプリをWebの技術で作れそうです.今回の経験を活かして,また気が向いたときに簡単なシーケンサーなどを作ってみようと思います.

Discussion