Android XR

使用 Gemini Nano 4 为智能眼镜打造音频优先体验

扬声器

关于

演讲简介

了解如何利用设备端 AI 技术打造强大的免提音频体验。我们将涵盖您需要了解的所有内容:设置眼镜 activity、捕获麦克风和摄像头画面,以及使用语音和其他音频提示作为终极界面。通过 Google AI Edge Gallery 应用中内置的开源 XR Language Teacher 的精彩实景展示,学习如何编排 Gemini Nano 4(基于 Gemma 4 构建,并针对 Android 设备进行了优化)和机器学习套件,并利用 LiteRT 运行时构建能够自然听、看和说的快速 XR 体验。

摘要

  • Jetpack Projected 库(Alpha 版)可将 Android 手机与智能眼镜连接起来。向现有应用添加投影 activity,以便将内容投影到显示眼镜或纯音频眼镜。
  • 机器学习套件推出了 Alpha 版设备端语音库,可在部分设备上实现以音频为主的互动。它在 Pixel 10 上提供文字转语音功能(支持 21 个语言区域的 8 种语音)和语音识别功能(支持 Gemini Nano 高级模式)。
  • Gemini Nano 4 会一次性处理相机帧和转写的问题。它在受支持的旗舰手机上本地链接设备端语音模型,从而提供离线体验。
  • 两阶段设计优先考虑智能眼镜上的实时音频延迟。第一轮:使用内嵌 XML 标记流式传输语音文本,以便在句子中间切换语言区域;第二轮:在后台提取结构化数据。
  • 开源 XR Language Teacher 实现包含在 Google AI Edge Gallery 应用中。开发者可以根据其他多模态助理用例调整其代码。

资源

Gemini Notebook

主题

  • intelligent-eyewear
  • android-xr
  • gemini-nano
  • on-device-ai
  • ml-kit
  • speech-recognition
  • 文字转语音

转写文稿

简介

  • 大家好,欢迎各位!

  • 今天很高兴见到大家。

  • 感谢大家的到来。

  • 我是 Amy。

  • 今天,我和同事 Alice 将一起探讨如何使用 Gemini Nano 4 为智能眼镜打造出色的音频优先体验。

  • 下面我们开始步入正题。

  • 因此,我们所说的智能眼镜是指轻巧便携、适合全天佩戴的眼镜。

  • 此类别将包括显示眼镜和纯音频眼镜,这些设备在硬件上会有所不同。

  • 首先,您会注意到它们有一个触控板,可用于用户输入,例如点按和滑动。

  • 此外,这些设备还配有扬声器、摄像头和麦克风。

  • 此外,它们可能配有显示屏,也可能没有,或者可能采用某种组合。

  • 今天,我们将探讨音频优先,以及如何打造引人入胜、智能且随时随地的体验。

  • 纯音频模式的一个有趣之处在于,语音成为了您的界面。

  • 对于希望提供离线和移动体验的开发者来说,设备端 AI 是绝佳选择。

