技術 約8分で読めます

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無料枠精度レイテンシ制限
DeepL50万文字/月要登録
Google Translate非公式のみ不安定
MyMemory5,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の音声をそのまま認識できない」という制約は、一見不便に見える。でも考えてみると、音声認識を「誰が」「どこで」やるかの選択肢があるのは悪くない。

ブラウザ内完結(解決策A)、サーバ側処理(解決策B)、それぞれにメリットがある。制約があるからこそ、設計の自由度が生まれる。

このブログには実際に動くP2P音声通話ツール音声認識テストがある。DataChannelでテキスト送受信する部分は応用できるはず。翻訳機能は未実装だが、組み合わせれば実現できる。

リアルタイム翻訳システムは、技術的には「WebRTC + SpeechRecognition + 翻訳API」で実現可能。あとはどれだけユーザー体験を磨けるか。