🔊

Web Audio API + WASMでリアルタイムノイズ抑制を実装する仕組み

に公開

はじめに

ブラウザベースの通話アプリで、WASMを使ったリアルタイムノイズ抑制を実装する機会がありました。

TwilioやDaily.coなどのWebRTC SDKと組み合わせて使う際、Web Audio APIの仕組みを理解する必要がありましたが、日本語で全体像を解説した記事が見つからず苦労しました。

本記事では、マイクから入力された音声がどのようにブラウザで処理され、WebRTCで送信されるかを、概念から実装まで解説します。

前提条件と制約

本記事の内容を実装する際は、以下の点に注意してください。

項目 内容
ブラウザサポート Chrome 66+, Firefox 76+, Edge 79+, Safari 14.1+
HTTPヘッダー SharedArrayBuffer使用時はCOOP/COEPヘッダーが必要
CPU使用率目安 RNNoiseで5-10%、DeepFilterNet3で10-20%程度(環境依存)
最小レイテンシ 約5-10ms(バッファサイズ依存)

全体像:音声の流れ

まず、音声がどこからどこへ流れるかを俯瞰します。

┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   マイク    │ ──→ │  ブラウザ   │ ──→ │ ノイズ除去  │ ──→ │  WebRTC     │
│ (ハードウェア) │     │ (getUserMedia) │     │ (WASM処理)  │     │ (Twilio等)  │
└─────────────┘     └─────────────┘     └─────────────┘     └─────────────┘
    物理的な音          デジタル化           加工          ネットワーク送信

各ステップで何が起きているかを順番に見ていきましょう。


Step 1:マイクからブラウザへ(getUserMedia)

アナログからデジタルへ

マイクに入ったアナログ音声(空気の振動)は、PCのオーディオインターフェースでデジタルデータに変換されます。

マイクが拾う音

[アナログ波形] ~~~~~

サンプリング(1秒間に48,000回測定)

[数値の列] 0.12, -0.34, 0.56, -0.23, ...

この「1秒間に何回測定するか」がサンプルレートです。48,000Hz(48kHz)なら1秒間に48,000個の数値が生成されます。

getUserMedia API

ブラウザでマイクにアクセスするにはgetUserMediaを使います。

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

返り値のMediaStreamは、音声データの流れを表すオブジェクトです。蛇口から水が流れ続けるように、音声データが流れ続けます。

┌──────────────────────────────────────┐
│  MediaStream                         │
│                                      │
│  ┌────────────────────────────────┐ │
│  │  AudioTrack                    │ │
│  │  [0.12, -0.34, 0.56, ...]     │→│→→ 音声データが流れ続ける
│  └────────────────────────────────┘ │
└──────────────────────────────────────┘

Step 2:音声処理の工場(AudioContext)

AudioContextとは

AudioContextは、音声処理を行う作業場(工場)です。すべての音声処理はこの中で行われます。

const audioContext = new AudioContext({ sampleRate: 48000 });

工場には様々な部品(AudioNode)があり、それらを配管(connect)で繋いで音声を流します。

┌────────────────────────────────────────────────────────────────────┐
│                     AudioContext(工場)                            │
│                                                                    │
│   ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐       │
│   │ Source  │───→│ Worklet │───→│  Gain   │───→│  Dest   │       │
│   │ (入口)  │    │ (加工機) │    │(音量調整)│    │ (出口)  │       │
│   └─────────┘    └─────────┘    └─────────┘    └─────────┘       │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘

主要なAudioNode

ノード 役割 作成方法
MediaStreamSource マイク音声を工場に入れる入口 createMediaStreamSource(stream)
AudioWorkletNode カスタム処理(WASM実行など) new AudioWorkletNode(ctx, name)
GainNode 音量を調整 createGain()
MediaStreamDestination 処理済み音声を取り出す出口 createMediaStreamDestination()

なぜGainNodeが必要か

ノイズ抑制アルゴリズムは、ノイズを除去する過程で音声全体の音量が下がることがあります。GainNodeを挟むことで、処理後の音量を適切なレベルに補正できます。

