はじめに
前回の記事では、OpenAI Realtime API(WebRTC)を使ってGoogleアシスタントを置き換えるVIA(VoiceInteractionService実装)を作る方法を確認した。
クライアントのサンプルアプリ(VoiceInteractionAppSample)は OpenAIのRealtime APIサーバーにWebRTCで接続し、[発話] → [STT] → [LLM] → [TTS] → [再生] の一連の流れを実現していた。
今回は、OpenAIが提供するバックエンドの代わりに、 Mac上でセルフホストする OpenAI Realtime API互換サーバー (STT・LLM・TTS) へ接続 する。Android Automotive側は音声の入出力と通信を担い、推論はMac側で実行する。
接続先ホスト名をapi.openai.comから OpenAI Realtime API互換サーバー を起動した ローカルMac のアドレスに変更し、設定画面でLOCALモードを選ぶだけで同じクライアントが動作する。
動作確認
今回利用した OpenAI Realtime API互換サーバー のリポジトリ
サンプルのクライアントアプリのリポジトリ
OpenAI Realtime API互換サーバの実現方法
WebRTCサーバーを自作する代わりに、OpenAI Realtime API互換サーバー
として開発されている huggingface/speech-to-speechの--mode realtimeをそのまま使用した。
特徴は次の通り
- WebRTC実装がOpenAI Realtime APIを模倣する設計になっている。データチャネル名
oai-events、エンドポイントPOST /v1/realtime/callsが互換 -
session.updateのパースにOpenAI公式Python SDKの型定義(openai.types.realtime.*)をそのまま使用。クライアントが送るsession.audio.input.turn_detectionのネスト形式もそのまま利用可能 - STT/LLM/TTSはCLIフラグの値で実装が選択可能。バックエンドの差し替えはCLIフラグの変更で実現
動作確認できたのは VoiceInteractionAppSample が使うSDP交換、oai-eventsデータチャネル、session.update、音声入出力、function callingイベントの範囲のみであり、Realtime API全体への互換をチェックしたわけではなく、未対応イベント・エラー応答・複数セッション同時接続などは検証していない。
OpenAI Realtime API互換を模倣するために、セッション認証情報を返すエンドポイントが必要だが、れはspeech-to-speechの範囲外になる。そこで、ローカル検証用に broker.pyという代替を用意した。speech-to-speechのrealtimeサーバー自体は認証チェックをしないため、clientSecretはダミー値を返すだけのHTTPサーバーである。
パイプライン構成とモデル選定
サーバー内部は VAD → STT → LLM → TTS の直列パイプラインで、VAD以外の各段はCLIフラグの値で実装を差し替えられる。今回選んだ実体は次の通り
| 段 | 差し替え | 選定 |
|---|---|---|
| VAD | 不可 | Silero VAD v5 + Smart Turn v3.2 |
| STT | 可 |
mlx-audio-whisper(whisper-large-v3-turbo, MLX、日本語対応) |
| LLM | 可 |
mlx-lm(mlx-community/Qwen3.5-9B-4bit) |
| TTS | 可 |
qwen3(Qwen3-TTS-1.7B-CustomVoice, bf16, mlx-audio, speaker=ono_anna) |
STTはデフォルトのParakeet-TDTが日本語非対応(--parakeet_tdt_languageのhelpに「25 European languages」と明記されている)なため、mlx-audio-whisperに変更した。
LLMはQwen3-4Bから始めたが、Tool Calling に問題が確認されたため Qwen3.5-9B-4bit に変更している。
TTSの話者はデフォルトのaiden(低めの男性声)から、日本語向けの ono_anna に変更し、量子化も 6bit→bf16 に上げて音質改善を試みた。
バックエンドの切り替え
speech-to-speech 0.2.12では、--stt、--llm_backend、--ttsの値に応じて各処理の実装が選択される。
今回の構成では、STTにmlx-audio-whisper、LLMにmlx-lm、TTSにqwen3を指定した。
Mac (Apple Silicon) 対応
speech-to-speechには MacOS 向けの実装が既に組み込まれており、推論バックエンドとして MLX を利用可能
セットアップと起動
uvでPythonバージョンと依存関係をロックしている。uv syncコマンドで全依存が.venvにインストールされる。
uv sync
起動はMakefileにまとめてある。
make setup # uv sync + broker.py(:8787) + speech-to-speech(:8765) を起動、:8765の応答を待つ
make down # 両方停止
make status # ヘルスチェック
make logs # 両方のログをtail
make setupは起動したPIDを.run/に記録し、既に起動中なら再起動せず現在のPIDを表示する。中身は次のコマンドと等価。
uv run python broker.py > .run/broker.log 2>&1 &
uv run speech-to-speech \
--mode realtime \
--device mps \
--stt mlx-audio-whisper \
--language ja \
--no_enable_live_transcription \
--llm_backend mlx-lm \
--model_name mlx-community/Qwen3.5-9B-4bit \
--tts qwen3 \
--qwen3_tts_model_name Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
--qwen3_tts_speaker ono_anna \
--qwen3_tts_mlx_quantization bf16 \
--qwen3_tts_non_streaming_mode True \
--ws_host 127.0.0.1 --ws_port 8765
STT実装の速度比較
現行採用のmlx-audio-whisper以外に、speech-to-speechが対応する--stt whisper-mlx(実体はlightning-whisper-mlxパッケージ)と、MLXを使わないwhisper.cpp(Homebrew whisper-cppのwhisper-cli)を含めた3方式で日本語音声の文字起こし速度を計測した。
計測条件: 短尺はmacOS say -v Kyokoで合成した6.4秒の日本語音声、長尺は手元のYouTube文字起こし系動画音声から切り出した180秒クリップ。各実装は逐次実行(同時実行するとMetalのGPUを取り合い計測が不正確になるため)、各条件1回のみの計測で、複数回実行した分散は取っていない。loadをモデルロード時間、inferを推論時間として分離して計測した。実運用のサーバーはモデルを起動時に一度だけロードして常駐させるため、毎ターン効くのはinferのみになる。
3実装で揃えたのは音声入力とタスク(日本語文字起こし)のみで、モデルは揃っていない。mlx-audio-whisperとwhisper.cppはlarge-v3-turbo、lightning-whisper-mlxはlarge-v3で、後者はturbo版ではない分だけ元々不利な条件になる。実装単体の速度比較ではなく、「今回試した組み合わせでの結果」として見る必要がある。
| 実装 | モデル | load (6.4s) | infer (6.4s) | infer (180s) |
|---|---|---|---|---|
| mlx-audio-whisper | whisper-large-v3-turbo | 3.51s | 0.74s | 8.18s |
whisper.cpp (whisper-cli) |
large-v3-turbo (ggml) | 0.41s | 0.90s | 10.9s |
| lightning-whisper-mlx | large-v3 | 0.53s | 1.57s | 28.3s |
今回のMac・このモデル・この音声・各1回の計測では、inferだけで見るとmlx-audio-whisperが短尺・長尺とも最短だった。これが現行構成を維持している根拠になっている。whisper.cppが「合計」で勝って見えたのは、CLIを1発叩くたびにモデルを再ロードするコスト(0.4秒程度)を含んでいたためで、常駐サーバーの実態とは前提が異なる比較だった。lightning-whisper-mlxは短尺では悪くないが、180秒の長尺クリップでinferが28秒(リアルタイム比0.16倍)まで悪化した。batch_size=6前提の実装であることは確認したが、長尺での悪化との因果関係は未特定。今回のサーバーはVAD区切りの短い発話単位でSTTを呼ぶ構成のため実害は小さいと見られるが、積極的に採用する理由もない。
なおlightning-whisper-mlxは--stt_model_name未指定時のデフォルトdistil-whisper/distil-large-v3が英語専用モデルで、日本語音声を渡すと英訳もどきの誤認識になる。日本語で使うには--stt_model_name large-v3(多言語モデル)の明示が必要だった。
既知の制約
- ローカルTTS(Qwen3-TTS)はOpenAI TTSと聴感上の音質差がある、という印象を持った。定量評価はしていない主観的な聞き比べで、評価者・比較手順は記録していない。量子化を6bit→bf16に上げる改善は試したが、それでも差は残っている。
speech-to-speechが対応する他の内蔵TTS(chatTTS/facebookMMS/pocket/kokoro)は未試験 -
speech-to-speech 0.2.12の--stt mlx-audio-whisperとこのVAD設定の組み合わせでは、--enable_live_transcription(デフォルトtrue)でSTT精度が崩れる事象を確認した。VADが発話の完了を確認する前の最初の約0.88秒だけでSTTを実行しそれを最終結果として確定してしまい、以降の音声を「stale」として破棄する。--no_enable_live_transcriptionを付けるとVADのSpeech soft-ended→Smart Turn: completeを待ってから一度だけSTTが走るようになり、正しく認識された
