WebRTCの音声をSpeechRecognition APIで認識できない問題と解決策
別プロジェクトでWebRTC音声通話を作ってて、「これに自動翻訳付けたら面白そう」と思った。WebRTCで音声を受信して、SpeechRecognition APIでテキスト化して、翻訳APIに投げればいいだけでしょ?
実際やってみたら、WebRTCのMediaStreamをSpeechRecognition APIに渡せないことが判明した。調べて試行錯誤した結果、実現可能な3つのアプローチが見えてきたので整理する。
このブログにはQRコードでWebRTCシグナリングを実装したP2P音声通話ツールと、iOSでWebSpeech APIを安定させる方法をまとめた記事があるので、そちらも参考にしてほしい。
問題の本質:MediaStreamの壁
SpeechRecognition APIは「ローカルマイクの音声」しか認識できない。
// これは動かない
peerConnection.ontrack = (event) => {
const remoteStream = event.streams[0];
const recognition = new webkitSpeechRecognition();
// remoteStreamをSpeechRecognitionに渡す方法がない
recognition.start(); // ローカルマイクの音声しか拾わない
};
getUserMedia() で取得したローカルのMediaStreamなら認識できるが、RTCPeerConnection.ontrack で受信したリモートのMediaStreamは対象外。
なぜそうなっているか。仕様として、SpeechRecognition APIはユーザーの音声入力デバイス(マイク)からの音声のみを対象としている。セキュリティ的な理由もあるし、ブラウザの実装的にもローカルマイクへの直接アクセスが前提になっている。
つまり、「WebRTCで受け取った相手の音声をこちら側で認識する」という方向では実現できない。発想を変える必要がある。
解決策A:リモート側認識(最もシンプル)
音声認識を「喋る側」で行う。認識結果のテキストをDataChannelで送信する。
// 送信側(喋る人)
const recognition = new webkitSpeechRecognition();
recognition.continuous = true;
recognition.interimResults = false; // final結果だけ
recognition.lang = 'ja-JP';
recognition.onresult = (event) => {
for (let i = event.resultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
const text = event.results[i][0].transcript;
// DataChannelでテキスト送信
dataChannel.send(JSON.stringify({
type: 'transcript',
lang: 'ja',
text: text
}));
}
}
};
// 受信側
dataChannel.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'transcript') {
// 翻訳APIに投げて結果を表示
translateAndDisplay(data.text, data.lang);
}
};
アーキテクチャ:
[User A] マイク → SpeechRecognition → テキスト
↓
DataChannel
↓
[User B] ← 翻訳結果 ← 翻訳API ← テキスト受信
メリット:
- 実装が簡単
- 追加コスト不要(Web Speech APIは無料)
- レイテンシが小さい(テキストだけ送るので通信量が少ない)
- サーバー不要(P2Pで完結)
デメリット:
- 相手側のブラウザに依存する
- 認識精度がブラウザ任せ
- 相手がiOS Safariだと不安定になる可能性(後述)
プロトタイプやカジュアルな用途ならこれで十分。
解決策B:サーバ側認識(高精度)
音声をサーバに送り、Whisper API等で認識する。
// MediaRecorderで音声を録音
const mediaRecorder = new MediaRecorder(localStream);
const socket = new WebSocket('wss://your-server.com/transcribe');
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
// 音声チャンクをサーバに送信
socket.send(event.data);
}
};
mediaRecorder.start(1000); // 1秒ごとにチャンク送信
// サーバ側でWhisper API呼び出し
socket.onmessage = (event) => {
const { text } = JSON.parse(event.data);
translateAndDisplay(text);
};
メリット:
- 高精度(Whisper APIなど商用サービスを使える)
- 言語モデルを選択可能
- ログ取得・分析が可能
- ブラウザ依存がない
デメリット:
- コストがかかる(従量課金)
- レイテンシが増加(音声アップロード + API処理)
- サーバー管理が必要
- WebSocket接続の管理が必要
各音声認識APIの比較:
| API | 料金 | 精度 | レイテンシ |
|---|---|---|---|
| Web Speech API | 無料 | 中 | 低 |
| Whisper API | $0.006/分 | 高 | 中 |
| Google Speech-to-Text | $0.016/分 | 高 | 低 |
ビジネス用途や会議の議事録など、精度が重要な場面ではこちらが良い。
解決策C:AudioContext実験(非推奨だが記録)
AudioContextでMediaStreamをキャプチャして、外部APIに送信する方法も理論上は可能。
// 実験段階:AudioContextで音声データ取得
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(remoteStream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
source.connect(processor);
processor.connect(audioContext.destination);
processor.onaudioprocess = (event) => {
const audioData = event.inputBuffer.getChannelData(0);
// このPCMデータをどこかに送る必要があるが...
// SpeechRecognition APIには渡せない
// → 結局外部APIが必要
};
問題点:
ScriptProcessorNodeは非推奨(AudioWorkletを使うべきだが、さらに複雑)- PCMデータを送るには結局外部APIが必要
- 実装コストが高い割に、解決策Bと変わらない結果になる
記録として残すが、推奨しない。素直に解決策Bを選んだ方が良い。
Push-to-Talk実装
continuous認識を使うと、誤認識やバッテリー消費の問題がある。Push-to-Talk(PTT)ボタンで認識のON/OFFを制御する方が実用的。
const recognition = new webkitSpeechRecognition();
recognition.continuous = true;
recognition.interimResults = false;
recognition.lang = 'ja-JP';
const pttButton = document.getElementById('ptt-btn');
let currentState = 'idle'; // idle / starting / listening / processing
pttButton.addEventListener('pointerdown', () => {
currentState = 'starting';
updateUI(); // 「準備中...」表示
recognition.start();
});
recognition.onstart = () => {
currentState = 'listening';
updateUI(); // 「どうぞ」表示
};
pttButton.addEventListener('pointerup', () => {
if (currentState === 'listening') {
currentState = 'processing';
updateUI(); // 「認識中...」表示
recognition.stop();
}
});
recognition.onresult = (event) => {
// テキスト送信処理
};
recognition.onend = () => {
currentState = 'idle';
updateUI(); // 「ボタンを押して話す」表示
};
function updateUI() {
const messages = {
idle: '🎤 ボタンを押して話す',
starting: '⏳ 準備中...',
listening: '🔴 どうぞ',
processing: '💭 認識中...'
};
document.getElementById('status').textContent = messages[currentState];
pttButton.dataset.state = currentState;
}
状態管理のポイント:
starting状態を設けることで、onstartが来るまでユーザーに「まだ喋らないで」を伝えられるpointerdown/pointerupを使うことで、タッチとマウス両方に対応onstartが来てから「どうぞ」を表示することで、iOS特有の起動ラグに対応
詳しくは iOSでWebSpeech APIを安定させる方法 を参照。
iOS特有の問題と対策
問題1:インスタンス再生成で「ぴこんぴこん」音
iOSでは、SpeechRecognitionインスタンスを毎回 new すると、起動音が毎回鳴る。しかも許可ダイアログが毎回出る可能性もある。
解決策:シングルトンパターン
// ❌ 毎回生成 → 毎回ダイアログ&ぴこん
pttButton.addEventListener('pointerdown', () => {
const recognition = new webkitSpeechRecognition(); // ここがダメ
recognition.start();
});
// ✅ 使い回し
const recognition = new webkitSpeechRecognition();
recognition.continuous = true;
recognition.interimResults = false;
recognition.lang = 'ja-JP';
pttButton.addEventListener('pointerdown', () => {
recognition.start(); // 同じインスタンス
});
インスタンスを使い回すことで、初回のみ許可ダイアログが出て、2回目以降は音が鳴らずにスムーズに起動する。
問題2:WebRTCとのマイク競合
iOSでは、getUserMedia() と SpeechRecognition が別々のマイクセッション扱いになる。片方が使っているともう片方が掴めない、または許可ダイアログが毎回出る場合がある。
解決策:track.enabled で一時停止
pttButton.addEventListener('pointerdown', () => {
// ローカルストリームのトラックを無効化
localStream.getAudioTracks().forEach(track => {
track.enabled = false;
});
recognition.start();
});
pttButton.addEventListener('pointerup', () => {
recognition.stop();
});
recognition.onend = () => {
// トラックを再度有効化
localStream.getAudioTracks().forEach(track => {
track.enabled = true;
});
};
PTT中はWebRTCの音声が止まるが、「今こっちが喋ってる」状態だから相手に聞こえなくても問題ない。むしろ自然。
iOS特有の問題と解決策まとめ:
| 問題 | 原因 | 解決策 |
|---|---|---|
| ぴこんぴこん音 | インスタンス再生成 | シングルトン |
| マイク競合 | WebRTCとの同時使用 | track.enabled = false |
| 初回認識失敗 | warm-up不足 | getUserMedia 事前実行 |
詳細は iOSでWebSpeech APIを安定させる方法 を参照。
翻訳APIの選択肢
音声認識でテキスト化できたら、次は翻訳。いくつか選択肢がある。
| API | 無料枠 | 精度 | レイテンシ | 制限 |
|---|---|---|---|---|
| DeepL | 50万文字/月 | 高 | 低 | 要登録 |
| Google Translate | 非公式のみ | 中 | 低 | 不安定 |
| MyMemory | 5,000文字/日 | 低 | 低 | IPごと制限 |
| OpenAI GPT | $0.002/1K tokens | 高 | 中 | 要API key |
おすすめ:
- プロトタイプ: MyMemory(無料、制限緩い)
- 本番: DeepL(日英の精度が高い、レスポンス速い)
- 多言語: Google Translate(対応言語多い)
- 文脈理解: OpenAI GPT(会話の流れを考慮した翻訳)
このブログのテキスト翻訳ツールではMyMemory APIを使用している。無料で試したい場合はこれが現実的。
リアルタイム会話ならDeepLが体感良い。日本語の扱いがこなれているし、レスポンスが速い。
注意点:
- interim results(途中経過)を送ると翻訳API叩きすぎるので、
isFinalのみ送る設計にする - 連続発話の切れ目検出が地味にダルい
- DataChannelでテキスト投げる形なら、プロトコル設計を先に決めておく(
{type, lang, text, isFinal}など)
実装アーキテクチャの比較
3つのアプローチをどう選ぶか。
選択フロー
START
↓
【Q1】コストをかけたくない?
├─ YES → 解決策A(リモート側認識)
└─ NO → Q2へ
↓
【Q2】高精度が必須?
├─ YES → 解決策B(サーバ側認識)
└─ NO → 解決策A で十分
ユースケース別推奨
| ユースケース | 推奨アプローチ | 理由 |
|---|---|---|
| 1対1カジュアル通話 | A(リモート側認識) | コスト不要、十分な精度 |
| ビジネス会議 | B(サーバ側認識) | 高精度、ログ保存 |
| プロトタイプ | A(リモート側認識) | 最速実装 |
| 多言語対応必須 | B(サーバ側認識) | 言語モデル選択可能 |
トレードオフ
| コスト | 精度 | レイテンシ | 実装難易度 | |
|---|---|---|---|---|
| A: リモート側認識 | 無料 | 中 | 低 | 低 |
| B: サーバ側認識 | 有料 | 高 | 中 | 中 |
| C: AudioContext | 有料 | 高 | 中 | 高 |
解決策Aで始めて、精度が足りなくなったらBに移行する、という段階的アプローチが現実的。
実装時のチェックリスト
実装する際に確認すべき項目をまとめておく。
環境・権限:
- HTTPS環境で実行(localhostを除く)
- マイク権限をユーザージェスチャーで取得
- iOSの場合、SpeechRecognitionをシングルトンで生成
- PTT使用時、WebRTCトラックを一時無効化
エラーハンドリング:
-
no-speechエラー(無音検出)の処理 -
audio-captureエラー(マイク取得失敗)の処理 -
not-allowedエラー(許可拒否)の処理 - DataChannel切断時の状態復旧
互換性:
- ブラウザ非対応時のUI表示
- iOS Safari特有の問題への対処
- Chrome/Edge/Safariでの動作確認
UX:
- 認識状態のビジュアルフィードバック
- PTTボタンの長押し対応
- 翻訳結果の表示タイミング
- ネットワークエラー時の通知
参考資料:
- 音声ハウリング対策 - WebRTC音声設定
- QRコードでWebRTCシグナリング - DataChannel実装
「WebRTCの音声をそのまま認識できない」という制約は、一見不便に見える。でも考えてみると、音声認識を「誰が」「どこで」やるかの選択肢があるのは悪くない。
ブラウザ内完結(解決策A)、サーバ側処理(解決策B)、それぞれにメリットがある。制約があるからこそ、設計の自由度が生まれる。
このブログには実際に動くP2P音声通話ツールと音声認識テストがある。DataChannelでテキスト送受信する部分は応用できるはず。翻訳機能は未実装だが、組み合わせれば実現できる。
リアルタイム翻訳システムは、技術的には「WebRTC + SpeechRecognition + 翻訳API」で実現可能。あとはどれだけユーザー体験を磨けるか。