const gainNode = audioContext.createGain();
gainNode.gain.value = 1.2; // 20%音量を上げる

パイプラインの構築

ノード同士をconnect()で繋ぎます。水道管を繋げるイメージです。

// 部品を作成
const source = audioContext.createMediaStreamSource(stream);
const workletNode = new AudioWorkletNode(audioContext, 'noise-processor');
const gainNode = audioContext.createGain();
const destination = audioContext.createMediaStreamDestination();

// 配管で繋げる
source.connect(workletNode);
workletNode.connect(gainNode);
gainNode.connect(destination);

// 処理済みの音声ストリームを取得
const processedStream = destination.stream;
配管接続のイメージ:

  [蛇口] ──管── [浄水器] ──管── [バルブ] ──管── [出口]
   Source      Worklet       Gain        Destination

  マイク音声が流れる → ノイズ除去 → 音量調整 → 綺麗な音声が出る

Step 3:なぜAudioWorkletが必要か

メインスレッドの問題

ブラウザのJavaScriptは通常メインスレッドで動きます。UIの描画もここで行われるため、重い処理があるとUIがカクつきます。

メインスレッドで音声処理すると...

[UI描画] ─ [音声処理] ─ [UI描画] ─ [音声処理] ─ ...

          UIがブロックされる

        画面がカクつく&音が途切れる

AudioWorkletの仕組み

AudioWorkletは専用のスレッド(別の作業員)で動きます。メインスレッドと独立しているため、UIに影響を与えません。

┌─────────────────────────────────────────────────────────────────┐
│ ブラウザ                                                         │
│                                                                 │
│  ┌─────────────────────┐      ┌─────────────────────────────┐  │
│  │  Main Thread        │      │  AudioWorklet Thread        │  │
│  │                     │      │                             │  │
│  │  - UI描画           │      │  - 音声処理専門             │  │
│  │  - ユーザー操作     │      │  - 128サンプルごとに処理    │  │
│  │  - 設定変更         │      │  - WASMを実行               │  │
│  │                     │      │                             │  │
│  │  互いに干渉しない   │      │  リアルタイムで安定動作     │  │
│  └─────────────────────┘      └─────────────────────────────┘  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

128サンプル単位の処理

AudioWorkletは128サンプル(render quantum)ごとにprocess()メソッドが呼ばれます。48kHzの場合、128 ÷ 48000 ≒ 2.67msに相当します。

// noise-processor.js(AudioWorklet Thread内で動く)
class NoiseProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
    this.wasmModule = null;

    // メインスレッドからWASMモジュールを受け取る
    this.port.onmessage = (event) => {
      if (event.data.type === 'init') {
        this.initWasm(event.data.wasmBinary);
      }
    };
  }

  async initWasm(wasmBinary) {
    const module = await WebAssembly.compile(wasmBinary);
    const instance = await WebAssembly.instantiate(module);
    this.wasmModule = instance.exports;
  }

  process(inputs, outputs, parameters) {
    const input = inputs[0]?.[0];
    const output = outputs[0]?.[0];

    // 入力がない場合は何もしない
    if (!input || !output) {
      return true;
    }

    // WASMが初期化されていない場合はパススルー
    if (!this.wasmModule) {
      output.set(input);
      return true;
    }

    // WASMでノイズ除去
    const cleaned = this.wasmModule.denoise(input);
    output.set(cleaned);

    return true; // 処理を継続
  }
}

// プロセッサを登録(これがないと動作しない)
registerProcessor('noise-processor', NoiseProcessor);
処理の流れ:

[128サンプル] → process() → [ノイズ除去済み]
[128サンプル] → process() → [ノイズ除去済み]
[128サンプル] → process() → [ノイズ除去済み]
...

Step 4:WASMの役割

なぜWASMを使うか

RNNoiseやDeepFilterNetのようなノイズ抑制アルゴリズムは、大量の数値計算を行います。

  • 畳み込み演算
  • FFT(高速フーリエ変換)
  • ニューラルネットワークの推論