为 Android XR 构建应用

  • 还有一个好消息。

  • 因此,如果您已经在为 Android 开发应用,那么您已经在为 Android XR 开发应用了。

  • 现有的 Android 应用旨在很好地扩展到无屏眼镜,通过蓝牙提供语音通知和其他音频。

  • 如果您想更进一步,为 XR 打造差异化应用,有很多方法可以实现。

  • 因此,在本次演讲中,我们讨论的是设备端模型,但这绝不是详尽无遗的列表。

  • 我们还有云模型。

  • 比如 Gemini Live API。

  • 您可以与 Gemini 等系统智能体集成,所有这些都可以帮助您打造最适合您的体验。

  • 那么,如何打造这种能够自然听、看和说的体验呢?

  • 今天,我们将带您了解如何构建这些由设备端 AI 赋能的强大免触摸体验。

  • 我将带您了解一些基础知识:如何为眼镜构建应用、如何设置 activity、如何获取所需权限,以及如何使用机器学习套件实现对话式界面。

  • 然后,我将把时间交给 Alice,她将通过一些实际示例向您介绍 Gemini Nano 4 的应用。

  • 现在,我们来深入了解一下代码。

  • 因此,在使用眼镜时,手机承担了大部分工作,眼镜内容将从手机投射到眼镜上。

  • 因此,在首次设置时,您需要两样东西来桥接这些设备。

  • 首先,您需要 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
           }
       }
    }
  • 然后,您将在现有应用和这个新 activity 中利用 Jetpack Projected 库,以便两个设备在需要时可以顺畅通信。

  • 因此,就像您见过的任何普通 activity 一样,您需要在清单中声明此 activity。

    <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>
  • 主要区别在于,您确实需要将 XR_PROJECTED 用作 requiredDisplayCategory。

  • 这样系统就知道此 activity 适用于眼镜。

  • 此外,您还需要使用此处的 intent 类别 XR_PROJECTED_LAUNCHER。

  • 这样,Gemini 就能发现您的应用,让用户在出行时免触摸即可启动应用。

  • 好了

  • 因此,如前所述,设置好该 activity 后,您将使用该 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)
  • 您将通过这种方式持续监控眼镜连接。

  • 您可以将此功能与设备生命周期结合使用,以了解眼镜何时真正可用,不仅包括配对或开机时,还包括佩戴并准备好使用时。

  • 这就是,所以,是的。

  • 这样,您就可以确切知道何时可以启动该 activity。

  • 为此,当您准备好启动 activity 时,需要在启动 activity 时设置一些特定选项。

  • 对于这些,您将使用 ProjectedContext 上的 createProjectedActivityOptions。

  • 同样,您需要将该参数与意图中的上下文结合使用。

  • 您还需要使用一个工具,那就是 ProjectedDeviceController。

  • 您可以通过这种方式查看用户使用的是哪种类型的眼镜。

  • 正如我之前所说,智能眼镜包括显示眼镜和纯音频眼镜,您需要查看功能是否包含此 CAPABILITY_VISUAL_UI,这样您就可以针对不同设备优雅地调整应用,并量身定制针对该设备进行优化的体验。

  • 因此,在打造这种差异化体验时,您可能需要访问设备的硬件,例如利用眼镜的摄像头。

  • 因此,如需从投射的 activity 执行此操作,您需要使用 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

