🎹

Webブラウザーで動くシンセサイザーを作る (AudioWorklet)

2024/08/10に公開

Webブラウザー上で音声処理を行うAPIにWeb Audio APIがあります.Web Audio APIでは,音源,エフェクト,入出力を行うノードを接続していくことでオーディオグラフを作成し,音声処理を表現します.Web Audio APIを用いることで,Webの技術だけで簡単なシンセサイザーを作成することができます.

今回はWeb Audio APIで用意されているAudioWorkletを使用して,リアルタイムで波形を生成する簡単なシンセサイザーを作成しました.

作成したWebアプリ

今回作成したWebアプリです.

https://rerrahkr.github.io/audioworklet-keyboard/

Enterキーを押すとシンセサイザーが起動します.シンセサイザーといっても,キーボードのZ,S,X...を押下すると,キーに対応してC,C♯,D...の音高の矩形波が鳴るだけの簡単なものです.おまけとして,ゲインスライダーで出力の音量を調節できるようにしています.

GitHubのリポジトリはこちらです.

https://github.com/rerrahkr/audioworklet-keyboard

正直この程度の機能ならOscillatorNodeGainNodeを使えば良いのですが,今回は自分の勉強のためAudioWorkletの1ノード内で矩形波の生成とゲイン調整を同時に行いました.

実装にあたって,Mozilla公式のWeb Audio APIのリファレンスを参考にしました.

https://developer.mozilla.org/ja/docs/Web/API/Web_Audio_API/Using_Web_Audio_API

オーディオグラフの設定

先述したように,Web Audio APIでは1つのオーディオ処理をノードとして定義し,ノードを接続していくことでオーディオ処理の流れをグラフの形で表現します.

例えば「正弦波を生成してディレイエフェクトをかけてから出力する」という処理は以下のコードで実装できます.

  // コンテクストの作成
const context = new AudioContext();

// オーディオノードをコンテクスト内に作成
const sineNode = new OscillatorNode(context, { type: "sine" });
const delayNode = new DelayNode(context, { delayTime: .2 });

// オーディオの流れが[sineNode] --> [delayNode] --> [出力]になるようノードを接続
sineNode.connect(delayNode).connect(context.destination);

// オーディオ処理を開始する
await context.resume();

はじめにAudioContextでオーディオ処理のコンテクストを作成します.次にオーディオ処理を行うノードをオーディオコンテクスト内に作成します.そしてノードのconnectメソッドを使用して,ノードの出力を引数で与えたノードの入力として渡すようノードを接続します.このようにしてオーディオ処理の流れを定義していきます.

オーディオグラフの設定が完了してから,オーディオコンテクストでオーディオ処理を開始させます.DOMノードのclickイベントなどによってオーディオコンテクストが作成された際には,自動的にオーディオ処理が開始されます.しかしユーザーアクションなしでオーディオコンテクストを作成すると,オーディオ処理は停止したままになります.この場合,オーディオコンテクストのresumeメソッドを実行して明示的に処理を開始させる必要があります.

AudioWorklet

Web Audio APIにはWeb Workerを利用して音声処理を行うAudioWorkletという機能があります.AudioWorkletを利用すると,音声処理を記述したスクリプトをメインスレッドとは異なるワーカースレッドで実行します.これにより計算コストのかかる音声処理をメインスレッドで実行されるUI描画処理に影響を及ぼさず実行することができます.

https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Using_AudioWorklet

プロセッサーの実装

まずは音声処理を行うプロセッサーを実装します.Web Workerで実行するため,プロセッサーはメインスレッドで実行されるコードとは別のスクリプトファイル内(例えば "my-processor.ts")に記述する必要があります.

プロセッサーはAudioWorkletProcessorを継承したクラスとして定義します.そしてこのクラスのprocessメソッドに音声処理を記述していきます.今回はMyProcessorというクラスを作成しました.

my-processor.ts
class MyProcessor extends AudioWorkletProcessor {
  processor(
    inputs: Float32Array[][],
    outputs: Float32Array[][],
    parameters: any
  ): boolean {
    // 音声処理を記述

    return true;
  }
}

