第4回(補足)個人開発でできるリアルタイム翻訳 - Bluetoothイヤホンで翻訳を聞く

4言語の双方向翻訳に対応: 英語・日本語・スペイン語・中国語の任意の組み合わせでリアルタイム双方向翻訳が可能です。ソースコードはGitHubで公開しています。

これはIntent-First Translation連載の補足記事です。前3回でシステムの核心部分 — 沈黙問題、LLMストリーミング実装、ローカルGPU検証 — を扱いました。今回は少し毛色が異なり、翻訳パイプラインに音声出力を追加する実験の記録です。

正直にお伝えすると、半分は動作し、半分はブラウザの制約に阻まれました。ただ、実装コードとそこから得た知見は、Web Speech APIやモバイルTTS、Bluetoothオーディオルーティングに取り組む方にとって、参考になる部分があるかもしれません。


目指したこと

AppleはAirPods Pro(約39,800円)にライブ翻訳機能を搭載し、iOS 26とApple Intelligenceを組み合わせた対面翻訳を実現しています。Timekettle M3(約16,980円〜)やVasco E1といった翻訳専用イヤホンも、専用ハードウェアと独自エコシステムによって完成度の高い体験を提供しています。

私が試したかったのは、もっと控えめな問いです。公開APIと普通のBluetoothイヤホンで、どこまでできるのか。

目指したパイプラインは以下のとおりです。

相手が英語で話す
  → スマホが音声を拾う
  → リアルタイムで翻訳(約2秒)
  → 翻訳を日本語音声で読み上げ
  → Bluetoothイヤホンから翻訳が聞こえる

TTS実装:コアとなるコード

TTSエンジンには、ブラウザ内蔵のWeb Speech APIを選びました。API課金なし、ネットワーク遅延なし、追加の依存関係もなし。プロトタイプとしては音声出力への最短経路です。

以下が speakTranslation 関数の全体と、その周辺のステート設定です。

// State setup
const [ttsEnabled, setTtsEnabled] = useState(false);
const ttsEnabledRef = useRef(ttsEnabled);
useEffect(() => { ttsEnabledRef.current = ttsEnabled; }, [ttsEnabled]);

// Mobile detection for rate adjustment
const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent);
const ttsRate = isMobile ? 1.3 : 3.0;

// Track last spoken text to prevent duplicate reads
const lastSpokenRef = useRef<string>('');

const speakTranslation = useCallback((text: string) => {
  if (!ttsEnabledRef.current) return;
  if (lastSpokenRef.current === text) return;  // Duplicate prevention
  lastSpokenRef.current = text;
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.lang = 'ja-JP';
  utterance.rate = ttsRate;
  window.speechSynthesis.speak(utterance);
}, [ttsRate]);

このコードには2つの設計上の判断があります。

ttsEnabledRef は、stateの ttsEnabled を直接読むのではなく、refを使っています。この関数はReactのイベントハンドラではなく、WebSocketのメッセージコールバックから呼ばれます。stateを直接参照すると、WebSocketハンドラが作成された時点の値がクロージャにキャプチャされ、古い値を読み続けてしまいます。refは常に最新の値を指します。

lastSpokenRef は、同じ翻訳が2回読み上げられるのを防ぎます。translation_partialintent の両方のメッセージが同じ翻訳テキストを含むことがあるため、このガードがないと同じ文が連続で読み上げられてしまいます。


翻訳パイプラインへのTTS統合

speakTranslation は、WebSocketメッセージハンドラの中で、intent(翻訳完了)メッセージ受信時に呼び出されます。

case 'intent': {
  setCurrentIntent(message.data);
  setPartialIntentLabel('');

  // TTS: read translation aloud (duplicates prevented inside speakTranslation)
  if (message.data.full_translation) {
    speakTranslation(message.data.full_translation);
  }

  // ... rest of timeline update
  break;
}

TTSは intent メッセージで発火します。このメッセージには確定済みの full_translation が含まれており、最も正確な翻訳結果です。部分翻訳で発火すると、不完全なテキストや直後に修正されるテキストを読み上げるリスクがあります。


TTSトグルボタンとオーディオのロック解除