设计以音频为主的体验

  • 现在,我们已经连接到设备,了解了硬件功能,并获得了适当的权限,接下来我们来谈谈您需要哪些工具来打造音频体验。

  • 因此,在设计音频优先体验时,我们确实希望确保为用户打造高品质的体验。

  • 这可能包括一些关键互动。

  • 首先是使用语音作为输出,让您的语音能够自然地与用户对话。

  • 接下来,使用耳标向用户发送特定的音频信号。

  • 然后还要处理用户的输入,无论是通过触控板进行点按和滑动,还是允许用户与应用对话。

  • 因此,我们要讨论的第一件事就是使用语音作为输出。

  • 为此,我想介绍一下机器学习套件的新 GenAI Text To Speech 库。

  • 此功能目前处于 Alpha 版阶段,可在部分 Pixel 和三星设备上使用。

  • 我们对此感到非常兴奋,尤其是对于这些以音频为主的体验。

  • 如果您之前使用过 Text-to-Speech,可能知道其中一个痛点是系统语音并不总是像您希望的那样自然。

  • 而连接到云端模型以使用其他语音,可能会导致潜在延迟或额外费用。

  • 因此,与系统 TTS 相比,机器学习套件可提供更自然的语音。

  • 机器学习套件提供了八种独特的语音供您选择,这些语音具有不同的特点,可满足您应用的需求。

  • 此外,机器学习套件还提供强大的语言支持,有 21 种不同的语言区域可供选择。

  • 除此之外,还有一个巨大的好处,那就是在初始下载之后,它将完全在设备上运行。

  • 此模型会下载到系统,并由各应用共享,因此不会增加 APK 的大小。

  • 它不仅可以离线使用(这对经常外出的眼镜用户来说尤为重要,因为他们可能没有 Wi-Fi,或者可能处于信号报道地区不佳的区域),而且由于无需与服务器进行往返通信,因此性能也可能更好。

  • 好了

  • 接下来,我们来详细了解一下该库,以及如何使用它进行构建。

  • 首先,您需要添加依赖项。

    dependencies {
       // ML Kit GenAI TTS SDK
       implementation("com.google.mlkit:genai-text-to-speech:1.0.0-alpha1")
    }
  • 因此,您只需要使用机器学习套件的 Text To Speech SDK。

  • 然后,您就可以开始编写代码了。

  • 因此,GenAI 机器学习套件库包含语音合成器 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
    }
  • 这样,您就可以通过简单的方式实现所选的声音。

  • 第一步是再次使用语音合成器初始化客户端,并设置您选择的语言区域。

  • 接下来,您要检查下载状态,看看是否已下载,然后选择语音。

  • 在此示例中,我们选择第一个语音,但我也想在这里指出,我们使用的是 firstOrNull。

  • 正如我所提到的,机器学习套件需要特定的手机,例如三星或 Pixel 设备。

  • 因此,如果您获得 null 值,则可能意味着用户使用的不是兼容的手机。

  • 然后,您只需确保已设置后备方案,无论是恢复为系统文字转语音,还是播放录制的消息或类似内容。

  • 好的,然后您将使用语音合成 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 开发者们,大家好!

  • 今天很高兴能在这里跟大家见面。

  • 希望大家喜欢我们关于音频优先体验的演讲。

  • 现在好多了,对吧?

  • 好的。

  • 机器学习套件的文字转语音库是一个非常强大的工具,但您不仅可以使用这些语音,还可以使用此库播放耳标。

    // 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 上找到这些音效,也可以直接从那里下载,并将其用于应用打开、应用关闭、错误通知以及静音或取消静音等操作。

  • 您也可以选择创建自己的自定义耳标。

  • 因此,无论您是使用从设计中心下载的耳标,还是自行创建耳标,我们都建议您遵循一些最佳实践。

  • 因此,您需要谨慎考虑耳标的使用方式和使用时机。

  • 因此,有一些事项需要注意,例如,首先尽量不要过度使用耳标。

  • 不要反复播放相同的声音,这可能会导致所谓的听觉疲劳。

  • 请记住,耳标应简单直观,并且在实现时应使用熟悉的音高和节奏。

  • 此外,尽量避免使用需要解释的复杂耳标,这些耳标可能更适合作为语音通知。

  • 请始终牢记,在工作室或系统上、在电脑扬声器中听起来可能很细微的声音,在用户反复播放后可能会变得令人讨厌。

  • 因此,请尽量避免让用户听到过多声音,以免他们感到困惑或忽略重要提醒。

  • 好了

  • 现在,我们能够通过自然语音或耳标提供音频提示和反馈。

  • 但输入呢?

  • 当然,设备上也有触控板或专用按钮,您可以使用它们来创建点按和滑动互动。

  • 但您可能也希望支持这种对话式界面,为此,您可以使用机器学习套件的生成式 AI 语音识别 API。

  • 目前处于 Alpha 版阶段,仅在部分设备上可用,但基本运行模式有两种。

  • 基本模式使用传统的设备端语音识别,适用于搭载 API 级别 31 及更高级别的 Android 设备。

  • 此外,还有使用 Gemini Nano 的高级模式,该模式在 Pixel 10 及更高版本的设备上提供,可带来更出色的质量和更广泛的语言覆盖范围。

  • 因此,SpeechRecognizer 遵循与 SpeechSynthesizer 非常相似的代码模式。

  • 您需要创建客户端、检查模型是否可用、根据需要下载模型、构建请求,并释放资源。

  • 因此,我们在这里只关注主要区别,即创建请求、捕获麦克风音频,然后处理结果。

  • 同样,您需要添加必要的依赖项。

    dependencies {
       // ML Kit Speech Recognition
       implementation("com.google.mlkit:genai-speech-recognition:1.0.0-alpha1")
    }
  • 在这里,您只需要 Speech Recognition API。

  • 然后,您就可以开始创建请求了。

  • 因此,您在定义识别逻辑时将使用挂起函数。

    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 时,您可能会收到四种响应:部分、最终、错误和完成。

  • Partial 会查看实时流,因此可用于热词或关键字等。

  • “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")
       }
    }
  • 假设我们希望用户能够使用语音指令让应用保存某种类型的内容,以便日后使用。

  • 我们可以使用一个明确的语音指令,并告知用户使用该指令;也可以允许用户使用多个关键字和词组。

  • 因此,我们在这里设置了允许他们使用:保存、添加书签或记住。

  • SpeechRecognizer 一旦检测到用户使用了其中一个短语,就会停止。它会保存内容,然后停止语音识别,播放自定义耳标,并使用 ML Kit 的 GenAI 文字转语音功能告诉用户内容已保存。