JavaScriptのV8エンジンはJITコンパイラにより高速ですが、リアルタイム処理では「常に一定時間内に完了する」保証が困難です。GCによる一時停止や、最適化の不安定さが問題になります。

WASMは以下の点で有利です:

  • 予測可能な実行時間(GCがない)
  • メモリ効率(手動メモリ管理)
  • 既存ライブラリの活用(C++/Rustで書かれた学術実装を再利用)

WASMとは

**WebAssembly(WASM)**は、C/C++/Rustで書いたコードをブラウザで実行する技術です。

C++/Rustのコード
    ↓ コンパイル(Emscripten等)
WASMバイナリ(.wasm)
    ↓ ブラウザで読み込み
予測可能な速度で実行

ノイズ抑制ライブラリの多くは、学術研究でC++/Pythonで開発され、それをWASMにコンパイルしてブラウザで使えるようにしています。

AudioWorklet内でのWASMロード

AudioWorkletスレッドは独立した環境のため、WASMのロードには工夫が必要です。

// main.js(メインスレッド)
async function setupNoiseProcessor(audioContext) {
  // 1. AudioWorkletモジュールを登録
  await audioContext.audioWorklet.addModule('/noise-processor.js');

  // 2. WASMバイナリをfetch
  const wasmResponse = await fetch('/rnnoise.wasm');
  const wasmBinary = await wasmResponse.arrayBuffer();

  // 3. AudioWorkletNodeを作成
  const workletNode = new AudioWorkletNode(audioContext, 'noise-processor');

  // 4. WASMバイナリをWorkletスレッドに送信
  workletNode.port.postMessage({
    type: 'init',
    wasmBinary: wasmBinary
  });

  return workletNode;
}
┌─────────────────────────────────────────────────────────────┐
│  AudioWorkletProcessor                                       │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  process(inputs, outputs)                            │   │
│  │                                                     │   │
│  │    音声データ(128サンプル)                         │   │
│  │           ↓                                         │   │
│  │    ┌─────────────────────────────┐                 │   │
│  │    │  WASM Module                │                 │   │
│  │    │  (RNNoise等)                │                 │   │
│  │    │                             │                 │   │
│  │    │  denoise(input) → output    │                 │   │
│  │    └─────────────────────────────┘                 │   │
│  │           ↓                                         │   │
│  │    ノイズ除去済み(128サンプル)                     │   │
│  │                                                     │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Step 5:モンキーパッチでWebRTC SDKと統合

課題:SDKは内部でgetUserMediaを呼ぶ

TwilioやDaily.coなどのWebRTC SDKは、内部でgetUserMediaを呼んでマイク音声を取得します。SDKのコードを変更せずに、ノイズ抑制を挟みたい場合どうするか?

解決策:モンキーパッチ

モンキーパッチとは、既存の関数を別の関数に置き換える手法です。

// オリジナルを保存
const originalGetUserMedia = navigator.mediaDevices.getUserMedia.bind(
  navigator.mediaDevices
);

// 置き換え
navigator.mediaDevices.getUserMedia = async (constraints) => {
  // 音声以外のリクエストはそのまま通す
  if (!constraints?.audio) {
    return originalGetUserMedia(constraints);
  }

  try {
    // 1. オリジナルでマイク音声を取得
    const stream = await originalGetUserMedia(constraints);

    // 2. ノイズ抑制パイプラインを通す
    const processedStream = await applyNoiseSuppression(stream);

    // 3. 処理済みを返す
    return processedStream;
  } catch (error) {
    // エラーはそのまま伝播させる
    console.error('Noise suppression failed:', error);
    throw error;
  }
};

動作の流れ