processはオーディオバッファーの生成が必要になった際に呼び出されるメソッドで,引数に1フレーム分の入力バッファーと出力バッファー,パラメーターのオブジェクトが渡されてきます.

入力バッファー inputs と出力バッファー outputs は3次元の配列です.1次元目は入力デバイスを,2次元目は入力のチャンネルを,3次元目はサンプルを表します.なお3次元目の大きさ(フレームサイズ; 1フレーム中のサンプル数)は128に固定されています.

processの戻り値は音声処理を継続するかを示す真偽値です.もし処理の途中でエラーが発生し,今後の処理を打ち切りたい場合はfalseを返します.

my-processor.ts
// 出力1チャンネル分だけ使う
const channel = outputs[0][0];

// サンプル生成
for (let i = 0; i < channel.length; i++) {
  channel[i] = generateSample();
}

// 他の出力チャンネルにサンプルをコピーする
for (let i = 1; i < outputs[0].length; i++) {
  outputs[0][i] = new Float32Array(channel);
}
    
return true;

最後にAudioWorkletのスクリプトのグローバルスコープに定義されているregisterProcessor関数を使用して,AudioWorkletに作成したプロセッサーを登録します.

my-processor.ts
// "my-processor" という名前で登録
registerProcessor("my-processor", MyProcessor);

AudioWorkletをオーディオグラフに接続する

メインスレッド側でAudioWorkletに登録したプロセッサーを使用するには,まずAudioContext.audioWorklet.addModuleメソッドでプロセッサーを定義したスクリプトをオーディオコンテクストで読み込みます.そしてオーディオコンテキストに紐づいたAudioWorkletNodeを作成します.このときコンストラクターへAudioWorkletに登録したプロセッサー名を与えることで,メインスレッド側からプロセッサーをオーディオノードとして扱うことができます.

hooks.tsx
// プロセッサーが定義されているスクリプトファイルへのパス
processorUrl = "my-processor.ts";

// コンテクストにスクリプトを読み込ませ,登録されているプロセッサーをノードとして取得する
await context.audioWorklet.addModule(processorUrl);
const processorNode = new AudioWorkletNode(context, "my-processor");

// 出力に接続する
processorNode.connect(context.destination);

パラメーターによる動作制御

AudioWorkletではメインスレッドとワーカースレッドの間でデータをやり取りする仕組みとして,AudioParamを用いる方法とMessagePortを用いる方法があります.それぞれできることが異なるため,データの性質に応じてどちらを選択するか決めましょう.

なお,このほかSharedArrayBufferを使用してメインスレッドとワーカースレッドの間で数値データを共有する方法もありますが,クロスオリジン分離を満たす必要があり少し複雑なため,今回の記事では省略します.

AudioParam

シンセサイザーの音色やエンベロープのパラメーターなど,ある範囲内の数値データをパラメーターとしてプロセッサーに送りたい場合は,AudioParamが有用です.

今回はゲインスライダーの値をプロセッサーに与えるのに使用しました.

パラメーターの定義

パラメーターはプロセッサーにAudioParamの配列を返すstatic getterメソッドparameterDescriptorsを実装することで定義します.

my-processor.ts
static get parameterDescriptors(): AudioParamDescriptor[] {
  return [
    {
      name: "gain",
      defaultValue: 0.7,
      minValue: 0,
      maxValue: 1,
      automationRate: "a-rate",
    },
  ];
}

パラメーターには名前 (name),デフォルト値 (defaultValue),最小値 (minValue),最大値 (maxValue),オートメーションレート (automationRate)を設定できます.

オートメーションレートについて

オートメーションレートはAudioParamのパラメーターの種類を設定します.以下2つの値を選択できます.

  • k-rate: processで渡される1フレームのデータに対して1つの値を割り当てます.
  • a-rate: processで渡される1フレーム中の各サンプルに対して1つの値を割り当てます.しかし,値の渡し方によっては1フレームに対して1つの値を割り当てることもあります.

