はじめに
Android Automotive OS(AAOS)には、車内の音声アシスタントを独自アプリに差し替える仕組みがある。
Google Assistant の代わりに、PTT(ステアリングホイールのボタンなど)や画面上のマイクアイコンから起動するアシスタントを実装できる。この仕組みを AAOS では Voice Interaction App(VIA)と呼ぶ。
VIA の一次情報は AOSP のドキュメントにまとまっている。
今回は VIA のバックエンドに OpenAI Realtime API を WebRTC 経由で接続し、日本語で会話しながら「YouTube でこの動画を検索して」のような指示を Android 側で実行できるアシスタントを実装した。
この記事で扱うソースコードは次のリポジトリの 0.0.1 に固定している。
AAOS Emulator 上でビルド・起動し、Realtime API との接続、音声対話、function calling によるブラウザ起動まで確認した。
開発した VIA アプリ
会話は AAOS Emulator の右下にあるマイクアイコンを押すと始まる。
VIA の状態を確認できるよう、Voice Plate としてアシスタントの発話・状態を表示する半透明のカードを画面左上に表示した。ユーザーの発話とアシスタントの応答もリアルタイムに文字起こしして表示する。
「フリーレンの動画を検索して」と話しかけた場合の画面が次のものだ。
この例では次の処理が行われる。
- ユーザーの発話を日本語として文字起こしし、Voice Plate に表示する
- モデルが
open_youtube_searchtool を呼び出す - Android 側で引数を検証した後、YouTube の検索結果をブラウザで開く
- tool の実行結果を Realtime API に返す
- アシスタントが実行結果を日本語で発話する
- Voice Plate 下部にセッション累計のトークン数と推定利用料金を表示する
会話中にユーザーが話し始めると、アシスタントの発話中でも割り込める。
本実装ではサーバー側 VAD(Voice Activity Detection)を使用しており、ユーザーの発話開始を検出すると進行中の応答をキャンセルする。
VIA アプリの開発方法
VIA を構成するコンポーネント
公式ドキュメントでは VIA を、音声制御用の assistant として振る舞うよう設計された Android アプリを説明している。
通常の Activity をユーザーが起動して使用する構成とは異なり、デフォルトの音声アシスタントとして選択された VoiceInteractionService に OS がバインドする。
VIA を構成する主要コンポーネントは次の3つになる。
-
VoiceInteractionService
OS からバインドされるサービス。ホットワード検出など、セッション外でも必要になる処理があればここで管理する -
VoiceInteractionSessionService(VSS)
音声対話開始時にVoiceInteractionSessionを生成する -
VoiceInteractionSession(VIS)
1回の対話セッションに対応する。Voice Plate の表示や対話中の処理を担当する
デフォルトとして選択される VIA は1つで、選択されたアプリが PTT(Push-To-Talk)や TTT(Tap-To-Talk)のイベントを処理する。
Settings アプリの Assistant & voice > Digital assistant app から切り替えられる。
本サンプルでは、それぞれを次のクラスに対応させている。
VoiceInteractionServiceImpl の実装は次の1行だけである。
class VoiceInteractionServiceImpl : VoiceInteractionService()
PeerConnection や HTTP クライアントはここでは生成しない。
Realtime API への接続処理は onNewSession() から生成される CarVoiceInteractionSession 以降で開始する。
VoiceInteractionService に接続処理を持たせると、デフォルトアシスタントとして選択されているだけで外部接続を維持する実装になりやすい。今回は「OS に常駐する部分」と「会話中だけ必要な処理」を分離するため、VoiceInteractionServiceImpl 自体には処理を持たせていない。
AndroidManifest.xml
App development に記載されている VIA の要件を AndroidManifest.xml に反映する。
主なマニフェスト要件
-
VoiceInteractionServiceを継承したサービスを登録する -
android.service.voice.VoiceInteractionServiceの intent-filter を設定する - VoiceInteractionService に
android.permission.BIND_VOICE_INTERACTIONを設定する -
RecognitionServiceを登録する - 音声入力を行う場合は
RECORD_AUDIOが必要
本サンプルのマニフェストは次のファイルにある。
via/src/main/AndroidManifest.xml
ここでは次の3サービスを登録している。
VoiceInteractionServiceImplVoiceInteractionSessionServiceImplStubRecognitionService
RecognitionService を使用しない場合でも宣言する
本サンプルの音声認識は OpenAI Realtime API 側で行うため、Android 標準の RecognitionService を音声認識処理には使用しない。
一方、VIA の登録情報として RecognitionService 自体は必要になる。
このため StubRecognitionService を用意し、Android 側のマイク入力処理を持たない空実装としている。
マニフェストには次の意図をコメントとして残した。
// AOSP VoiceInteractionServiceInfo.java requires recognitionService to be
// non-null or the whole VIA registration silently fails to parse (getParseError()).
// This app's audio path is Realtime WebRTC only; this stub exists ONLY to satisfy
// that manifest requirement and must never touch the microphone.
recognitionService を指定しない場合、今回使用した AAOS 環境では VIA の登録情報が AOSP 側で正常にパースされなかった。
そのため StubRecognitionService は Android の音声認識を利用するためではなく、VIA として登録するためだけに存在する。
デフォルトアシスタントとして登録する
Settings から選択する以外に、開発中は adb でも切り替えられる。
adb shell settings put secure voice_interaction_service \
"com.example.voiceinteractionappsample/com.example.voiceinteractionappsample.via.VoiceInteractionServiceImpl"
モジュール構成
本サンプルは次の6モジュールに分けている。
:via
:realtime
:audio
:tools
:session
:diagnostics
:via が AAOS の Voice Interaction framework と接続し、:session が1回の会話のライフサイクルを管理する。
:session から :realtime、:audio、:tools を利用する構成にした。
:diagnostics は通常の会話処理には入らず、起動時のセルフチェック画面から各コンポーネントの状態を参照する。
この境界にした理由は、AAOS 固有処理と Realtime API 固有処理を直接結合させないためである。
:via は Android framework との接続だけを持ち、WebRTC の接続状態や function calling の処理は :session より下に置く。
この分割によって WebRTC、音声デバイス、tool 実行部分を VIA framework から切り離してテストできる。一方で、会話状態を集約する :session の責務は大きくなる。
OpenAI Realtime API を WebRTC で接続する
OpenAI Realtime API では WebRTC と WebSocket を利用できる。
本サンプルでは WebRTC を使用した。
WebSocket で音声を扱う場合、音声データの送受信をアプリ側のイベント処理として実装する必要がある。WebRTC では Android の音声入力を AudioTrack として PeerConnection に接続し、リアルタイム音声伝送やコーデック処理を WebRTC スタックに委譲できる。
特に、エコーキャンセルをWebRTCに任せることでサンプル実装はかなり楽になる。
エコーキャンセル機能がないと、AIエージェントはスピーカーから出力した自らの音声出力をユーザーからの音声入力だと誤検知してループする場合がある
車載アシスタントではマイク入力とモデル音声出力を同時に扱うため、今回は WebRTC を選択した。
Session Broker
OpenAI の標準 API キーは Android アプリには配置しない。
APK に API キーを埋め込むと、端末や APK を解析できる利用者からキーを秘匿できないためである。そのため標準 API キーを保持する Session Broker を Android の外側に配置する。
Broker は OpenAI から ephemeral key(clientSecret) を取得する。
開発環境ではホスト PC 上の次のスクリプトを使用した。
backend/local_broker.py
Android アプリは Broker から短命な clientSecret を取得し、その値を使用して OpenAI と直接 WebRTC ネゴシエーションを行う。
標準の OpenAI API キーを保持するのは Broker だけで、Android アプリには渡さない。
Broker 自体の認証方式や本番配置は今回の対象外である。backend/local_broker.py はローカルで接続確認を行うための実装としている。
ephemeral key を使用する接続フロー
OpenAI の WebRTC 接続には複数の初期化方式がある。
本サンプルでは、Broker が ephemeral key を取得し、Android がそのキーを使って /v1/realtime/calls に SDP offer を送る方式を採用した。
Android 側では次の順序になる。
- Broker から
clientSecretを取得する -
PeerConnectionを作る -
oai-eventsDataChannel を作る -
createOffer()を実行する -
setLocalDescription()を実行する -
/v1/realtime/callsに SDP offer を送る - SDP answer を受け取る
-
setRemoteDescription()を実行する - ICE/DTLS の接続を待つ
この⑤〜⑧付近を実装しているのが RealtimeWebRtcClient.connect() である。
主要部分は次のようになる。
val dataChannel = peerConnection.createDataChannel(
DATA_CHANNEL_LABEL,
DataChannel.Init(),
) ?: throw RealtimeConnectionException("createDataChannel returned null")
val offer = peerConnection.createOfferSuspend()
peerConnection.setLocalDescriptionSuspend(offer)
val answerSdp = withContext(Dispatchers.IO) {
postOffer(credential.clientSecret, offer.description)
}
peerConnection.setRemoteDescriptionSuspend(
SessionDescription(
SessionDescription.Type.ANSWER,
answerSdp,
),
)
postOffer() は SDP offer を次のエンドポイントへ送る。
POST https://api.openai.com/v1/realtime/calls
Content-Type: application/sdp
レスポンスは JSON ではなく SDP answer のテキストとして受け取り、そのまま setRemoteDescription() に渡す。
このフローは 2026-08 に実エンドポイントでも確認した。
古い記事では /v1/realtime/sessions を前提にした接続例も存在するため、実装時には現在の公式ドキュメントと使用する API の仕様を確認する必要がある。
DataChannel を offer より前に作る
Realtime API のイベント送受信には oai-events DataChannel を使用する。
本実装では初回接続後の WebRTC 再ネゴシエーションを実装していない。
そのため DataChannel を最初の SDP offer に含める必要があり、createOffer() より前に生成している。
val dataChannel = peerConnection.createDataChannel(
"oai-events",
DataChannel.Init(),
)
val offer = peerConnection.createOfferSuspend()
WebRTC 自体は後から DataChannel を追加して再ネゴシエーションする構成も取れるが、本サンプルではその経路を持たない。
session.update
接続後、Realtime API に会話設定を送る。
ConversationController.buildSessionUpdateEvent() が session.update を組み立てる。
ここでは次の設定をまとめて送る。
- 車載アシスタント用の
instructions - function calling の tool schema
- VAD
- noise reduction
- 日本語の音声文字起こし設定
コードは次のようになる。
JSONObject()
.put("type", "realtime")
.put("instructions", CAR_ASSISTANT_INSTRUCTIONS)
.put("tools", toolSchemas)
.put(
"audio",
JSONObject().put(
"input",
JSONObject()
.put(
"turn_detection",
vadConfig.toTurnDetectionJson(),
)
.put(
"noise_reduction",
noiseReductionConfig.toJson(),
)
.put(
"transcription",
JSONObject()
.put("model", "whisper-1")
.put("language", "ja"),
),
),
)
RealtimeVadConfig の初期値は次の値にしている。
threshold = 0.5
prefix_padding_ms = 300
silence_duration_ms = 500
今回の検証ではこの値を基準値として使用しただけで、実車の車内ノイズ環境に対する妥当性までは確認していない。
noise_reduction は、AAOS Emulator での動作確認中に無音区間を音声として誤検出するケースが発生したため追加した。
function calling で YouTube 検索を実行する
「YouTube で動画を検索する」という操作は Realtime API の function calling を使用している。
処理は大きく4段階に分かれる。
1. tool schema をモデルへ渡す
session.update の tools 配列に open_youtube_search を登録する。
実装は OpenYouTubeSearchToolSchema に置いている。
引数は query だけである。
open_youtube_search(query: string)
description には動画を探したいという明示的な要求がある場合だけ使用するよう条件を記述している。
「YouTube とは何か」のような会話でブラウザを開かないようにするためである。
2. function call を受信する
モデルが tool を選択すると、DataChannel に function call のイベントが流れる。
RealtimeToolBridge がイベントをデコードし、内部の ToolCall に変換する。
ToolCall は主に次の値を保持する。
call_id
name
arguments
3. Android API を呼ぶ前に検証する
tool call を受信しても、そのまま Android API は呼ばない。
DeviceToolExecutor で次の順序に通す。
Parse
↓
必須フィールド確認
↓
Policy check
↓
UX restriction check
↓
DeviceTool.execute()
未知の tool や不正な引数をモデルから受け取った場合、Android API まで到達させない。
LLM の出力を OS 操作へ直接接続せず、決められた schema とアプリ側の検証処理を境界に置いている。
4. tool の実行結果をモデルへ返す
Android 側の操作が完了したら、その結果を Realtime API へ返す。
モデルはその結果を使って次の応答を生成する。
このため、
YouTube で「フリーレン 動画」を検索しました
という発話は、Android 側で tool を実行した後の結果をモデルへ戻してから生成される。
YouTube をブラウザで開く
OpenYouTubeSearchTool は検索 URL を作り、ACTION_VIEW Intent でブラウザを起動する。
実行部分は次のようになる。
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(buildYouTubeSearchUrl(query)),
).apply {
setClassName(
CHROME_PACKAGE,
CHROME_MAIN_ACTIVITY,
)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return try {
context.startActivity(intent)
result(YouTubeSearchOutcome.OPENED)
} catch (e: ActivityNotFoundException) {
result(YouTubeSearchOutcome.NO_HANDLER)
}
今回使用した AAOS Emulator イメージでは、通常の ACTION_VIEW の implicit intent では Chromium が選択されなかった。
コンポーネントを指定しない場合、AAOS 標準の Link Viewer が起動し、QR コードを表示してスマートフォン側へ処理を引き渡す画面になった。
このためサンプルでは setClassName() により Chromium のコンポーネントを明示している。
この挙動は今回使用した AAOS Emulator イメージで確認したもので、すべての AAOS 環境で同じ Intent 解決になるとは限らない。
AAOS Emulator に Chromium を入れる
今回使用した AAOS Emulator イメージには、検索結果を通常の Web ページとして表示できるブラウザが入っていなかった。
そのため Chromium の Android 向けビルド済み APK を追加した。
Chromium の Android Arm64 スナップショットは次の場所から取得できる。
取得した ChromePublic.apk をインストールする。
adb install /path/to/ChromePublic.apk
今回使用した AAOS Emulator はマルチユーザー構成だったため、対象ユーザーでパッケージを有効化した。
adb shell pm enable --user 0 org.chromium.chrome
adb shell pm enable --user 10 org.chromium.chrome
初回起動時には利用規約への同意画面が表示される。
tool から起動する前に一度手動で Chromium を起動して初期画面を通過しておかないと、open_youtube_search を呼んでも検索画面ではなく初期設定画面で止まる。
実装時に入れた処理
マイクボタンの2回目を終了操作として扱う
Google Assistant の操作ではマイクボタンを再度押すと会話を終了できる。
今回使用した AAOS Emulator で VIA のイベントを確認すると、会話中にもう一度マイクボタンを押しても hide() に変換されず、onShow() が再度呼ばれた。
そのため CarVoiceInteractionSession.onShow() では、すでに接続中なら2回目の onShow() を終了要求として扱う。
if (
controller.state.value.connection
!= ConnectionState.DISCONNECTED
) {
hide()
return
}
これは AAOS framework が toggle-to-stop を保証していることを前提にせず、今回確認したイベント列に対してアプリ側で操作を成立させるための処理である。
cancel() は後始末を途中で止めない
ConversationController.cancel() では次の順序でリソースを解放する。
response.cancel
↓
イベント収集 Job 停止
↓
DataChannel close
↓
PeerConnection close
↓
AudioTrack / AudioSource dispose
↓
PeerConnectionFactory dispose
↓
AudioDeviceModule release
↓
audio focus 解放
各ステップは safely {} で個別に囲っている。
途中の解放処理で例外が発生しても、後続のリソース解放まで継続するためである。
例えば DataChannel.close() の失敗によって AudioDeviceModule.release() まで実行されなくなると、次のセッションでマイクを再取得できない状態が残る可能性がある。
一方、この実装では解放時の例外を上位へ伝播させないため、異常を把握するにはログや diagnostics 側で別途記録する必要がある。
アイドルタイムアウトと最大セッション時間
SessionTimeoutPolicy では2種類のタイムアウトを設定している。
-
idleTimeoutMs
既定値 10 秒。対象イベントが来ない状態が続いた場合に切断する -
maxSessionDurationMs
既定値 2 分。会話が継続していても、この時間を超えた場合は切断する
これらは今回のサンプル用の暫定値で、車載 UX として適切な値を検証したものではない。
Realtime API の利用料は処理された音声・テキストなどのトークン量によって発生する。
接続している時間そのものを単純に課金時間として扱うことはできないが、不要なセッションを維持すると、車内ノイズによる VAD 発火や意図しない音声処理によってトークンを消費する可能性がある。
このためコスト対策だけではなく、マイクや PeerConnection を不要に保持し続けないためのライフサイクル上限としてもタイムアウトを設定した。
トークン数と推定利用料金
Realtime API の response.done イベントには usage 情報が含まれる。RealtimeUsageCost では、この値を使ってセッション内の利用料金を推定している。
入力と出力をそれぞれ、下記のように分け、モデルごとの単価を掛け合わせる。
text
audio
cached
non-cached
単価はこのサンプルで使用するモデル分だけをハードコードしている。
2026-08-11 時点で OpenAI の価格ページに掲載されていた値を基準としているため、現在の料金を保証するものではない。
Broker が複数モデルを切り替える構成にする場合、Android 側も実際に使用したモデルに対応する単価テーブルを選択する必要がある。
価格変更によって表示額が実際の請求額とずれる可能性もあるため、Voice Plate 上では確定料金ではなく推定値として扱っている。
PTT から会話開始までの待ち時間
この構成で最も目立った UX 上の問題は、PTT を押してから Realtime API と実際に会話できる状態になるまでの待ち時間だった。
RealtimeWebRtcClient に次の時点のタイムスタンプを記録した。
credential 取得完了
SDP offer 作成完了
SDP answer 受信完了
setRemoteDescription 完了
ICE CONNECTED
同じホスト PC、同じ AAOS Emulator、同じネットワーク環境で3回計測した結果が次のグラフになる。
- credential 取得は Session Broker を経由した OpenAI へのリクエスト
- SDP 交換は Android アプリと OpenAI 間の通信
- 最後に ICE/DTLS による経路確立
3回の合計時間は約 2.4 秒から 6.0 秒で、平均は約 4.5 秒だった。
3回だけの計測なので、一般的な Realtime API の接続時間を示すデータではない。今回のログでは credential 取得と SDP answer の取得時間の振れが大きく、ネットワーク通信を含む区間が総時間のばらつきに大きく寄与していた。
ICE/DTLS の確立にも約 0.9〜2.1 秒かかった。
ただし、この測定では Broker 内部の処理時間、インターネット上の RTT、OpenAI 側の処理時間を個別には計測していない。したがって、どの区間が根本原因かまでは切り分けていない。
現在の実装では接続完了まで話せない
本サンプルでは PTT が押された後に次の処理を開始する。
PTT
↓
clientSecret 取得
↓
SDP 交換
↓
ICE/DTLS 確立
↓
音声入力開始
このため PeerConnection が使用可能になるまでは、Voice Plate に WORKING を表示して待つ。
credential 取得、SDP 交換、ICE/DTLS 確立を PTT 押下後に開始しており、その時間がそのまま入力受付開始までの待ち時間になってしまいユーザビリティが悪い。
同じ Emulator 上の Google Assistant では、本サンプルのような数秒間の WORKING 時間がなく即座にアシスタントと会話可能になる。
接続前の音声を保持する案
待ち時間を隠す方法として、PTT 押下直後からローカルで音声入力を開始し、Realtime API の接続とは別に一時保持する構成が考えられる。
PTT
├─ 音声キャプチャ開始
│ ↓
│ ローカルバッファ
│
└─ WebRTC 接続開始
↓
接続完了
↓
バッファ済み音声を入力
ただし、現在の実装はマイクから取得したリアルタイム音声を WebRTC の AudioTrack に直接渡している。
接続前に取得した PCM を後から送る場合、録音済みデータを Realtime API に投入する別経路が必要になる。
さらに、接続に4秒かかった場合に4秒分の音声を等速で送れば、その4秒の遅延は残る。高速に投入する場合は、リアルタイム音声との合流方法や VAD の扱いも設計する必要がある。
そのため「PTT と同時に録音を開始する」だけでは解決せず、接続前音声をどの経路で送り、現在時刻の入力へどう追いつかせるかまで決める必要がある。
今回の記事では深い調査は行っておらず、PTT 後に WebRTC 接続を開始する単純な構成にしている。