トグルボタンは単なるフラグの切り替えではありません。モバイルブラウザでは、初回のON操作時に無音の発話でオーディオコンテキストをロック解除する必要があります。

{/* TTS toggle button */}
<button
  onClick={() => {
    if (ttsEnabled) {
      // Turning OFF: cancel any ongoing speech
      window.speechSynthesis.cancel();
    } else {
      // Turning ON: unlock audio for mobile browsers
      // Mobile browsers require a user gesture to enable speechSynthesis
      const unlock = new SpeechSynthesisUtterance('');
      unlock.volume = 0;
      window.speechSynthesis.speak(unlock);
    }
    setTtsEnabled(!ttsEnabled);
  }}
  className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
    ttsEnabled
      ? 'bg-amber-500 text-white'
      : 'bg-gray-300 text-gray-600'
  }`}
>
  {ttsEnabled ? '🔊 TTS ON' : '🔇 TTS OFF'}
</button>

ここで鍵となるのが、無音の発話 new SpeechSynthesisUtterance('')(volume=0)です。ユーザーがボタンをタップすると、この処理はユーザージェスチャのコンテキスト内で実行され、ブラウザの自動再生ポリシーを満たします。これにより、以降のWebSocketコールバックからの speak() 呼び出しがすべて許可されます。この初回のロック解除がなければ、プログラムからのTTS呼び出しはすべて無音でブロックされます。


問題1:プラットフォームによるTTS速度の違い

Web Speech APIはブラウザ標準ですが、実際のTTSエンジンはOSごとに異なります。

設定PC (Windows Chrome)iPhone Chrome
rate=1.1通常速度通常速度
rate=1.8やや速いかなり速い
rate=3.0ちょうどいい速すぎて聞き取れない

同じ rate=3.0 でも、iPhoneでは聞き取れないほどの速さになりました。原因は、iPhone上のすべてのブラウザ(Chrome、Safari、Firefox)がAppleのWebKitエンジンを使用しており、TTS処理もApple独自の音声エンジンで実行されるためです。「標準API」であっても、プラットフォーム間で一貫した動作は保証されません。

修正は、コアコードで示したモバイル判定です。

const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent);
const ttsRate = isMobile ? 1.3 : 3.0;

単純なチェックですが、これがないとモバイルでの翻訳音声は聞き取れません。


問題2:モバイルで無音になる

PCで動作していたTTSが、iPhoneでは完全に無音でした。

原因はモバイルブラウザの自動再生制限です。speechSynthesis.speak() は、ユーザーのタップ操作から直接呼ばれた場合のみ動作します。WebSocketコールバックからの呼び出しはユーザー操作とは見なされず、無音でブロックされます — エラーも警告も出ません。

解決策は、上のトグルボタンのコードで示したオーディオのロック解除機構です。ユーザーがTTS ONボタンを最初にタップした際に、無音の発話 new SpeechSynthesisUtterance('') がオーディオコンテキストをロック解除します。この1回のジェスチャ以降、プログラムからの speak() 呼び出しは正常に機能します。


問題3:翻訳が表示されても読み上げられないケース

画面に翻訳が表示されているのに、音声が再生されないケースがありました。

原因はTTSの発火条件にありました。当初、確定結果(is_final=true)のときだけTTSを発火していましたが、実験的なストリーミングモードでは部分翻訳(is_final=false)も画面に表示されることがあります。部分翻訳が表示された後、確定結果が到達する前に次の発話で上書きされると、TTSが発火しないまま消えていました。

修正として、is_final フラグに関係なく、翻訳テキストが存在すればTTSを発火する方式に変更しました。speakTranslation 内部の重複防止ロジック(lastSpokenRef チェック)が同じ文の二重読み上げを防ぐため、部分結果と確定結果の両方でTTSを発火しても問題ありません。


Bluetoothイヤホンでの実機テスト

これらの修正を経て、iPhoneにBluetoothイヤホンを接続し、実機テストを行いました。

動作したこと

  • 自分が英語を話す → イヤホンで日本語訳が聞こえる」— これは動作しました。

イヤホンのマイクが自分の声を拾い、翻訳され、日本語音声がイヤホンから再生されました。翻訳テキストの画面表示から音声開始までの遅延は約1秒以内でした。

動作しなかったこと

  • 相手の英語をイヤホンのマイクで拾って翻訳する」— これは失敗しました。

Bluetoothイヤホンのマイクは、装着者の声を拾うように設計されています。ノイズキャンセリングが周囲の音を積極的にカットし、通話に最適化されているため、目の前に座っている相手の声をまともに拾うことができませんでした。

これはソフトウェアの問題ではなく、マイクの物理的な設計上の制約です。AirPods Proや翻訳専用イヤホンがビームフォーミングやマルチマイクアレイを搭載しているのは、この制約を専用ハードウェアで解決するためです。


「入力は内蔵マイク、出力はイヤホン」の試み

相手の声を拾うには、スマホの内蔵マイクを使えばよいはずです。入力デバイスをスマホの内蔵マイクに固定し、出力だけをBluetoothイヤホンに送る構成を試みました。

マイク選択UIを実装し、getUserMediadeviceId 制約で内蔵マイクを指定しました。

結果は失敗でした。iOSのブラウザ(WebKit)では、オーディオ入力デバイスの明示的な選択がサポートされておらず、デバイス指定は無視されるか、接続が即座に切断されました。ブラウザ環境内での回避策はありませんでした。


ブラウザの限界とネイティブアプリ

ここで、ブラウザでできることの境界が明確になりました。

機能ブラウザネイティブアプリ
TTS音声出力可能(制限あり)可能(制限なし)
入力/出力デバイスの分離不可可能
バックグラウンド動作不可可能

iOSのネイティブアプリであれば、AVAudioSession APIで入力デバイスと出力デバイスを個別に制御できます。構成は以下のようになります。

相手が英語で話す
  → スマホ内蔵マイク(AVAudioSession)
  → Deepgram → LLM → 翻訳(約2秒)
  → iOS TTS (AVSpeechSynthesizer)
  → Bluetoothイヤホンで日本語訳を聞く

バックエンド(FastAPI + Deepgram + LLM)はそのまま流用でき、フロントエンドのみをReact NativeまたはSwiftに置き換えることで実現できます。


遅延の計測結果

テストで得られた遅延データです。

指標計測値
発話終了 → 翻訳テキスト表示平均 2,115ms
翻訳テキスト表示 → TTS読み上げ完了(短文)約1秒以内
発話終了 → 翻訳音声が聞こえる約3秒

参考として、プロの同時通訳者は通常2〜3秒遅れで訳出します。短い発話であれば近いレイテンシを達成しています。長い文では差が広がりますが、基礎的な計測値として、有用な出発点になるデータです。


この実験で確認できたこと

  1. 公開APIの組み合わせで、翻訳音声のリアルタイム再生は実現できる — ただし、プラットフォームごとの差異への対処が必要。
  2. 汎用のBluetoothイヤホンでも、翻訳音声の出力には問題がない — 出力側には専用ハードウェアは不要。
  3. ブラウザではオーディオ入出力デバイスの分離制御ができない — iOS WebKitの制約で、現時点では回避策がない。
  4. ネイティブアプリ化すれば、「内蔵マイクで聞き取り+イヤホンで翻訳音声」は技術的に実現可能AVAudioSession がブラウザにないデバイス制御を提供する。

専用製品が提供するすべてを個人開発で再現することはできません。ハードウェアに依存する部分 — ビームフォーミングによる周囲音の集音やマルチマイクアレイ — は、ソフトウェアだけでは対応できない領域です。

しかし、「スマホの内蔵マイクで音声を拾い、翻訳音声をイヤホンで聞く」という構成であれば、公開APIとオープンな技術で機能するプロトタイプは構築できます。本記事のコード — TTS関数、オーディオのロック解除機構、WebSocketパイプラインとの統合 — は、記載のとおり動作します。

この実験で得られた知見 — プラットフォームごとのTTS挙動の差異、モバイルブラウザのオーディオ制限、Bluetoothルーティングの制約 — は、同様の実装を検討する方にとって、ブラウザでの開発でもネイティブアプリでも、実用的な参考資料になると考えています。


この記事をシェア

関連記事