┌─────────────────────────────────────────────────────────────────┐
│  WebRTC SDK(Twilio等)                                          │
│                                                                 │
│  1. 通話開始                                                    │
│  2. navigator.mediaDevices.getUserMedia() を呼ぶ               │
│                                                                 │
└───────────────────────────────┬─────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│  モンキーパッチ(横取り)                                        │
│                                                                 │
│  1. 本物のgetUserMediaでマイク音声を取得                        │
│  2. AudioContext + AudioWorklet でノイズ除去                    │
│  3. destination.stream を返す                                   │
│                                                                 │
└───────────────────────────────┬─────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│  WebRTC SDK(続き)                                              │
│                                                                 │
│  3. 返ってきたstream(実はノイズ除去済み)をWebRTCで送信        │
│  4. 相手に綺麗な音声が届く                                      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

SDKからは普通にgetUserMediaを呼んでいるように見えますが、実際にはノイズ除去済みの音声が返されます。


実装時のハマりどころ

1. AudioContextのsuspended状態

ブラウザのオートプレイポリシーにより、ユーザー操作なしで作成したAudioContextsuspended(一時停止)状態になります。

const ctx = new AudioContext();
console.log(ctx.state); // "suspended"

// ユーザー操作後にresumeが必要
await ctx.resume();
console.log(ctx.state); // "running"

対策:getUserMediaが呼ばれた時点でユーザー操作があったと見なせるので、そこでresume()を呼びます。

if (audioContext.state === "suspended") {
  await audioContext.resume();
}

2. AudioWorkletの登録は1回だけ

AudioWorkletのプロセッサ名は、同じAudioContext内で1度しか登録できません。

// 1回目:成功
await audioContext.audioWorklet.addModule('/processor.js');

// 2回目:エラー
await audioContext.audioWorklet.addModule('/processor.js');
// → "AudioWorkletProcessor with name 'xxx' already registered"

**対策:**初期化済みフラグで管理するか、ライブラリ側で登録済みチェックを行います。

let isWorkletRegistered = false;

async function ensureWorkletRegistered(audioContext) {
  if (isWorkletRegistered) return;

  await audioContext.audioWorklet.addModule('/processor.js');
  isWorkletRegistered = true;
}

3. AudioWorkletNodeの作成タイミング

AudioWorkletNodeinit()で事前作成してキャッシュしようとすると、問題が起きることがあります。

// NG: init()で事前作成
async init(ctx) {
  this.workletNode = await createAudioWorkletNode(ctx);
  // この時点ではまだ音声ソースがない
  // → workletNodeが「空の状態」で作られる
}

async process(stream) {
  // 後から接続しても動かない場合がある
  source.connect(this.workletNode);
}

対策:AudioWorkletNodeは音声ソースが接続されるタイミング(process()内)で作成します。

// OK: process()で毎回作成
async process(stream, ctx) {
  const workletNode = await createAudioWorkletNode(ctx);
  const source = ctx.createMediaStreamSource(stream);
  source.connect(workletNode);
  // ...
}

4. サンプルレートの不一致

ノイズ抑制アルゴリズムによって要求されるサンプルレートが異なります。

アルゴリズム 対応サンプルレート 備考
DeepFilterNet3 48kHz 高品質
RNNoise 48kHz 軽量
DTLN 16kHz / 48kHz 実装により異なる

ブラウザのデフォルトは通常48kHzなので、16kHz固定のライブラリを使う場合はリサンプリングが必要です。

5. リソースのクリーンアップ

長時間動作するアプリでは、リソース解放を忘れるとメモリリークの原因になります。

class NoiseSuppressionPipeline {
  private audioContext: AudioContext | null = null;
  private source: MediaStreamAudioSourceNode | null = null;
  private workletNode: AudioWorkletNode | null = null;
  private originalStream: MediaStream | null = null;

  async cleanup() {
    // ノードの切断
    this.source?.disconnect();
    this.workletNode?.disconnect();

    // AudioContextを閉じる
    if (this.audioContext?.state !== 'closed') {
      await this.audioContext?.close();
    }

    // オリジナルのMediaStreamを停止
    this.originalStream?.getTracks().forEach(track => track.stop());

    // 参照をクリア
    this.audioContext = null;
    this.source = null;
    this.workletNode = null;
    this.originalStream = null;
  }
}