XR 语言教师案例研究

  • 好的。

  • 我们经历了很多。

  • 这可能感觉有点抽象。

  • 如果您和我一样,希望看到一些实际示例。

  • 我和 Alice 将带您了解具体的操作步骤。

  • 她将带您了解 Google AI Edge Gallery 应用中的 XR 语言教师。

  • Alice 的团队构建了该应用,她将向您展示该应用的具体运作方式。

  • 下面就交给你了。

  • 谢谢 Amy。

  • 没错

  • 首先,我想从大家熟悉的内容开始。

  • 我正在学习日语,已经花了数小时使用语言学习应用、抽认卡、连胜机制等各种工具。

  • 然后,我到了东京,走进一个市场大厅,发现眼前的几乎所有东西都不是我学过的词汇。

  • 集市摊位上有一个木制的剑玉玩具,我一眼就认出来了,但我不知道它的日语怎么说。

  • 我想买商店橱窗里的一件饰品,但不知道它的名字。

  • 就在那一刻,我最想学习这个词。

  • 而不是坐在沙发上盯着语言应用。

  • 就在那一刻,我看着我想学的东西。

  • 所以,我想要的是:

  • 我想整天戴着智能眼镜,到处走走。

  • 每当有东西吸引我的注意力时,我只需大声询问即可。

  • 然后,晚上回到酒店时,我问过的所有内容都会像词汇卡一样,在手机上等着我。

  • 这就是我们打造的产品。

  • 我们来看一段短视频。

  • 因此,我们将其称为 XR 语言教师。

  • 大家好!

  • 您今天想了解什么?

  • 您还可以在教师设置中自定义学习体验。

  • 例如,选择您想学习的语言,并选择适合您的特定教师角色。

  • 真的吗?

  • 您还可以在眼镜模式和手机模式之间切换。

  • “这些葡萄”用西班牙语怎么说?

  • 看这里,美味的葡萄叫作“uvas”。

  • 在西班牙语中,您可以用“Estas uvas son deliciosas”来描述它们。

  • 我想说的是绿葡萄。

  • 让我看一下出了什么问题。

  • 如要指定绿葡萄,您可以说“Uvas verdes”。

  • Me gustan las uvas verdes”是表达你喜欢绿葡萄的好方法。

  • 这个用法语怎么说?我该怎么说才能买三个?

  • 我们来看看。

  • 那些看起来很美味的糕点叫作 des madeleines(玛德琳蛋糕)。

  • 在法语中,如果要买三个,可以说“Je voudrais trois madeleines, s'il vous plaît.”

  • 如何使用此功能请求更改?

  • 让我们看看这里有什么。

  • 如需找零,您可以说“Otsuri wa arimasu ka?”

  • 意思是“你有零钱吗?”

  • 您也可以使用“Otsuri o onegaishimasu”礼貌地请求找零。

  • 然后,系统会自动将你学到的所有内容保存为词汇卡。

  • La botella de agua.

  • Esta es una botella de agua.

  • 没错,你还可以在“学习”标签页中测试记忆力并进行练习。

  • 好了

  • 没错

  • 因此,您刚才看到的所有内容都是在设备上运行的,完全没有网络参与。

  • 我旅行时会使用国际流量套餐,通常情况下都没问题,但只要一进入市场大厅、地下或厚厚的混凝土墙后面,就会遇到问题。

  • 您最需要这项功能的时候,往往也是信号最差的时候。

  • 我认为,如果一个体验在信号不好时就无法使用,那它就不是真正的旅行伴侣。因此,我们让 XR 语言教师完全在设备上离线运行。

