オンデバイス AI を活用して、堅牢なハンズフリー オーディオ エクスペリエンスを構築する方法をご紹介します。グラス アクティビティの設定、マイクとカメラフィードのキャプチャ、音声やその他の音声キューを究極の UI として使用する方法など、必要なすべてのことを説明します。Google AI Edge ギャラリー アプリに組み込まれたオープンソースの XR Language Teacher の実世界でのエキサイティングなショーケースを通じて、Gemini Nano 4(Gemma 4 をベースに構築され、Android デバイス向けに最適化)と ML Kit をオーケストレートする方法を学び、LiteRT ランタイムを活用して、自然に聞き、見て、話す高速な XR エクスペリエンスを構築する方法を学びます。
文字起こし
皆さん、こんにちは。
皆さん、本日はお集まりいただきありがとうございます。
ご参加いただきありがとうございました。
Amy です。
本日は、同僚のアリスとともに、Gemini Nano 4 を使用してスマートグラス向けの優れたオーディオ ファースト エクスペリエンスを構築する方法についてお話しします。
では、始めましょう。
インテリジェントなアイウェアとは、一日中着用できる、ポータブルで軽量なメガネのことです。
このカテゴリには、ディスプレイ付きのメガネとオーディオのみのメガネの両方が含まれます。これらのデバイスでは、さまざまなハードウェアが使用されています。
まず、タップやスワイプなどのユーザー入力に使用できるタッチパッドがあることに気づくでしょう。
スピーカー、カメラ、マイクも搭載されています。
また、ディスプレイの有無や、これらの組み合わせも考えられます。
本日は、オーディオ ファーストと、魅力的でインテリジェントな外出先でのエクスペリエンスを構築する方法についてお話しします。
音声のみの興味深い点の 1 つは、音声が UI になることです。
また、オンデバイス AI は、オフラインや外出先でのエクスペリエンスを求めているデベロッパーに最適です。
そして、うれしいお知らせがあります。
つまり、すでに Android 向けにビルドしている場合は、すでに Android XR 向けにビルドしていることになります。
既存の Android アプリは、オーディオ グラスに適切に拡張できるように設計されており、Bluetooth 経由で音声通知やその他の音声を配信します。
さらに、XR 向けにアプリを差別化する方法もたくさんあります。
この講演ではオンデバイス モデルについて説明しますが、これは決して網羅的なリストではありません。
クラウドモデルもあります。
Gemini Live API などがあります。
Gemini などのシステム エージェントを統合することもできます。これらすべてが、最適なエクスペリエンスの構築に役立ちます。
では、自然に聞き、見て、話すエクスペリエンスをどのように構築すればよいのでしょうか。
本日は、オンデバイス AI を活用した、堅牢なハンズフリー エクスペリエンスの構築方法について説明します。
ここでは、グラス向けにビルドする方法、アクティビティを設定する方法、必要な権限を取得する方法、会話型 UI に ML Kit を使用する方法など、基本的な手順を説明します。
その後、Alice にバトンタッチして、Gemini Nano 4 を使用した実際の例をいくつか紹介してもらいます。
それでは、コードを詳しく見ていきましょう。
グラスの場合、スマートフォンが重い処理を行い、グラスのコンテンツはスマートフォンからグラスに投影されます。
そのため、最初にセットアップする際に、これらのデバイスをブリッジするために必要なものが 2 つあります。
まず、Jetpack Projected ライブラリが必要です。
この依存関係を追加する必要があります。この依存関係には、投影されたエクスペリエンスを強化する API が含まれています。
dependencies {
implementation("androidx.xr.projected:projected:1.0.0-alpha13")
}また、メガネ専用の新しいアクティビティを作成する必要もあります。
class ProjectedMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// Glasses content
}
}
}次に、既存のアプリとこの新しいアクティビティの両方で Jetpack Projected ライブラリを活用して、必要に応じて 2 つのデバイスがスムーズに通信できるようにします。
通常のアクティビティと同様に、マニフェストで宣言します。
<activity
android:name=".ProjectedMainActivity"
android:exported="false"
android:requiredDisplayCategory="android.hardware.display.category.XR_PROJECTED"
android:label="Example activity for audio glasses">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.XR_PROJECTED_LAUNCHER"/>
</intent-filter>
</activity>主な違いは、requiredDisplayCategory として XR_PROJECTED を使用する必要があることです。
これは、このアクティビティがアイウェア用であることをシステムに知らせるためです。
また、ここではインテント カテゴリ XR_PROJECTED_LAUNCHER も使用します。
これにより、Gemini がアプリを検出できるようになり、ユーザーは外出先でもハンズフリーでアプリを起動できるようになります。
それでは
アクティビティを設定したら、前述のとおり、Projected ライブラリを使用してアプリ エクスペリエンスを強化します。
このライブラリには、グラスのすべての機能を構築するために必要なツールがすべて含まれています。
ここで使用する主な API をいくつか見ていきましょう。
まず、isProjectedDeviceConnected メソッドについて説明します。
// Continuously monitor whether the glasses are connected
val isGlassesConnected by ProjectedContext.isProjectedDeviceConnected(
context,
scope.coroutineContext
).collectAsStateWithLifecycle(initialValue = false)
// Launch the activity
val options = ProjectedContext.createProjectedActivityOptions(context)
val intent = Intent(context, ProjectedMainActivity::class.java)
context.startActivity(intent, options.toBundle())
// Check if the device is audio only or has a display
val projectedDeviceController = ProjectedDeviceController.create(this@ProjectedMainActivity)
isVisualUiSupported = projectedDeviceController.capabilities.contains(CAPABILITY_VISUAL_UI)これにより、グラスの接続を継続的にモニタリングします。
これをデバイスのライフサイクルと組み合わせて使用することで、グラスが実際に使用可能になるタイミングを把握できます。つまり、ペア設定や電源オンのタイミングだけでなく、装着されて使用可能になるタイミングも把握できます。
これが、そう、
これにより、アクティビティを開始するタイミングを正確に把握できます。
そのためには、アクティビティを起動する準備が整ったときに、アクティビティの開始時に設定する必要がある特定のオプションがあります。
これらについては、ProjectedContext の createProjectedActivityOptions を使用します。
そして、インテントのコンテキストでそれを使用します。
もう一つ、使用するツールとして ProjectedDeviceController があります。
お客様が使用しているメガネの種類を確認する方法は次のとおりです。
先ほど、スマートグラスにはディスプレイのみのメガネとオーディオのみのメガネがあると説明しました。この CAPABILITY_VISUAL_UI が機能に含まれているかどうかを確認することで、アプリをさまざまなデバイスに適切に適応させ、そのデバイスに最適化されたエクスペリエンスを調整できます。
この差別化されたエクスペリエンスを構築する際には、デバイスのハードウェアにアクセスして、たとえば、グラスのカメラを活用したいと思うかもしれません。
そのため、投影されたアクティビティからこれを行うには、ProjectedActivityCompat を使用して適切な権限をリクエストする必要があります。
// In a projected activity
private fun requestProjectedPermissions() {
lifecycleScope.launch {
try {
ProjectedActivityCompat.requestPermissions(
this@PermissionsFromProjectedActivity,
arrayOf(Manifest.permission.CAMERA),
// CAMERA_PERMISSION_REQUEST_CODE is a developer defined constant
requestCode = CAMERA_PERMISSION_REQUEST_CODE,
)
} catch (e: IllegalStateException) {
// Thrown when the projected system service can't be bound.
Log.e(TAG, "Request failed: projected service unavailable.", e)
}
}
}また、スマートフォンに必要な権限がアプリにすでに付与されている場合でも、グラスのハードウェアに固有の権限をリクエストする必要があります。
また、権限を扱う際は、Android 全般のベスト プラクティスを念頭に置いてください。
マニフェストで必要な権限を宣言し、権限が必要なコンテキスト内で権限をリクエストします。アプリの機能に必要な権限のみをリクエストしてください。
もちろん、このことは developer.android.com のサイトに記載されています。
また、新しいスキルとして、投影ハードウェア スキルも追加されました。
Android Studio の Android CLI でこのスキルを使用して権限ワークフローを設定できます。このスキルにアクセスできるようになると、他のすべての Android スキルにもアクセスできるようになります。
android skills add projected-hardware
android skills add --all接続が完了し、ハードウェアの機能が把握でき、適切な権限が付与されたので、オーディオ エクスペリエンスを構築するためのツールについて説明します。
そのため、音声ファーストのエクスペリエンスを設計する際には、ユーザーに高品質のエクスペリエンスを提供できるようにする必要があります。
これには、いくつかの重要なインタラクションが含まれる可能性があります。
1 つ目は、音声を出力として使用することです。つまり、ユーザーに自然に話しかける音声を使用します。
次に、イヤーコンを使用して特定の音声信号をユーザーに送信します。
また、タップやスワイプによるタッチパッドからの入力や、アプリへの音声入力など、ユーザーからの入力も処理します。
まず、音声を出力として使用することについて説明します。
そこで、ML Kit の新しい GenAI Text To Speech ライブラリをご紹介します。
この機能は現在、一部の Google Pixel デバイスと Samsung デバイスでアルファ版として利用できます。
これは、特に音声ファーストのエクスペリエンスにおいて、非常に期待されている機能です。
Text-to-Speech を使用したことがある方なら、システム音声が必ずしも自然に聞こえないことが課題の一つであることをご存じでしょう。
また、他の音声のためにクラウドモデルに接続すると、レイテンシが発生したり、追加費用が発生したりする可能性があります。
そのため、ML Kit では、システム TTS と比較して、より自然な音声が生成されます。
ML Kit には、アプリに合わせて選択できる、異なる特徴を持つ 8 種類の音声が用意されています。
また、ML Kit は 21 種類の言語 / 地域に対応しており、強力な言語サポートを提供しています。
さらに、大きなメリットとして、最初のダウンロード後は完全にデバイス上で動作します。
このモデルはシステムにダウンロードされ、アプリ間で共有されるため、APK のサイズが増えることはありません。
また、オフラインでも利用できるため、外出中のメガネ ユーザーにとって特に重要です。Wi-Fi が利用できない場合や、電波状況が悪い場合でも、サーバーへの往復呼び出しを行う必要がないため、パフォーマンスが向上する可能性があります。
それでは
では、ライブラリの詳細と、ライブラリを使用してビルドする方法について見ていきましょう。
まず、依存関係を追加する必要があります。
dependencies {
// ML Kit GenAI TTS SDK
implementation("com.google.mlkit:genai-text-to-speech:1.0.0-alpha1")
}必要なのは、ML Kit の Text To Speech SDK だけです。
これで、コードの記述を開始できます。
そのため、GenAI ML Kit ライブラリには Speech Synthesizer API が含まれています。
// 1. Initializing the SpeechSynthesizer client.
val options = SpeechSynthesizerOptions.Builder(Locale.US).build()
val client = SpeechSynthesis.getClient(options)
// 2. Check and download the voice model.
val voiceInfo = client.getSupportedVoices().firstOrNull()
if (voiceInfo != null && client.checkStatus(voiceInfo) == FeatureStatus.DOWNLOADABLE) {
client.download(voiceInfo).collect { status -> println("Download status: $status") }
} else if (voiceInfo == null) {
// Tip: Fallback to system TTS if ML Kit voice is unavailable
}これにより、選択した音声を簡単に実装できます。
ここで行う最初のステップは、SpeechSynthesizer を再度使用してクライアントを初期化し、選択した言語 / 地域を設定することです。
次に、ダウンロード ステータスを確認し、音声を選択します。
この例では、最初の音声を選択していますが、firstOrNull を使用していることにも注目してください。
前述のとおり、ML Kit には特定の対応スマートフォン(Samsung または Google Pixel デバイス)が必要です。
null 値が返された場合は、ユーザーが対応するスマートフォンを使用していない可能性があります。
そして、システムのテキスト読み上げに戻るか、録音されたメッセージを再生するかなど、フォールバックが適切に設定されていることを確認します。
次に、speech synthe- SpeechSynthesizerRequest を使用してリクエストを作成し、入力テキストを指定して、使用方法とコンテンツ タイプを設定します。
// 3. Construct the SpeechSynthesizerRequest
val request = SpeechSynthesizerRequest(
input = SpeechSynthesizerRequest.Input.Text("Hello world", voiceInfo),
audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
// 4. Speak and collect status
client.synthesize(request).collect { response ->
when (response) {
is SpeechSynthesizerResponse.StatusUpdate -> println("Playback status: ${response.status}")
}
}音声を再生し、ステータスをモニタリングして、音声が完了したことを確認し、発生する可能性のあるエラーを収集します。
必要に応じてリソースをクローズします。
// 4. Speak and collect status
client.synthesize(request).collect { response ->
when (response) {
is SpeechSynthesizerResponse.StatusUpdate -> println("Playback status: ${response.status}")
}
}
// 5. Release resources when finished (e.g., on Activity destroy)
override fun onDestroy() {
super.onDestroy()
client.close()
}これは非常にわかりやすいですね。
これらの手順により、アプリのサウンドをこのようなものから…
Android デベロッパーの皆さん、こんにちは。
皆様にお会いでき、大変嬉しく思います。
オーディオ ファースト エクスペリエンスに関するプレゼンテーションをお楽しみいただけているでしょうか。
次のような音声に変換されます。
Android デベロッパーの皆さん、こんにちは。
皆様にお会いでき、大変嬉しく思います。
オーディオ ファースト エクスペリエンスに関するプレゼンテーションをお楽しみいただけているでしょうか。
かなり改善されましたね。
違いますね。
ML Kit のテキスト読み上げライブラリは非常に強力なツールですが、これらの音声を使用するだけでなく、このライブラリでイヤーコンを再生することもできます。
// 1. Register Earcon resource with Builder
SpeechSynthesizerOptions.Builder(Locale.US)
.addEarcon(
name = "shutter_sound",
Uri = "path/to/earcon.file".toUri()
)
.build()
// 2. Request Earcon playback
val request = SpeechSynthesizerRequest(
input = SpeechSynthesizerRequest.Input.Earcon(
name = "shutter_sound"
),
audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes
.USAGE_ASSISTANCE_SONIFICATION)
.setContentType(AudioAttributes
.CONTENT_TYPE_SONIFICATION)
.build()
)イヤーコンは、その名のとおり、基本的にはアイコンの音声版です。
これにより、短い音を再生してユーザーとコミュニケーションを取ることができます。
イヤーコン付きの視覚的なアイコンと同様に、ユーザーはこれらが何であるか、そして何を意味するのかを知る必要があります。
そのため、最初は音声通知と組み合わせて、わかりやすくするとよいでしょう。
Android Design Hub で利用できる標準化されたサウンドをすでにいくつか作成しています。それらをダウンロードして、アプリの起動、アプリの終了、エラー通知、ミュート、ミュート解除などに使用できます。
また、独自のカスタム音声アイコンを作成することもできます。
イヤコンについては、Design Hub からダウンロードしたものを使用する場合でも、独自に作成する場合でも、おすすめのベスト プラクティスがいくつかあります。
そのため、イヤーコンの使用方法や使用するタイミングを慎重に検討する必要があります。
いくつか注意すべき点があります。たとえば、まず、サウンドアイコンを使いすぎないようにしてください。
同じ音を何度も繰り返さないでください。これは、いわゆる耳の疲労を引き起こす可能性があります。
また、イヤーコンは、直感的で、実装時に聞き慣れたピッチとリズムを使用する必要があります。
また、説明が必要なほど複雑なイヤーコンは避けてください。そのような場合は、音声通知の方が適している可能性があります。
また、スタジオやシステム、パソコンのスピーカーでは微妙に聞こえるものが、ユーザーが繰り返し再生すると不快に感じられる可能性があることを常に念頭に置いてください。
ユーザーを混乱させたり、重要なアラートを無視させたりするような音でユーザーに過度な負担をかけないようにしてください。
それでは
そのため、自然な音声やイヤーコンを使用して、音声による合図やフィードバックを提供できるようになりました。
では、入力はどうでしょうか。
もちろん、タッチパッドや専用ボタンもデバイスに搭載されており、タップやスワイプの操作に使用できます。
しかし、この会話型 UI を許可したい場合もあるでしょう。その場合は、ML Kit の GenAI Speech Recognition API を使用できます。
現在はアルファ版で、一部のデバイスで利用できますが、基本的には 2 つの異なるモードで動作します。
基本モードは、従来のオンデバイス音声認識を使用するもので、API レベル 31 以降の Android デバイスで利用できます。
また、Gemini Nano を使用する詳細モードもあります。これは Google Pixel 10 以降のデバイスで提供されており、品質と対応言語が向上しています。
そのため、SpeechRecognizer は SpeechSynthesizer と非常によく似たコードパターンに従います。
クライアントを作成し、モデルが利用可能かどうかを確認し、必要に応じてダウンロードし、リクエストを作成して、リソースを解放します。
ここでは、リクエストの作成、マイク音声のキャプチャ、結果の処理における主な違いに焦点を当てましょう。
ここでも、必要な依存関係を追加する必要があります。
dependencies {
// ML Kit Speech Recognition
implementation("com.google.mlkit:genai-speech-recognition:1.0.0-alpha1")
}ここで必要なのは Speech Recognition API だけです。
その後、リクエストの作成を開始できます。
そのため、認識ロジックを定義するときに suspend 関数を使用します。
suspend fun startMyRecognition() {
// choose locale and mode
val options: SpeechRecognizerOptions =
speechRecognizerOptions {
locale = Locale.US
preferredMode = SpeechRecognizerOptions.Mode.MODE_ADVANCED
}
val speechRecognizer: SpeechRecognizer = SpeechRecognition.getClient(options)
val request: SpeechRecognizerRequest =
speechRecognizerRequest { audioSource = AudioSource.fromMic() }
...次に、音声認識オプションを指定して、言語 / 地域と preferredMode を設定します。
また、ここで audioSource を選択し、リクエスト自体を定義します。
次に、認識を開始してレスポンスを収集します。
speechRecognizer.startRecognition(request).collect { response ->
when (response) {
is SpeechRecognizerResponse.PartialTextResponse -> {
// Real-time: Act immediately if trigger phrase is heard
handleResponse(response.text, speechRecognizer)
}
is SpeechRecognizerResponse.FinalTextResponse -> {
// Final result and recognition
handleResponse(response.text, speechRecognizer)
}
is SpeechRecognizerResponse.CompletedResponse -> {
resetListeningUi()
}
is SpeechRecognizerResponse.ErrorResponse -> {
Log.e("SpeechRec", "Error: ${response.e.errorCode}", response.e)
resetListeningUi()
}
...SpeechRecognizer では、部分、最終、エラー、完了の 4 つのレスポンスが返される可能性があります。
部分的な結果はリアルタイム ストリームを対象とするため、ホットワードやキーワードなどの検出に使用されます。
final は、完全に分析して更新した後の最終的な音声ストリームを取得します。completed は、音声が完了した後に取得します。error は、発生する可能性のあるエラーを収集します。
これらをすべて組み合わせる方法は次のとおりです。
// Helper function to check for multiple phrases and trigger the right action
private suspend fun handleResponse(text: String, recognizer: SpeechRecognizer) {
val savePhrases = listOf("save", "bookmark", "remember")
if (savePhrases.any { text.contains(it) }) {
// 1. Update app state immediately
saveItemForLater()
// 2. Stop the recognizer to enforce the action and prevent TTS echo
recognizer.stopRecognition()
// 3. Play the mute earcon for immediate, non-verbal confirmation
playEarcon()
// 4. Provide voice feedback
playAudioFeedback("Saved")
}
}たとえば、ユーザーが音声コマンドを使用して、アプリに特定の種類のコンテンツを保存して後で利用できるようにしたいとします。
使用するように指示した単一の明示的な音声コマンドを使用することも、複数のキーワードやフレーズを使用できるようにすることもできます。
ここでは、save、bookmark、remember の使用を許可しています。
SpeechRecognizer は、ユーザーがこれらのフレーズのいずれかを使用したことを検出すると、コンテンツを保存して音声認識を停止し、カスタム イアコンを再生して、ML Kit の GenAI Text to Speech を使用してコンテンツが保存されたことをユーザーに伝えます。
違いますね。
ここまで、多くのことを説明してきました。
少し抽象的に感じられるかもしれません。
私と同じように、実際の例を見たいという方もいらっしゃるでしょう。
アリスと私は、まさにその点について説明したいと考えています。
彼女が Google AI Edge Gallery アプリの XR 言語教師について説明します。
アリスのチームが構築したこのシステムについて、アリスがその仕組みを詳しく説明します。
では、よろしくお願いします。
Amy、ありがとう。
うん
そこで、皆さんの多くがご存じのことから始めたいと思います。
私は日本語を勉強していて、語学学習アプリ、フラッシュカード、連続学習など、あらゆる方法に何時間も費やしてきました。
そして、東京に到着し、市場に入ると、目の前にあるもののほとんどが、私が勉強した語彙ではないことに気づきました。
市場の屋台に木製のけん玉が置いてあります。見た目は知っていますが、日本語で何と言うのかわかりません。
ショーウィンドウに飾られているオーナメントを買いたいのですが、名前がわかりません。
その瞬間こそ、単語を学ぶ意欲が最も高まるタイミングです。
ソファに座って語学アプリを見ているだけではだめです。
その瞬間、私は学びたいものに目を向けています。
そこで、私が求めているのは、
スマートグラスを 1 日中装着して、歩き回りたいです。
気になるものがあれば、声に出して質問するだけです。
そして夜、ホテルに戻ると、質問した内容がすべて単語カードのようにスマートフォンに表示される。
そして、これが構築したものです。
短いクリップを見てみましょう。
そこで、私たちはこれを XR 言語教師と呼んでいます。
お問い合わせいただきありがとうございます。
本日はどのようなことをお知りになりたいですか?
また、教師の設定で学習エクスペリエンスをカスタマイズできます。
たとえば、学習したい言語を選択し、自分に合った特定の教師ペルソナを選択します。
うわぁ
また、メガネモードとスマートフォン モードを切り替えることができます。
「これらのブドウ」はスペイン語でなんて言う?
これを見ると、おいしそうなブドウは「uvas」と呼ばれています。
スペイン語では、「Estas uvas son deliciosas」と言って、ぶどうを説明できます。
特に緑色のブドウと言いたい。
詳細を確認いたします。
緑色のブドウを指定するには、「Uvas verdes」と言います。
Me gustan las uvas verdes」は、緑色のブドウが好きであることを伝えるのに最適な表現です。
これはフランス語で何と言いますか?また、3 つ注文するにはどう言えばよいですか?
見てみましょう。
おいしそうな焼き菓子は、マドレーヌと呼ばれています。
フランス語で 3 つ注文するには、「Je voudrais trois madeleines, s'il vous plaît.」と言います。
この件について変更を依頼するにはどうすればよいですか?
何があるか見てみましょう。
おつりを求めるには、「おつりはありますか?」と言います。
「お釣りはありますか?」という意味です。
「お釣りをお願いします」と言って、丁寧にお釣りを求めることもできます。
学習した内容はすべて、語彙カードとして自動的に保存されます。
La botella de agua.
Esta es una botella de agua.
そう、[学習] タブで記憶力をテストしたり、練習したりできる。
それでは
うん
ご覧いただいたものはすべてデバイス上で実行されており、ネットワークは一切介在していません。
私は海外データプランを利用して旅行していますが、通常は問題ありません。ただし、市場の建物内、地下、厚いコンクリートの裏などに入ると、電波が届かなくなります。
この機能が最も必要とされるのは、多くの場合、電波状況が最も悪いときです。
電波が届かなくなると使えなくなるようなものは、本当の旅のパートナーとは言えません。そのため、XR Language Teacher は完全にオフラインで、デバイス上で動作するようにしました。
では、これはどのように構築されたのでしょうか。
アーキテクチャの観点から、Amy はすでに、グラスからスマートフォンに投影されたカメラフレームを使用する方法を説明しました。
ML Kit の音声認識を使用してテキストを音声に変換する方法と、ML Kit の Text-to-Speech を使用してテキストを音声に戻す方法を説明しました。
まだ必要だったのは、カメラのフレームと質問を一緒に見て、ユーザーが実際に何を尋ねているのかを判断できる、中間に位置する頭脳です。
その頭脳が Gemini Nano 4 です。これは、スマートフォン上でローカルに動作する Google のオープンソース モデルである Gemma 4 をベースに構築されています。
そのため、サポートされているフラッグシップ Android デバイスでは、ML Kit API を直接使用することで、最適化されたパフォーマンスを得ることができます。
Android システムがモデルの配信を処理するため、アプリにバンドルする必要はありません。
これらをまとめると、マイクが音声認識に入力され、カメラフレームと文字起こしが Gemini Nano 4 に入力されます。Gemini Nano 4 は音声合成に出力し、スピーカーから出力されます。
3 つのモデルが連携して、すべてスマートフォン上でローカルに実行されます。
Gemini Nano 4 がこの問題に特に適している理由はいくつかあります。
まず、1 回のパスで見て読み取ります。
ML Kit を通じて、Gemini Nano 4 はテキストと画像をネイティブに処理するため、検出機能、分類機能、翻訳機能を使用する必要がありません。
これは、カメラのフレームと質問を一緒に確認するモデルです。
2 つ目は、幅広い言語に対応していることです。
Gemini Nano 4 は 140 以上の言語でトレーニングされています。
また、言語チューターの場合、翻訳だけでは不十分です。
慣用句、フォーマルな表現とカジュアルな表現、実在する人物が話すような例文も必要です。
最後に、パーソナリティを持たせることができるほど、制御性に優れています。
発音を声に出して練習するのは、気が引けるかもしれません。
このモデルはシステム プロンプトに適切に従うため、ユーザーは独自の教師ペルソナを選択できます。
忍耐強く励ましてくれる AI なら、人々はもっと挑戦したくなるかもしれません。
このデザインについて、もう 1 つ強調したいことがあります。
会話がメガネモードで行われている間、アプリはバックグラウンドで静かに語彙カードを作成しています。
単語、翻訳、例文、質問した瞬間の写真。
ユーザーが手動でタップする必要はありません。
歩きながら話しているだけです。
そしてその日のうちに、すべてがスマートフォンに届きます。これはまさに、私が東京にいたときに求めていたものです。
つまり、音声ファーストは音声のみを意味するわけではありません。
グラスは好奇心を満たすのに適したデバイスであり、スマートフォンは後で振り返るのに適したデバイスです。
ハンズフリーの瞬間を思い出に残しましょう。
ここからは、このデモの構築から得られた、Gemini Nano 4 と ML Kit を使用してリアルタイム エクスペリエンスを構築するための実践的なヒントをいくつかご紹介します。
ヒント 1: 2 パス設計でリアルタイムのレイテンシを優先する。
オーディオ グラスで「これは何?」と尋ねたとき
沈黙の瞬間はすべて目立ちます。
XR 言語教師の例では、ユーザー インタラクションの各ターンで、モデルから 2 つのものが必要です。1 つは即時の音声回答、もう 1 つはアプリに保存する構造化された語彙カードです。
1 回のパスで 2 つのことをモデルに要求すると、バックグラウンド データの生成に費やされたすべてのトークンが、最初の発話文を遅らせることになります。
そこで、ユーザーが今必要としているものと、アプリが数秒後に必要とするものを分離します。
そこで、各ターンを 2 つのパスに分割します。
パス 1 では、カメラフレームと音声による回答の質問のみをストリーミングし、最初の文が完了するとすぐにテキスト読み上げをトリガーします。
TPU や NPU は、テキスト読み上げが完了するずっと前に短い返答の生成を終えるため、ユーザーがまだ聞いている間に、チップがアイドル状態になる数秒間のウィンドウが発生します。
また、パス 1 でテキストによる回答でオブジェクトがすでに特定されているため、パス 2 では画像入力さえ必要ありません。
Pass 1 の返信をそのアイドル状態のチップに渡して、教師が話し終える前に構造化されたカードを抽出して保存します。
では、パス 1 を拡大してみましょう。ここで 2 つ目のヒントです。
ストリーミング時に軽量の XML タグを使用する。
パス 1 はテキスト読み上げに直接ストリーミングされるため、最初の発話が重い JSON オブジェクトによって遅延しないようにする必要があります。
代わりに、アプリがストリームの途中で実践する必要があるときに、軽量のインライン タグですぐに読み上げることができる書式なしテキストが必要です。
では、ここに至るまでの問題について説明します。
先ほど、市場の屋台で売られていた木製のけん玉についてお話ししました。
日本語では「けん玉」と呼ばれています。
初期の段階で、モデルはカメラのフレームからけん玉を認識し、「それはけん玉という木製のおもちゃです」という正しい答えを返しました。
そこで、この完全な文を英語のテキスト読み上げセッションに渡したところ、次のような結果が得られました。
これは「けんみ」という木製のおもちゃです。
それは正しくないようです。
代わりに、次のように言うべきです。
これはけん玉という木製のおもちゃです。
では、なぜこのようなことが起こったのでしょうか。
けん玉は、ひらがなの「けん」と漢字の「玉」で書かれています。
英語のテキスト読み上げエンジンは、「ken」のようなひらがなの音声は問題なく処理しますが、漢字の部分に差し掛かると完全に破綻し、「dama」を「me」のように発音します。
ロケールを切り替えないと、さまざまな言語でさまざまな方法でこの問題が発生します。
たとえば、スペイン語では次のように聞こえることがあります。
Suh-BOH-luh…
実際に期待しているのは…
Cebolla。
同様に、フランス語では次のように聞こえます。
PAIN oh chock-uh-lat…
実際に期待しているのは…
パン・オ・ショコラ。
はい。TTS セッションは通常、一度に 1 つの言語 / 地域向けに設計されていますが、語学教師はすべての文でコードスイッチングを行う必要があります。
そのため、パス 1 では、モデルに、ストリーミング時にすべてのターゲット言語の単語やフレーズをターゲット XML タグでラップするよう指示します。
チャンクが到着すると、クライアントはターゲットで分割し、各スパンを一致するテキスト読み上げロケール(日本語、スペイン語、フランス語、学習中の言語など)にルーティングします。その後、それらをシームレスに再生し、文の途中でアクセントを切り替えることで、「けん玉」のような単語が正しく発音されます。
ここでのポイントは、モデルはどの語彙がどの言語に属しているか、どの単語がどの言語に属しているかをすでに知っているということです。ストリーミング中にターゲット スパンをインラインでタグ付けすることで、後でこの全体を自分で後処理するよりもはるかにクリーンになります。
コードでのパス 1 は次のとおりです。
// 1. Pass 1 System + Turn Prompt (XrLanguageTeacherPersona.kt)
// System: "Always start immediately with <reply>...</reply> (strictly 1 to 3 sentences total).
// Inside <reply>, wrap every Japanese word AND every complete Japanese example sentence
// inside <target>...</target> tags (never output <card>)."
val pass1TurnPrompt = """
The student is looking at the camera view and asked: "$question". Answer in English
teaching Japanese inside <reply>...</reply>: if they refer to 'this'/'that' or point to
an object, ground it to the focal item in the camera frame; if they ask for a phrase or
greeting, teach that phrase.
""".trimIndent()
// 2. Stream multimodal dialogue (ImagePart + TextPart) and split <target> tags to TTS
val generativeModel = Generation.getClient()
val pass1Request = generateContentRequest(ImagePart(frame), TextPart(pass1TurnPrompt)) {
temperature = 1.0f; topK = 1; maxOutputTokens = 1024
}
generativeModel.generateContentStream(pass1Request).collect { chunk ->
val delta = chunk.candidates.firstOrNull()?.text.orEmpty()
streamReplyAndSplitTargetTagsToTts(delta) // Speaks 1st complete sentence immediately
}
// Streams: "<reply>That's <target>けん玉</target> — a wooden toy. <target>けん玉をください。</target></reply>"上部では、プロンプトを使用して、モデルに reply タグ内で 1 ~ 3 文で回答し、すべての日本語の単語または例文を target タグで囲むように指示しています。
次に、カメラ ImagePart とプロンプト TextPart を ML Kit の generateContentStream に渡し、各文が到着するたびにターゲット スパンをテキスト読み上げに分割します。
ここで、2 回目のパスと 3 つ目のヒントについて説明します。
軽量の XML タグはライブ音声ストリームに適していますが、アプリにデータを保存する場合は、クリーンな型付きオブジェクトを返すように構築された構造化出力を使用します。
XR Language Teacher は会話形式であるため、ユーザーは新しい単語が教えられていない場合でも、フォローアップの質問をすることができます。
また、単語を教えるときには、データベースにクリーンな辞書フィールドが必要で、それを使って語彙カードを作成します。
そのため、パス 1 の文字起こしを解析するために脆弱な正規表現を記述する代わりに、パス 2 でバックグラウンドで型安全なオブジェクトを抽出します。
パス 2 でスキーマを適用するには、ML Kit の構造化出力 API を使用します。この API は、内部で制約付きデコードを使用しています。
import com.google.mlkit.genai.prompt.Generation
import com.google.mlkit.genai.prompt.generateTypedContentRequest
import com.google.mlkit.genai.schema.annotations.Generable
import com.google.mlkit.genai.schema.annotations.Guide
// Pass 2 schema (XrLanguageTeacherGenerableSchemas.kt):
@Generable(description = "Vocabulary flashcard for the taught word or short phrase")
data class GenerableVocabCard(
@Guide(description = TeacherResponseSchema.WORD_FIELD_DESCRIPTION) val word: String,
@Guide(description = "Native-language meaning or translation") val meaning: String,
@Guide(description = "Target-language example sentence") val example: String,
@Guide(description = "Native-language translation of the example sentence") val example_translation: String,
)
@Generable(description = "Optional vocabulary card extracted from a teacher reply")
data class GenerableOptionalVocabCardWrapper(
@Guide(description = "Vocabulary flashcard if a vocabulary term was taught, or null otherwise")
val card: GenerableVocabCard?
)この単語カードクラスの例のように、データクラスに @Generable アノテーションを付け、各フィールドに @Guide アノテーションを付けるだけです。
また、null 許容の card フィールドをラップすることで、パス 2 は、新しい単語が教えられた場合は入力済みのカードを返し、純粋な会話のターンの場合は null を返します。
コードでのパス 2 は次のとおりです。
// 1. Pass 1 Streamed Output -> Pass 2 Extraction Prompt (no image needed!)
val replyWithTargetTags =
"<reply>That's <target>けん玉</target> — a wooden toy. <target>けん玉をください。</target></reply>"
val pass2Prompt = """
If the teacher reply below teaches or introduces a specific Japanese vocabulary item, extract
only its core focal dictionary term into "card" with keys "word", "meaning", "example", and
"example_translation". If purely conversational with no focal term taught, set "card" to null:
$replyWithTargetTags
""".trimIndent()
// 2. Extract @Generable card on idle NPU while TTS audio plays
val pass2Request = generateContentRequest(TextPart(pass2Prompt)) {
temperature = 1.0f; topK = 1; maxOutputTokens = 256
}
val typedRequest =
generateTypedContentRequest(pass2Request, GenerableOptionalVocabCardWrapper::class)
val card: GenerableVocabCard? =
generativeModel.generateContent(typedRequest).candidates.firstOrNull()?.response?.card
// Extracts: {"word": "けん玉", "meaning": "a wooden toy", "example": "けん玉をください。", ...}上部では、パス 1 のストリーミングされた返信テキストを取得し、画像は必要なく、抽出プロンプトを作成します。
次に、ユーザーがテキスト読み上げをまだ聞いている間に、プロンプトとラッパークラスを generateTypedContentRequest に渡し、タグのないクリーンな単語カードを抽出します。単語が教えられていない場合は null を抽出します。
それでは
多くのことを取り上げました。
この時点で、「ステージ上では素晴らしく見えるが、自分には関係ない」と考えるのは当然です。
これは違います。
XR Language Teacher は、他のオンデバイス AI エクスペリエンスとともに Google AI Edge Gallery アプリに搭載されています。
今すぐダウンロードして、自分のスマートフォンにインストールし、別の言語でどのように言うかを知りたいものにカメラを向けることができます。
この実装全体は GitHub でオープンソースとして公開されているため、どのように構築されているかを確認できます。
すべてのユーザーがオーディオ グラスやディスプレイ グラス、またはサポートされているフラッグシップ Android デバイスをすでに持っているわけではないので、心配しないでください。
Google のコードベースには、同じエクスペリエンスをモバイル専用のエクスペリエンスに適応させる方法の例も示されています。
他のプラットフォームにも対応したい場合は、Google AI Edge Gallery アプリのコードで、Google LiteRT ランタイムと MediaPipe Tasks API を使用してこの機能を構築する方法も確認できます。
また、他のオープンソースのオンデバイス音声モデルや言語モデルを使用する場合も、コードからその方法を確認できます。
そこで、プロトタイピングを容易にするために、XR 言語教師をオープンソース化しました。
お好みのエージェントを GitHub リポジトリに指定して、このライブのオンデバイス AI 搭載エクスペリエンスを別のものに変えることができます。
美術館のガイド、カウンターを見ている料理アシスタント、修理のチュートリアル、ユーザー補助のナレーター、現場検査ツールなど、さまざまな用途が考えられます。
モデル、ランタイム、オープンソース実装、ツールはすべて今すぐ利用可能で、この秋に予定されているインテリジェント アイウェアのリリースに備えることができます。
このライブラリをぜひご活用ください。
最後に、本日は、デバイス上でシームレスに動作する AI モデルで実現できることの強力な例をご紹介しました。
開始するには、いくつかの方法があります。
まず、画面に表示されているリソースを入手します。
AI Edge ギャラリー アプリをダウンロードしてデモを試し、GitHub のリファレンス コードを確認してください。
QR コードをスキャンして、Android XR SDK、ML Kit API、Google AI Edge のサービスにアクセスしてください。
2 つ目は、今すぐスマートフォンで試験運用版を試すことです。
今回ご紹介した機能の多くは、モバイル ファーストのエクスペリエンスですでに構築できます。
3 つ目は、すでにリリースしているモバイルアプリを見て、どのコア機能がハンズフリーに適しているかを自問することです。
既存の APK に Projected Activity を追加するだけで、そのエクスペリエンスをグラスにも提供できます。
本日はご参加いただき誠にありがとうございました。
Android とスマートグラスで、皆様がどのような素晴らしい音声ファーストのエクスペリエンスを構築されるか、楽しみにしております。
ありがとうございました
dependencies {
implementation("androidx.xr.projected:projected:1.0.0-alpha13")
}class ProjectedMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// Glasses content
}
}
}<activity
android:name=".ProjectedMainActivity"
android:exported="false"
android:requiredDisplayCategory="android.hardware.display.category.XR_PROJECTED"
android:label="Example activity for audio glasses">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.XR_PROJECTED_LAUNCHER"/>
</intent-filter>
</activity>// Continuously monitor whether the glasses are connected
val isGlassesConnected by ProjectedContext.isProjectedDeviceConnected(
context,
scope.coroutineContext
).collectAsStateWithLifecycle(initialValue = false)
// Launch the activity
val options = ProjectedContext.createProjectedActivityOptions(context)
val intent = Intent(context, ProjectedMainActivity::class.java)
context.startActivity(intent, options.toBundle())
// Check if the device is audio only or has a display
val projectedDeviceController = ProjectedDeviceController.create(this@ProjectedMainActivity)
isVisualUiSupported = projectedDeviceController.capabilities.contains(CAPABILITY_VISUAL_UI)// In a projected activity
private fun requestProjectedPermissions() {
lifecycleScope.launch {
try {
ProjectedActivityCompat.requestPermissions(
this@PermissionsFromProjectedActivity,
arrayOf(Manifest.permission.CAMERA),
// CAMERA_PERMISSION_REQUEST_CODE is a developer defined constant
requestCode = CAMERA_PERMISSION_REQUEST_CODE,
)
} catch (e: IllegalStateException) {
// Thrown when the projected system service can't be bound.
Log.e(TAG, "Request failed: projected service unavailable.", e)
}
}
}android skills add projected-hardware
android skills add --alldependencies {
// ML Kit GenAI TTS SDK
implementation("com.google.mlkit:genai-text-to-speech:1.0.0-alpha1")
}// 1. Initializing the SpeechSynthesizer client.
val options = SpeechSynthesizerOptions.Builder(Locale.US).build()
val client = SpeechSynthesis.getClient(options)
// 2. Check and download the voice model.
val voiceInfo = client.getSupportedVoices().firstOrNull()
if (voiceInfo != null && client.checkStatus(voiceInfo) == FeatureStatus.DOWNLOADABLE) {
client.download(voiceInfo).collect { status -> println("Download status: $status") }
} else if (voiceInfo == null) {
// Tip: Fallback to system TTS if ML Kit voice is unavailable
}// 3. Construct the SpeechSynthesizerRequest
val request = SpeechSynthesizerRequest(
input = SpeechSynthesizerRequest.Input.Text("Hello world", voiceInfo),
audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
// 4. Speak and collect status
client.synthesize(request).collect { response ->
when (response) {
is SpeechSynthesizerResponse.StatusUpdate -> println("Playback status: ${response.status}")
}
}// 4. Speak and collect status
client.synthesize(request).collect { response ->
when (response) {
is SpeechSynthesizerResponse.StatusUpdate -> println("Playback status: ${response.status}")
}
}
// 5. Release resources when finished (e.g., on Activity destroy)
override fun onDestroy() {
super.onDestroy()
client.close()
}// 1. Register Earcon resource with Builder
SpeechSynthesizerOptions.Builder(Locale.US)
.addEarcon(
name = "shutter_sound",
Uri = "path/to/earcon.file".toUri()
)
.build()
// 2. Request Earcon playback
val request = SpeechSynthesizerRequest(
input = SpeechSynthesizerRequest.Input.Earcon(
name = "shutter_sound"
),
audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes
.USAGE_ASSISTANCE_SONIFICATION)
.setContentType(AudioAttributes
.CONTENT_TYPE_SONIFICATION)
.build()
)dependencies {
// ML Kit Speech Recognition
implementation("com.google.mlkit:genai-speech-recognition:1.0.0-alpha1")
}suspend fun startMyRecognition() {
// choose locale and mode
val options: SpeechRecognizerOptions =
speechRecognizerOptions {
locale = Locale.US
preferredMode = SpeechRecognizerOptions.Mode.MODE_ADVANCED
}
val speechRecognizer: SpeechRecognizer = SpeechRecognition.getClient(options)
val request: SpeechRecognizerRequest =
speechRecognizerRequest { audioSource = AudioSource.fromMic() }
...speechRecognizer.startRecognition(request).collect { response ->
when (response) {
is SpeechRecognizerResponse.PartialTextResponse -> {
// Real-time: Act immediately if trigger phrase is heard
handleResponse(response.text, speechRecognizer)
}
is SpeechRecognizerResponse.FinalTextResponse -> {
// Final result and recognition
handleResponse(response.text, speechRecognizer)
}
is SpeechRecognizerResponse.CompletedResponse -> {
resetListeningUi()
}
is SpeechRecognizerResponse.ErrorResponse -> {
Log.e("SpeechRec", "Error: ${response.e.errorCode}", response.e)
resetListeningUi()
}
...// Helper function to check for multiple phrases and trigger the right action
private suspend fun handleResponse(text: String, recognizer: SpeechRecognizer) {
val savePhrases = listOf("save", "bookmark", "remember")
if (savePhrases.any { text.contains(it) }) {
// 1. Update app state immediately
saveItemForLater()
// 2. Stop the recognizer to enforce the action and prevent TTS echo
recognizer.stopRecognition()
// 3. Play the mute earcon for immediate, non-verbal confirmation
playEarcon()
// 4. Provide voice feedback
playAudioFeedback("Saved")
}
}// 1. Pass 1 System + Turn Prompt (XrLanguageTeacherPersona.kt)
// System: "Always start immediately with <reply>...</reply> (strictly 1 to 3 sentences total).
// Inside <reply>, wrap every Japanese word AND every complete Japanese example sentence
// inside <target>...</target> tags (never output <card>)."
val pass1TurnPrompt = """
The student is looking at the camera view and asked: "$question". Answer in English
teaching Japanese inside <reply>...</reply>: if they refer to 'this'/'that' or point to
an object, ground it to the focal item in the camera frame; if they ask for a phrase or
greeting, teach that phrase.
""".trimIndent()
// 2. Stream multimodal dialogue (ImagePart + TextPart) and split <target> tags to TTS
val generativeModel = Generation.getClient()
val pass1Request = generateContentRequest(ImagePart(frame), TextPart(pass1TurnPrompt)) {
temperature = 1.0f; topK = 1; maxOutputTokens = 1024
}
generativeModel.generateContentStream(pass1Request).collect { chunk ->
val delta = chunk.candidates.firstOrNull()?.text.orEmpty()
streamReplyAndSplitTargetTagsToTts(delta) // Speaks 1st complete sentence immediately
}
// Streams: "<reply>That's <target>けん玉</target> — a wooden toy. <target>けん玉をください。</target></reply>"import com.google.mlkit.genai.prompt.Generation
import com.google.mlkit.genai.prompt.generateTypedContentRequest
import com.google.mlkit.genai.schema.annotations.Generable
import com.google.mlkit.genai.schema.annotations.Guide
// Pass 2 schema (XrLanguageTeacherGenerableSchemas.kt):
@Generable(description = "Vocabulary flashcard for the taught word or short phrase")
data class GenerableVocabCard(
@Guide(description = TeacherResponseSchema.WORD_FIELD_DESCRIPTION) val word: String,
@Guide(description = "Native-language meaning or translation") val meaning: String,
@Guide(description = "Target-language example sentence") val example: String,
@Guide(description = "Native-language translation of the example sentence") val example_translation: String,
)
@Generable(description = "Optional vocabulary card extracted from a teacher reply")
data class GenerableOptionalVocabCardWrapper(
@Guide(description = "Vocabulary flashcard if a vocabulary term was taught, or null otherwise")
val card: GenerableVocabCard?
)// 1. Pass 1 Streamed Output -> Pass 2 Extraction Prompt (no image needed!)
val replyWithTargetTags =
"<reply>That's <target>けん玉</target> — a wooden toy. <target>けん玉をください。</target></reply>"
val pass2Prompt = """
If the teacher reply below teaches or introduces a specific Japanese vocabulary item, extract
only its core focal dictionary term into "card" with keys "word", "meaning", "example", and
"example_translation". If purely conversational with no focal term taught, set "card" to null:
$replyWithTargetTags
""".trimIndent()
// 2. Extract @Generable card on idle NPU while TTS audio plays
val pass2Request = generateContentRequest(TextPart(pass2Prompt)) {
temperature = 1.0f; topK = 1; maxOutputTokens = 256
}
val typedRequest =
generateTypedContentRequest(pass2Request, GenerableOptionalVocabCardWrapper::class)
val card: GenerableVocabCard? =
generativeModel.generateContent(typedRequest).candidates.firstOrNull()?.response?.card
// Extracts: {"word": "けん玉", "meaning": "a wooden toy", "example": "けん玉をください。", ...}