6. エラーハンドリング

本番環境では、フォールバック処理が重要です。

async function applyNoiseSuppression(stream: MediaStream): Promise<MediaStream> {
  try {
    // ノイズ抑制を適用
    const processedStream = await createNoiseSuppressionPipeline(stream);
    return processedStream;
  } catch (error) {
    console.warn('Noise suppression failed, falling back to original stream:', error);

    // フォールバック:元のストリームをそのまま返す
    return stream;
  }
}

代替アプローチ

本記事で解説したAudioWorklet + WASMの方法以外にも、いくつかの選択肢があります。

ブラウザ内蔵のノイズ抑制

Chrome/Edgeでは、getUserMediaのconstraintsでノイズ抑制を有効にできます。

const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    noiseSuppression: true,
    echoCancellation: true,
    autoGainControl: true
  }
});

メリット: 実装不要、CPU負荷が低い
デメリット: 品質がブラウザ依存、カスタマイズ不可

サーバーサイド処理

音声をサーバーに送り、ノイズ抑制後に返す方法。

メリット: クライアント負荷なし、高性能なモデルが使える
デメリット: レイテンシ増加(往復時間)、サーバーコスト

WebRTC Insertable Streams

より新しいAPIで、WebRTCのパイプラインに直接処理を挿入できます。

const sender = peerConnection.getSenders()[0];
const streams = sender.createEncodedStreams();
// streams.readable と streams.writable で変換

メリット: AudioContext不要、よりシンプル
デメリット: ブラウザサポートが限定的(Chrome 86+)


まとめ

音声処理の全体フロー

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  ┌─────────┐                                                           │
│  │ マイク   │ アナログ音声                                              │
│  └────┬────┘                                                           │
│       ↓                                                                 │
│  ┌─────────────────────┐                                               │
│  │ オーディオIF        │ A/D変換(サンプリング)                        │
│  └──────────┬──────────┘                                               │
│             ↓                                                           │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ ブラウザ                                                         │   │
│  │                                                                 │   │
│  │  navigator.mediaDevices.getUserMedia()                          │   │
│  │       ↓ (モンキーパッチで横取り)                                 │   │
│  │  ┌─────────────────────────────────────────────────────────┐   │   │
│  │  │ AudioContext                                             │   │   │
│  │  │                                                         │   │   │
│  │  │  Source ──→ AudioWorkletNode ──→ GainNode ──→ Dest     │   │   │
│  │  │              (WASM実行)          (音量補正)              │   │   │
│  │  │                                                         │   │   │
│  │  └─────────────────────────────────────────────────────────┘   │   │
│  │       ↓                                                         │   │
│  │  destination.stream(ノイズ除去済み)                           │   │
│  │       ↓                                                         │   │
│  │  WebRTC SDK(Twilio等)が送信                                   │   │
│  │                                                                 │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│             ↓                                                           │
│  ┌─────────────────────┐                                               │
│  │ インターネット       │ WebRTC(UDP)                                 │
│  └──────────┬──────────┘                                               │
│             ↓                                                           │
│  ┌─────────────────────┐                                               │
│  │ 相手の電話/ブラウザ  │ 綺麗な音声が届く                              │
│  └─────────────────────┘                                               │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

用語まとめ

用語 説明
MediaStream 音声/映像データの流れを表すオブジェクト
AudioContext 音声処理の作業場。すべてのノードはここに属する
AudioNode 音声処理の部品(Source, Gain, Destination等)
AudioWorklet 専用スレッドで動く音声処理の仕組み
AudioWorkletNode AudioWorkletをAudioContextに繋ぐためのノード
AudioWorkletProcessor 実際の処理ロジックを書くクラス
registerProcessor() ProcessorをWorkletに登録する関数(必須)
connect() ノード同士を繋いで音声を流す
WASM C++/Rustのコードをブラウザで実行する技術
モンキーパッチ 既存の関数を別の関数に置き換える手法(リスクあり)

参考資料

公式ドキュメント

実装事例

ライブラリ

Discussion