链接设备端模型

  • 那么,这个应用是如何构建的呢?

  • 在架构方面,Amy 已经向您展示了如何将眼镜中的投影相机画面传输到手机中。

  • 她向您展示了如何使用机器学习套件和语音识别功能将文本转换为语音,以及如何使用机器学习套件的文字转语音功能将文本转换回语音。

  • 我们还需要一个中间大脑,它能同时查看相机画面和问题,并弄清楚用户实际询问的内容。

  • 这个“大脑”就是 Gemini Nano 4,它基于 Google 的开源模型 Gemma 4 构建,可在手机上本地运行。

  • 因此,在受支持的旗舰 Android 设备上,您可以直接使用机器学习套件 API 来获得经过优化的性能。

  • Android 系统会为您处理模型分发,因此您无需在应用中捆绑任何内容。

  • 总结一下,麦克风采集的声音会输入到语音识别系统,摄像头画面和转写内容会输入到 Gemini Nano 4,Gemini Nano 4 的输出会输入到语音合成系统,最后通过扬声器播放出来。

  • 三个模型串联,全部在手机上本地运行。

  • 以下是 Gemini Nano 4 特别适合解决此问题的几个原因。

  • 首先,它能一次性完成视觉识别和文本阅读。

  • 通过机器学习套件,Gemini Nano 4 可以原生处理文本和图片,因此您无需先使用检测器,再使用分类器,最后使用翻译器。

  • 它是一个模型,可以同时查看摄像头画面和问题。

  • 其次是其广泛的语言支持。

  • Gemini Nano 4 经过 140 多种语言的训练。

  • 此外,对于语言导师来说,仅有翻译功能是不够的。

  • 您需要习语、正式与非正式语体,以及真人会说的例句。

  • 最后,它具有足够的可控性,可以展现出个性。

  • 大声练习发音可能会让人感到不自在。

  • 而且,该模型能很好地遵循系统提示,因此用户可以自行选择教师角色。

  • 而一个耐心且鼓励性的声音可能会让人们更愿意尝试。

  • 关于设计,我还想强调一点。

  • 当对话在眼镜模式下进行时,应用会在后台默默地生成词汇卡。

  • 您提问时的字词、翻译、例句和照片。

  • 用户无需手动点按任何内容。

  • 他们只是在边走边聊。

  • 当天晚些时候,所有内容都会在手机上等待他们,这正是我在东京时想要的效果。

  • 因此,音频优先并不意味着只能使用音频。

  • 眼镜是满足好奇心的绝佳工具,而手机则是事后回顾的理想平台。

  • 让免触摸的时刻留下一些回忆。

  • 现在,让我们重点介绍一些通过 Gemini Nano 4 和机器学习套件构建实时体验的实用技巧,这些技巧是我们从构建此演示中总结出来的。

