NTTドコモR&Dの技術ブログです。

Android XR取り組み紹介(デモアプリ Geminiチャットbot編)

※ 本記事は 2026/3/31 以前にNTTコノキューにて記載した記事になります

0.はじめに

この記事はNTTコノキュー アドベントカレンダー2025の記事です。

こんにちは。NTTコノキューの乗松です。

本記事はGeminiに音声入力を行い、アプリ内に回答を出力させる機能の紹介になります。

Android XRアプリに限らずですが、Geminiと連携させた機能を実装する場面は多くなると思います。少しでも参考になれば幸いです。

1.開発環境

  • MacBook Apple M4
  • Android Studio Narwhal 4 Feature Drop | 2025.1.4 Canary 2
  • Jetpack XR SDK 1.0.0-alpha07

2.実装手順

2.1 画面の作成

まずは音声取得を開始するボタンを実装します。

添付画像は作成したホームスペースの画面になります。

細かいUIの実装は省きますが、ボタンの実装は下記になります。

Button(
    // ボタンのレイアウト設定を記述     
    modifier = Modifier
        .width(300.dp)
        .background(Color.Black)
        .align(Alignment.CenterVertically)
        .padding(end = 10.dp),
    onClick = {
        // ボタンが押下された時の処理を記述
        if (hasRecordAudioPermission) {
        // パーミッション許可されていた場合
        } else {
        // パーミッション許可されていない場合
        }
     }
) {
    // ボタンにテキストを入れる場合は設定を記述
    Text(
        text = if (isListening) "Listening..." else "Tap to Speak",
        fontSize = 32.sp,
        color = Color.White
        )
}

上記でhasRecordAudioPermissionという変数でパーミッション確認をしていますが、マイクを使用する場合パーミッションの実装が必須になります。

2.2 パーミッションの実装

音声取得のパーミッション実装する手順を紹介します。

2.2.1 マニフェストファイルの実装

AndroidManifest.xmlに下記を追記します。

<uses-permission android:name="android.permission.RECORD_AUDIO" />
2.2.2 パーミッションが許可されているか変数として保存

音声取得ボタンに実装されていた条件分岐はここで保存された変数を使用しています。rememberとmutableStateOfを使用することでhasRecordAudioPermissionの状態を記憶することができます。

val context = LocalContext.current
    var hasRecordAudioPermission by remember {
        mutableStateOf(
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.RECORD_AUDIO
            ) == PackageManager.PERMISSION_GRANTED
        )
    }
2.2.3 パーミッションをリクエストした結果が返ってきた時の実装

ユーザーはダイアログで音声取得の許可/拒否を選択できますが、その選択を行なった時の処理を記述できます。

val requestPermissionLauncher = rememberLauncherForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
    hasRecordAudioPermission = isGranted
    if (isGranted) {
    // パーミッションが許可された時の実装を記述
    } else {
    // パーミッションが拒否された時の実装を記述
    }
}
2.2.4 パーミッションをユーザーにリクエストする

最後に、パーミッションのリクエストダイアログを表示したい場所に下記の1行を追加します。

requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)

2.3 Geminiのモデルのインスタンス作成する

Geminiをアプリ内に実装する方法を紹介します。

本記事はFirebase AI Logicを使用しています。

事前にFirebaseの公式ドキュメントを参考に、Firebaseプロジェクトを作成してアプリを接続した後、アプリにSDKをインストールしてください。

下記は「Gemini 2.5 Flash」のモデルのインスタンスを作成するメソッドになります。

使用できるモデル一覧はこちら

Geminiのモデルは、必要に応じてHilt等を使ってDIするのが良いと思います。

import com.google.firebase.Firebase
import com.google.firebase.ai.ai
import com.google.firebase.ai.GenerativeModel
import com.google.firebase.ai.type.GenerativeBackend

-------------中略-------------

val model: GenerativeModel = generativeModel()

-------------中略-------------

fun generativeModel(): GenerativeModel {
    return  Firebase.ai(backend = GenerativeBackend.googleAI())
        .generativeModel("gemini-2.5-flash")
}

使用する機能によってはGenerativeModelを変更してください。Imagenを使用したい場合はImagenModel、Gemini Live APIを使用したい場合はLiveModelを作成してください。

また、backendの設定にはVertex AI Geminiも設定することができます。

2.4 Geminiへテキストを送信する