a-rateパラメーターはAudioParam.linearRampToValueAtTimeなどによって値を時間連続的に変化させた場合はサンプル単位で値を割り当てますが,何も値を変化させなかったときは1フレームに対して1つの値を割り当てます.そのため後述するprocess内の処理では,パラメーター値の配列の長さを事前確認することが重要です.

パラメーターの送信

メインスレッドでスライダーなどによってパラメーターの値を変更させる場合は,AudioWorkletNode.parameters.getメソッドにパラメーター名を渡して取得したAudioParamを操作します.

取得したAudioParamのsetValueAtTimeメソッドやlinearRampToValueAtTimeメソッドなどを使用することで,オーディオプロセッサーにパラメーターの値の変化を送信します.

hooks.tsx
const gainParam = audioWorkletNode.parameters.get("gain");
// ゲインを即座に0.5に変更する
gainParam.setValueAtTime(0.5, context.currentTime);

パラメーターの取得

設定したパラメーターはプロセッサーのprocessメソッドの第3引数parametersオブジェクトで渡されます.このオブジェクトは先ほど定義したパラメーター名のプロパティを持ち,そのプロパティにアクセスすることでパラメーターの値の配列を取得できます.ここで受け取ったパラメーターの値をもとに入出力のデータを変化させていきます.

my-processor.ts
process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: any): boolean {
  // パラメーター値の配列の長さが1の時はフレームサイズになるよう値を繰り返す
  const gainSequence =
    parameters.gain.length === 1
      ? new Float32Array(output[0][0].length).fill(parameters.gain[0])
      : parameters.gain;

  // ゲインエフェクトをサンプル単位でかける
  for (let i = 0; i < output[0][0].length; i++) {
    output[0][0][i] = createSample(i) * gainSequence[i];
  }

  return true;
}

MessagePort

音名など数値以外のデータをプロセッサーに渡したい場合は,MessagePort機能を利用します.この機能はプロセッサーの外部からのデータを受信するだけでなく,プロセッサーが外部にデータを送信することも可能です.

https://developer.chrome.com/blog/audio-worklet?hl=ja#bi-directional_communication_with_messageport

今回はメインスレッドからプロセッサーに鍵盤の打鍵情報を送信するのに使用しました.

メッセージの送信

AudioWorkletNode.postMessageメソッドの引数に送信したいデータを含んだメッセージオブジェクトを渡してプロセッサーに情報を送ります.ここでプロセッサーが何の情報が送られてきたかを判別できるように,メッセージにはtypeプロパティを定義してメッセージの種類を記述しておきます.

hooks.tsx
// 「"C"を打鍵した」というメッセージをプロセッサーに送る
audioWorkletNode.port.postMessage({
  type: "keyOn",
  pitch: "C",
});

メッセージの受信

プロセッサーのport.onmessageプロパティには,メッセージを受信したときに実行するコールバック関数を設定できます.プロセッサーのコンストラクター内で,受信時のコールバック処理を設定します.

my-processor.ts
constructor() {
  super();

  this.port.onmessage = (e: MessageEvent) => {
    switch (e.data.type) {
      case "keyOn":
        this.pitch = e.data.pitch;
        break;

      // その他処理...

      default:
        break;
    }
  };
}

受信したメッセージに応じてプロセッサー内で用意したプロパティを更新するなどし,processでそれらの値を用いて音声処理を制御します.

まとめ

今回はAudioWorkletを使用して,ワーカースレッドでサンプルの生成とオーディオエフェクトの実行を行うシンセサイザーを作成しました.プロセッサーでフレーム単位の音声処理を行うと方法は,JUCEを用いたオーディオプラグインの製作と同様の方法であったので個人的には理解しやすかったです.

またプロセッサー内の音声処理をWASMで実装することで,過去の資産を流用することや処理の高速化を図ることも可能です.今後はC++などで実装された音源エミュレーターをEmscriptenなどでコンパイルし,AudioWorklet内で実行してWeb上で鳴らすことを試したいです.

Discussion