通过双通道设计优先考虑实时延迟

  • 提示 1:采用双通道设计,优先考虑实时延迟。

  • 在无屏眼镜上,当您询问“那是什么?”时

  • 每个沉默时刻都清晰可辨。

  • 在 XR 语言教师示例中,对于用户互动的每一轮,我们需要模型提供两样东西:即时语音回答,以及要保存到应用中的结构化词汇卡。

  • 如果我们要求模型在一次处理中同时完成这两件事,那么在生成背景数据时消耗的每个 token 都会导致第一句语音输出出现延迟。

  • 因此,我们将用户当前的需求与应用几秒钟后的需求区分开来。

  • 因此,我们将每个轮次分为两个阶段。

  • 在第一轮中,我们仅流式传输相机画面和问题,以获取语音回复,并在第一句话完成后立即触发文字转语音。

  • 由于 TPU 或 NPU 在文字转语音功能完成朗读之前很久就已经完成了简短回复的生成,因此我们实际上会有一个几秒钟的窗口期,此时用户仍在聆听,而芯片已经处于空闲状态。

  • 由于 Pass 1 已在文本回复中识别出该对象,因此 Pass 2 甚至不需要图片输入。

  • 我们只需将 Pass 1 的回复传回该空闲芯片,以便在教师讲完之前提取并保存结构化卡片。

  • 现在,我们来放大第 1 阶段,这会引出我的第二个提示。

  • 在流式传输时使用轻量级 XML 标记。

  • 由于第 1 阶段直接流式传输到文字转语音,因此您不希望繁重的 JSON 对象拖慢第一个语音字词。

  • 相反,您需要的是纯文本,以便在应用需要在中间环节采取行动时,立即通过轻量级的内嵌标签进行语音播报。

  • 下面,我来介绍一下导致我们走到这一步的问题。

  • 还记得我之前提到的那个集市摊位上的木制剑玉玩具吗?

  • 在日语中,它被称为“kendama”。

  • 在早期阶段,模型从摄像头画面中识别出了该物品,并给出了正确答案:“那是剑玉,一种木制玩具。”

  • 因此,我们将这个完整的句子输入到英语文字转语音会话中,结果如下。

  • 那是 ken-mi,一种木制玩具。

  • 这听起来不太对。

  • 实际上,这句话应该听起来像…

  • 那是剑玉,一种木制玩具。

  • 为什么会出现这种情况?

  • “Kendama”由平假名“ken”和汉字“dama”组成。

  • 英语文字转语音引擎可以很好地处理“ken”等平假名发音,但在遇到汉字部分时会完全失效,将“dama”转换为听起来像“me”的发音。

  • 如果您不切换语言区域,这种情况实际上会以不同的方式发生在许多不同的语言中。

  • 例如,在西班牙语中,您可能会听到…

  • Suh-BOH-luh…

  • 而你实际期待的是…

  • Cebolla。

  • 同样,在法语中,您可能会听到…

  • PAIN oh chock-uh-lat…

  • 而您实际期待的是…

  • 巧克力面包。

  • 是的,TTS 会话通常一次只针对一个语言区域,但语言教师需要在每个句子中进行语码转换。

  • 因此,在第 1 轮中,我们要求模型在流式传输时将每个目标语言字词或短语封装在目标 XML 标记内。

  • 当这些片段到达时,我们的客户端会根据目标语言进行拆分,将每个片段路由到匹配的文字转语音语言区域(可能是日语、西班牙语、法语或您正在学习的任何语言),然后无缝播放,在句子中间切换口音,这样像“kendama”这样的词就能正确发音。

  • 因此,这里的要点是,模型已经知道哪些词汇属于哪种语言,哪些单词属于哪种语言,并且在流式传输时让它内嵌标记目标跨度,这比您自己事后对整个内容进行后处理要干净得多。

  • 这是第 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>"
  • 在顶部,我们使用提示来指示模型在回复标记内用一到三句话进行回复,然后将每个日语单词或例句封装在目标标记内。

  • 然后,我们将相机 ImagePart 和提示 TextPart 传递给机器学习套件的 generateContentStream,在每个句子到达时将目标 span 拆分为文字转语音。

  • 这就引出了第二遍检查和第三个提示。

  • 虽然轻量级 XML 标记非常适合实时音频串流,但在将数据保存到应用时,请使用结构化输出,这种输出旨在返回干净、类型化的对象。

  • 由于我们的 XR 语言教师采用对话形式,用户还可以提出后续问题,而无需学习新单词。

  • 在教授单词时,数据库需要干净的字典字段来创建词汇卡。

  • 因此,我们无需编写脆弱的正则表达式来解析第一遍的转录内容,而是让第二遍在后台提取类型安全的对象。

  • 为了在第 2 轮中强制执行架构,我们使用了机器学习套件的结构化输出 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 的卡片字段,pass 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 AI Edge Gallery 应用代码还展示了如何使用 Google LiteRT 运行时和 MediaPipe Tasks API 构建此功能。

  • 如果您想引入其他开源设备端音频或语言模型,也可以通过代码了解如何实现。