発話した音声をstringに変換してpromptという引数として渡しています。

音声をstringに変換する実装はこの後紹介します。

下記メソッドはGeminiと1問1答の形式ですが、過去の文脈を覚えてほしい場合はchatオブジェクトをViewModelのプロパティとして保持して使い回す必要があります。

fun sendTextToGemini(
    prompt: String,
    onResponse: (String) -> Unit
) {
    viewModelScope.launch(Dispatchers.IO) {
        try {
            // 新しいチャットセッションを開始
            val chat = model.startChat()

            // // 実際にプロンプトを送信し、APIからレスポンスを受け取る
            val response = chat.sendMessage(prompt)
            val geminiResponseText = response.text ?: "No response text found."

            // 処理結果をUIに反映させるため、メインスレッドで結果を返却する
            withContext(Dispatchers.Main) {
                onResponse(geminiResponseText)
            }
        } catch (e: Exception) {
            withContext(Dispatchers.Main) {
                onResponse("Error: ${e.message}")
            }
        }
    }
}

2.5 speechRecognizerを使った音声認識

ACTION_RECOGNIZE_SPEECHのputExtraにて設定している項目は公式ドキュメントを参照してカスタマイズしてください。

ボタンが押下された時やパーミッション許可された時に下記メソッドを使用することで音声認識を開始します。

認識した音声をStringで返すようになっているので、上記sendTextToGeminiと組み合わせることで音声入力→Gemini API→文字出力を実現することができます。

fun startSpeechRecognition(
    context: Context,
    onReady: () -> Unit,
    onResult: (String) -> Unit,
    onError: (Int) -> Unit
) {
    // SpeechRecognizerのインスタンスを生成
    val speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context)

    // 音声認識用の設定(Intent)を作成
    val speechRecognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
        putExtra(
            RecognizerIntent.EXTRA_LANGUAGE_MODEL,
            RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
        )
        putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) 
        putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak now...")
    }

    // 音声認識のイベントを受け取るリスナーを設定
    speechRecognizer.setRecognitionListener(object : RecognitionListener {
        
        // 認識準備が完了したとき
        override fun onReadyForSpeech(params: Bundle?) {
            onReady()
        }

        // 認識結果が得られたとき
        override fun onResults(results: Bundle?) {
            val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNIITION)
            onResult(matches?.getOrNull(0) ?: "")
        }

        // エラーが発生したとき
        override fun onError(error: Int) {
            onError(error)
        }

        override fun onBeginningOfSpeech() {
        // 話し始めた時の処理を記述
        }
        override fun onRmsChanged(rmsdB: Float) {
        // 音量が変化した時の処理を記述
        }
        override fun onBufferReceived(buffer: ByteArray?) {
        // バッファを受信した時の処理を記述
        }
        override fun onEndOfSpeech() {
        // 話し終わった時の処理を記述
        }
        override fun onPartialResults(partialResults: Bundle?) {
     // 途中の認識結果が利用可能になった時の処理を記述
     }
        override fun onEvent(eventType: Int, params: Bundle?) {
        // 追加のイベントを受信した時の処理を記述
        }
    })

    // メインスレッドで音声認識を開始
    viewModelScope.launch(Dispatchers.Main) {
        try {
            speechRecognizer.startListening(speechRecognizerIntent)
        } catch (e: Exception) {
            e.printStackTrace()
            onError(SpeechRecognizer.ERROR_CLIENT) 
        }
    }
}

2.6 Geminiの回答を画面に表示する

最後に、添付画像のようにGeminiからの回答を表示します。

下記が実装部分になります。

sendTextToGeminiメソッドの返り値の文字列を非同期でgeminiResponseという変数に代入して実装しています。

var geminiResponse by remember { mutableStateOf("Gemini Response will appear here.") }

-------------中略-------------

Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(Color.Black),
contentAlignment = Alignment.Center
) {
    Text(
        text = geminiResponse,
        fontSize = 24.sp,
        color = Color.White,
    )
}

3.まとめ

本記事ではGeminiを使用した音声入力チャットボットの実装方法を紹介しました。

AndroidXRアプリに組み込んでいますが、音声認識の実装やパーミッション周りの実装は、ほぼAndroidアプリの実装から流用することができます。これはKotlinで実装する大きな利点だと感じました。

Gemini Developer APIは無料枠があるので、気軽に試してみてください!