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状態
ブラウザのオートプレイポリシーにより、ユーザー操作なしで作成したAudioContextはsuspended(一時停止)状態になります。
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の作成タイミング
AudioWorkletNodeをinit()で事前作成してキャッシュしようとすると、問題が起きることがあります。
// 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のコードをブラウザで実行する技術 |
| モンキーパッチ | 既存の関数を別の関数に置き換える手法(リスクあり) |
参考資料
公式ドキュメント
実装事例
- Datadog: How we built a real-time, client-side noise suppression library
- Krisp: Better Audio Processing with Krisp JS SDK
- Processing Web Audio with Rust and WASM
ライブラリ
- sapphi-red/web-noise-suppressor - RNNoise/Speexベース
- mezonai/mezon-noise-suppression - DeepFilterNet3
- Rikorose/DeepFilterNet - DeepFilterNet3オリジナル
Discussion