通过 AI 智能体适应其他应用场景

  • 因此,我们开源了 XR Language Teacher,以便您更轻松地进行原型设计。

  • 将您选择的智能体指向我们的 GitHub 代码库,即可将这种由 AI 赋能的实时设备端体验转变为其他体验。

  • 它可以是博物馆导览、厨房里的烹饪助手、维修指南、无障碍朗读者,甚至是现场检查工具。

  • 模型、运行时、开源实现和工具现已全部推出,您可以为今年秋季即将推出的智能眼镜做好准备。

  • 我们期待看到您用它创作的精彩内容。

  • 最后,今天我们展示了 AI 模型在设备端无缝运行所能实现的强大功能。

  • 以下是几种入门方法。

  • 首先,获取屏幕上的资源。

  • 下载 AI Edge Gallery 应用即可试用演示,并查看 GitHub 上的参考代码。

  • 扫描二维码即可访问 Android XR SDK、机器学习套件 API 和 Google AI Edge 产品。

  • 其次,立即在手机上开始实验。

  • 我们介绍了很多内容,其中许多已经可以用来构建移动优先体验。

  • 第三,看看您已经发布的应用,问问自己,哪些核心功能更适合免触摸操作?

  • 只需将 Projected Activity 添加到现有 APK 中,即可将该体验也带到眼镜上。

  • 非常感谢各位今天的参与。

  • 我们迫不及待地想看到您在 Android 和智能眼镜上打造出令人惊艳的音频优先体验。

  • 谢谢。

代码

代码段

“为 Android XR 构建应用”视频中 02:39 处的 Kotlin 代码段
dependencies {
   implementation("androidx.xr.projected:projected:1.0.0-alpha13")
}
“Building for Android XR”中 02:45 处的 Kotlin 代码段
class ProjectedMainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           // Glasses content
       }
   }
}
“针对 Android XR 进行构建”视频中 03:02 处的 XML 代码段
<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>
“针对 Android XR 进行构建”视频中 03:44 处的 Kotlin 代码段
// 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)
“Android XR 应用开发”中 05:05 处的 Kotlin 代码段
// 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 XR 构建应用”视频中 05:46 处的 Shell 代码段
android skills add projected-hardware
android skills add --all
“设计音频优先体验”中 08:14 处的 Kotlin 代码段
dependencies {
   // ML Kit GenAI TTS SDK
   implementation("com.google.mlkit:genai-text-to-speech:1.0.0-alpha1")
}
“设计音频优先体验”中 08:26 处的 Kotlin 代码段
// 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
}
“设计音频优先体验”中 09:26 处的 Kotlin 代码段
// 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}")
   }
}
“设计音频优先体验”中 09:47 处的 Kotlin 代码段
// 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()
}
“设计音频优先体验”中 10:29 处的 Kotlin 代码段
// 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()
)
“设计音频优先体验”中 13:42 处的 Kotlin 代码段
dependencies {
   // ML Kit Speech Recognition
   implementation("com.google.mlkit:genai-speech-recognition:1.0.0-alpha1")
}
“设计音频优先体验”中 13:52 处的 Kotlin 代码段
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() }
...
“设计音频优先体验”中 14:12 处的 Kotlin 代码段
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()
       }
...
“设计音频优先体验”中 14:41 处的 Kotlin 代码段
// 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")
   }
}
“Prioritizing real-time latency with two-pass design”中 26:27 处的 Kotlin 代码段
// 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>"
“通过两阶段设计优先考虑实时延迟”中 27:33 处的 Kotlin 代码段
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?
)
“通过双通道设计优先考虑实时延迟”中 28:00 处的 Kotlin 代码段
// 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": "けん玉をください。", ...}