Android XR

Gemini Nano 4를 사용한 지능형 글라스용 오디오 중심 환경 빌드

스피커

정보

강연 소개

온디바이스 AI를 기반으로 강력한 핸즈프리 오디오 환경을 빌드하는 방법을 알아보세요. 안경 활동 설정, 마이크 및 카메라 피드 캡처, 음성과 기타 오디오 신호를 궁극적인 UI로 사용하는 방법 등 필요한 모든 내용을 다룹니다. Google AI Edge 갤러리 앱에 빌드된 오픈소스 XR Language Teacher의 흥미로운 실제 쇼케이스를 통해 Gemini Nano 4 (Gemma 4를 기반으로 빌드되고 Android 기기에 최적화됨)와 ML Kit를 조정하는 방법을 알아보고, LiteRT 런타임을 활용하여 자연스럽게 듣고 보고 말하는 빠른 XR 환경을 빌드하는 방법을 알아보세요.

요약

  • Jetpack Projected 라이브러리 (알파)는 Android 휴대전화를 지능형 글라스에 연결합니다. 기존 앱에 프로젝션된 활동을 추가하면 디스플레이 또는 오디오 전용 안경에 콘텐츠가 프로젝션됩니다.
  • ML Kit는 일부 기기에서 오디오 우선 상호작용을 위한 알파 버전의 기기 내 음성 라이브러리를 도입합니다. 21개 언어에서 8가지 음성으로 텍스트 음성 변환을 제공하며, Pixel 10에서는 Gemini Nano 고급 모드로 음성 인식을 지원합니다.
  • Gemini Nano 4는 카메라 프레임과 스크립트 작성된 질문을 한 번에 함께 처리합니다. 지원되는 플래그십 휴대전화에서 온디바이스 음성 모델을 로컬로 연결하여 오프라인 환경을 지원합니다.
  • 2패스 설계는 스마트 글라스에서 실시간 오디오 지연 시간을 우선시합니다. 1단계에서는 문장 중간에 언어를 전환하기 위해 인라인 XML 태그를 사용하여 음성 텍스트를 스트리밍하고, 2단계에서는 백그라운드에서 구조화된 데이터를 추출합니다.
  • 오픈소스 XR Language Teacher 구현은 Google AI Edge 갤러리 앱에 포함되어 있습니다. 개발자는 다른 멀티모달 어시스턴트 사용 사례에 맞게 코드를 조정할 수 있습니다.

리소스

Gemini Notebook

주제

  • intelligent-eyewear
  • android-xr
  • gemini-nano
  • on-device-ai
  • ml-kit
  • speech-recognition
  • text-to-speech

스크립트

소개

  • 안녕하세요, 여러분 모두 환영합니다.

  • 오늘 이렇게 만나 뵙게 되어 정말 반갑습니다.

  • 참석해 주셔서 감사합니다.

  • 에이미입니다.

  • 오늘은 동료인 앨리스와 함께 Gemini Nano 4를 사용하여 지능형 글라스를 위한 놀라운 오디오 중심 환경을 구축하는 방법을 이야기해 보겠습니다.

  • 그럼 시작해 보겠습니다.

  • 지능형 글라스란 하루 종일 착용할 수 있는 가볍고 휴대성이 뛰어난 안경을 의미합니다.

  • 이 카테고리에는 디스플레이 및 오디오 전용 안경이 모두 포함되며, 이러한 기기에는 다양한 하드웨어가 사용됩니다.

  • 가장 먼저 눈에 띄는 것은 탭, 스와이프와 같은 사용자 입력에 사용할 수 있는 터치패드가 있다는 점입니다.

  • 스피커, 카메라, 마이크도 있습니다.

  • 디스플레이가 있을 수도 있고 없을 수도 있으며, 이 두 가지가 조합된 형태일 수도 있습니다.

  • 오늘은 오디오 우선과 매력적이고 지능적인 이동 중 경험을 구축하는 방법에 대해 이야기하겠습니다.

  • 오디오 전용에서 흥미로운 점 중 하나는 음성이 UI가 된다는 것입니다.

  • 온디바이스 AI는 오프라인 및 이동 중 환경을 원하는 개발자에게 적합합니다.

Android XR용 빌드

  • 좋은 소식이 있습니다.

  • 따라서 Android용으로 빌드하고 있다면 이미 Android XR용으로 빌드하고 있는 것입니다.

  • 기존 Android 앱은 오디오 글라스로 원활하게 확장되도록 설계되어 블루투스를 통해 음성 알림과 기타 오디오를 제공합니다.

  • 더 나아가 XR에 맞게 앱을 차별화하고 싶다면 다양한 방법이 있습니다.

  • 이 강연에서는 온디바이스 모델에 대해 이야기하지만, 이것이 전부는 아닙니다.

  • 클라우드 모델도 있습니다.

  • Gemini Live API와 같은 기능이 있습니다.

  • Gemini와 같이 통합할 수 있는 시스템 에이전트가 있으며, 이 모든 것이 최적의 환경을 구축하는 데 도움이 될 수 있습니다.

  • 그렇다면 자연스럽게 듣고, 보고, 말하는 환경을 어떻게 구축할 수 있을까요?

  • 오늘은 온디바이스 AI를 기반으로 하는 강력한 핸즈프리 환경을 구축하는 방법을 안내해 드리겠습니다.

  • 스마트 안경용 앱을 빌드하는 방법, 활동을 설정하는 방법, 필요한 권한을 얻는 방법, 대화형 UI에 ML Kit를 사용하는 방법 등 기본 사항을 안내해 드리겠습니다.

  • 그런 다음 앨리스에게 넘겨 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
           }
       }
    }
  • 그런 다음 기존 앱과 이 새 활동 모두에서 Jetpack Projected 라이브러리를 활용하여 필요할 때마다 두 기기가 원활하게 통신할 수 있도록 합니다.

  • 따라서 지금까지 살펴본 일반적인 활동과 마찬가지로 매니페스트에서 이를 선언합니다.

    <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로 사용해야 한다는 것입니다.

  • 이렇게 하면 시스템에서 이 활동이 안경에 관한 것임을 알 수 있습니다.

  • 그런 다음 여기에서 의도 카테고리인 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 스튜디오에서 Android CLI와 함께 사용하여 권한 워크플로를 설정할 수 있으며, 이 스킬과 다른 모든 Android 스킬에 액세스할 수 있습니다.

    android skills add projected-hardware
    android skills add --all

오디오 중심 환경 설계

  • 이제 연결이 완료되었고 하드웨어 기능을 파악했으며 적절한 권한을 확보했으니 오디오 환경을 구축하는 데 필요한 도구에 관해 이야기해 보겠습니다.

  • 따라서 오디오 우선 환경을 설계할 때는 사용자에게 고품질 환경을 제공해야 합니다.

  • 여기에는 몇 가지 주요 상호작용이 포함될 수 있습니다.

  • 첫 번째는 음성을 출력으로 사용하여 음성이 사용자에게 자연스럽게 말하도록 하는 것입니다.

  • 다음은 이어콘을 사용하여 사용자에게 특정 오디오 신호를 보내는 것입니다.

  • 또한 탭과 스와이프를 사용하여 터치패드를 통해 사용자의 입력을 처리하거나 사용자가 앱에 말할 수 있도록 허용할 수도 있습니다.

  • 먼저 음성을 출력으로 사용하는 것에 대해 이야기해 보겠습니다.

  • 이를 위해 ML Kit의 새로운 GenAI Text To Speech 라이브러리를 소개합니다.

  • 이 기능은 현재 알파 버전으로 제공되며 일부 Pixel 및 삼성 기기에서 사용할 수 있습니다.

  • 특히 오디오 중심 환경에서 이 기능이 매우 유용할 것으로 기대됩니다.

  • 이전에 텍스트 음성 변환을 사용해 본 적이 있다면 시스템 음성이 항상 원하는 만큼 자연스럽게 들리지 않는다는 점이 문제 중 하나라는 것을 알고 있을 것입니다.

  • 다른 음성을 위해 클라우드 모델에 연결하면 지연 시간이나 추가 비용이 발생할 수 있습니다.

  • 따라서 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의 텍스트 음성 변환 SDK만 있으면 됩니다.

  • 그러면 코드 작성을 시작할 수 있습니다.

  • 따라서 GenAI ML Kit 라이브러리에는 음성 합성기 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를 사용하고 있다는 점도 주목해야 합니다.

  • 앞서 언급했듯이 ML Kit를 사용하려면 특정 기능을 지원하는 휴대전화(삼성 또는 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 개발자 여러분, 안녕하세요.

  • 오늘 여러분을 만나 뵙게 되어 기쁩니다.

  • 오디오 중심 환경에 관한 프레젠테이션을 즐겁게 시청하고 계시기를 바랍니다.

  • 훨씬 낫죠?

  • 좋습니다.

  • 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에서 사용할 수 있는 표준화된 사운드를 이미 만들어 두었으니, 바로 다운로드하여 앱 열기, 앱 닫기, 오류 알림, 음소거 또는 음소거 해제 등에 사용할 수 있습니다.

  • 또한 나만의 맞춤 이어콘을 만들 수도 있습니다.

  • 이어콘의 경우 디자인 허브에서 다운로드한 이어콘을 사용하든 직접 만들든 상관없이 권장하는 몇 가지 권장사항이 있습니다.

  • 따라서 이어콘을 사용하는 방식과 사용 방법에 대해 신중하게 생각해야 합니다.

  • 따라서 몇 가지 유의해야 할 사항이 있습니다. 예를 들어 이어콘을 과도하게 사용하지 않도록 하세요.

  • 동일한 사운드를 반복해서 재생하지 마세요. 이로 인해 청각 피로가 발생할 수 있습니다.

  • 이어콘은 직관적이어야 하며, 구현할 때는 익숙한 음높이와 리듬을 사용해야 합니다.

  • 설명이 필요한 지나치게 복잡한 이어콘은 피하세요. 이러한 이어콘은 음성 알림으로 사용하는 것이 더 적합할 수 있습니다.

  • 그리고 스튜디오나 시스템, 컴퓨터 스피커에서 미묘하게 들리는 소리가 사용자에게 반복적으로 재생되면 짜증을 유발할 수 있다는 점을 항상 염두에 두세요.

  • 따라서 사용자가 혼란스러워하거나 중요한 알림을 무시하게 만들 수 있는 소리로 사용자에게 과부하를 주지 않도록 최선을 다하세요.

  • 좋습니다.

  • 이제 자연스러운 음성이나 이어콘을 통해 오디오 신호와 피드백을 제공할 수 있습니다.

  • 입력은 어떨까요?

  • 물론 기기에는 터치패드 또는 전용 버튼도 있어 탭 및 스와이프 상호작용을 만드는 데 사용할 수 있습니다.

  • 하지만 대화형 UI를 허용하고 싶을 수도 있습니다. 이 경우 ML Kit의 GenAI Speech Recognition API를 사용할 수 있습니다.

  • 현재 알파 버전이며 일부 기기에서 사용할 수 있지만 기본적으로 두 가지 모드로 실행됩니다.

  • 기본 모드는 기존의 기기 내 음성 인식을 사용하며 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를 사용하면 부분, 최종, 오류, 완료라는 네 가지 응답이 가능합니다.

  • 부분은 실시간 스트림을 살펴보므로 핫워드나 키워드와 같은 생각에 사용됩니다.

  • 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 Text to Speech를 사용하여 사용자에게 콘텐츠가 저장되었음을 알립니다.

XR 언어 교사 우수사례

  • 좋습니다.

  • 지금까지 많은 내용을 다뤘습니다.

  • 다소 추상적으로 느껴질 수 있습니다.

  • 저와 같은 분이라면 실제 사례를 보고 싶으실 겁니다.

  • 앨리스와 제가 바로 이 내용을 안내해 드리겠습니다.

  • Google AI Edge 갤러리 앱의 XR Language Teacher를 안내해 드릴 예정입니다.

  • 앨리스의 팀에서 이 앱을 빌드했으며, 앨리스가 앱의 작동 방식을 정확히 보여줄 것입니다.

  • 이제 여러분 차례입니다.

  • 에이미, 감사합니다.

  • 네.

  • 많은 분이 알고 계실 만한 내용으로 시작해 보겠습니다.

  • 일본어를 배우고 있는데 언어 학습 앱, 플래시카드, 연속 학습 등 모든 것을 시도하며 몇 시간씩 공부하고 있습니다.

  • 그러다 도쿄에 도착해 시장에 들어섰는데, 눈앞에 보이는 것 중 제가 공부한 단어는 거의 없다는 사실을 깨달았습니다.

  • 시장 가판대에 있는 나무로 만든 컵앤볼 장난감을 보고 알아봤지만 일본어로 어떻게 말해야 할지 모르겠습니다.

  • 상점 창문에 있는 장식품을 사고 싶은데 이름을 모르겠어요.

  • 그 순간이 바로 단어를 배우려는 동기가 가장 강해지는 때입니다.

  • 소파에 앉아 언어 앱을 보는 것이 아니라요.

  • 그 순간, 배우고 싶은 것을 보고 있습니다.

  • 내가 원하는 건 이거야.

  • 스마트 글라스를 하루 종일 착용하고 돌아다니고 싶습니다.

  • 눈에 띄는 것이 있으면 소리 내어 물어봅니다.

  • 그리고 밤에 호텔로 돌아가면 제가 물어본 모든 것이 휴대전화에 단어 카드처럼 준비되어 있습니다.

  • 그렇게 해서 만든 것이 바로 이것입니다.

  • 짧은 동영상을 시청하겠습니다.

  • 그래서 이를 XR 언어 교사라고 부릅니다.

  • 안녕하세요.

  • 오늘은 무엇에 대해 알아보고 싶으신가요?

  • 교사 설정에서 학습 환경을 맞춤설정할 수 있습니다.

  • 예를 들어 배우고 싶은 언어를 선택하고 나에게 맞는 특정 교사 캐릭터를 선택할 수 있습니다.

  • 와

  • 안경 모드와 휴대전화 모드 간에 전환할 수 있습니다.

  • 스페인어로 이 포도를 뭐라고 해?

  • 이걸 보면, 맛있는 포도는 uvas라고 부릅니다.

  • 스페인어로는 'Estas uvas son deliciosas'라고 말할 수 있습니다.

  • 특히 청포도라고 말하고 싶습니다.

  • 한번 확인해 보겠습니다.

  • 청포도를 지정하려면 'Uvas verdes'라고 말하면 됩니다.

  • Me gustan las uvas verdes'는 청포도를 좋아한다고 말하는 좋은 방법입니다.

  • 이걸 프랑스어로 뭐라고 해? 그리고 3개 달라고 하려면 어떻게 말해야 해?

  • 확인해 보겠습니다.

  • 맛있어 보이는 이 페이스트리는 마들렌이라고 합니다.

  • 프랑스어로 3개를 요청하려면 "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 Language Teacher는 완전히 오프라인으로 기기에서 작동하도록 만들었습니다.

온디바이스 모델 연결

  • 그렇다면 이 기능은 어떻게 빌드되었을까요?

  • 아키텍처 측면에서 에이미는 이미 안경에서 휴대전화로 투사된 카메라 프레임을 사용하는 방법을 보여주었습니다.

  • ML Kit의 음성 인식을 사용하여 텍스트를 음성으로 변환하고, ML Kit의 텍스트 음성 변환을 사용하여 음성을 다시 텍스트로 변환하는 방법을 보여주었습니다.

  • 하지만 카메라 프레임과 질문을 함께 살펴보고 사용자가 실제로 무엇을 묻고 있는지 파악할 수 있는 중간 단계의 브레인이 필요했습니다.

  • 이 두뇌는 휴대전화에서 로컬로 실행되는 Google의 오픈소스 모델인 Gemma 4를 기반으로 빌드된 Gemini Nano 4입니다.

  • 따라서 지원되는 플래그십 Android 기기에서는 ML Kit API를 직접 사용하여 최적화된 성능을 얻을 수 있습니다.

  • Android 시스템에서 모델 전송을 처리하므로 앱에 번들로 묶을 필요가 없습니다.

  • 이 모든 것을 종합하면 마이크는 음성 인식으로, 카메라 프레임과 스크립트는 Gemini Nano 4로 전달되고, Gemini Nano 4는 음성 합성으로 출력한 다음 스피커에서 출력됩니다.

  • 세 가지 모델이 연결되어 모두 휴대전화에서 로컬로 실행됩니다.

  • Gemini Nano 4가 이 문제에 특히 적합한 이유는 다음과 같습니다.

  • 첫째, 한 번에 보고 읽습니다.

  • Gemini Nano 4는 ML Kit를 통해 텍스트와 이미지를 기본적으로 처리하므로 감지기, 분류기, 번역기를 사용하지 않습니다.

  • 카메라 프레임과 질문을 함께 살펴보는 모델입니다.

  • 두 번째는 광범위한 언어 지원입니다.

  • Gemini Nano 4는 140개 이상의 언어로 학습되었습니다.

  • 또한 언어 교사의 경우 번역만으로는 충분하지 않습니다.

  • 관용구, 격식 있는 표현과 격식 없는 표현, 실제 사용자가 말할 법한 예문이 필요합니다.

  • 마지막으로, 개성을 가질 수 있을 만큼 조정 가능합니다.

  • 소리 내어 발음을 연습하는 것은 부담스러울 수 있습니다.

  • 이 모델은 시스템 프롬프트를 잘 따르므로 사용자가 원하는 교사 페르소나를 선택할 수 있습니다.

  • 인내심을 갖고 격려하는 어조를 사용하면 사람들이 더 적극적으로 시도할 수 있습니다.

  • 디자인에 관해 강조하고 싶은 점이 하나 더 있습니다.

  • 안경 모드에서 대화가 진행되는 동안 앱은 백그라운드에서 조용히 단어 카드를 작성합니다.

  • 질문한 순간의 단어, 번역, 예문, 사진이 표시됩니다.

  • 사용자가 수동으로 탭할 필요가 없습니다.

  • 그냥 걸으면서 이야기하고 있습니다.

  • 그리고 그날 오후에 휴대전화에서 모든 것을 확인할 수 있습니다. 제가 도쿄에 있을 때 원했던 것이 바로 이것입니다.

  • 따라서 오디오 중심이라고 해서 오디오만 제공해야 하는 것은 아닙니다.

  • 글라스는 호기심을 충족하기에 적합한 기기이며, 휴대전화는 이후에 되돌아보기에 적합한 기기입니다.

  • 핸즈프리 순간을 기록으로 남기세요.

  • 이제 이 데모를 빌드하면서 배운 Gemini Nano 4와 ML Kit를 사용하여 실시간 환경을 빌드하는 몇 가지 실용적인 팁을 자세히 살펴보겠습니다.

2단계 설계로 실시간 지연 시간 우선순위 지정

  • 첫 번째 팁은 2단계 설계로 실시간 지연 시간에 우선순위를 두는 것입니다.

  • 오디오 글라스에서 "이게 뭐야?"라고 물으면

  • 침묵의 순간은 모두 눈에 띕니다.

  • XR 언어 교사의 예에서는 모델에서 사용자 상호작용의 각 차례에 두 가지가 필요합니다. 즉각적인 음성 답변과 앱에 저장할 구조화된 어휘 카드입니다.

  • 모델에 한 번에 두 가지를 요청하면 백그라운드 데이터 생성에 사용되는 모든 토큰으로 인해 첫 번째 음성 문장이 지연됩니다.

  • 따라서 사용자가 지금 당장 필요로 하는 것과 앱이 몇 초 후에 필요로 하는 것을 분리합니다.

  • 따라서 각 차례를 두 단계로 나눕니다.

  • 1단계에서는 카메라 프레임과 음성 대답을 위한 질문을 스트리밍하여 첫 번째 문장이 완료되는 즉시 텍스트 음성 변환을 트리거합니다.

  • 또한 TPU 또는 NPU는 텍스트 음성 변환이 소리 내어 말하기를 마치기 훨씬 전에 짧은 답장을 생성하기 때문에 사용자가 여전히 듣고 있는 동안 칩은 이미 유휴 상태에 있는 몇 초의 시간이 생깁니다.

  • 또한 1단계에서 이미 텍스트 대답으로 객체를 식별했으므로 2단계에서는 이미지 입력이 필요하지 않습니다.

  • 교사가 말을 마치기도 전에 Pass 1의 대답을 유휴 칩에 다시 전달하여 구조화된 카드를 추출하고 저장합니다.

  • 이제 첫 번째 단계에 집중해 보겠습니다. 두 번째 팁으로 이어집니다.

  • 스트리밍할 때는 가벼운 XML 태그를 사용합니다.

  • 1단계는 TTS로 직접 스트리밍되므로 무거운 JSON 객체가 첫 번째 음성 단어를 지연시키지 않도록 해야 합니다.

  • 대신 앱이 스트림 중간에 작업을 실행해야 할 때마다 가벼운 인라인 태그를 사용하여 즉시 말할 수 있는 일반 텍스트가 필요합니다.

  • 그럼 이 문제를 살펴보겠습니다.

  • 앞서 언급한 시장 가판대의 나무 컵앤볼 장난감을 기억하시나요?

  • 일본어로는 '켄다마'라고 합니다.

  • 초반에 모델은 카메라 프레임에서 켄다마를 인식하고 "켄다마라는 나무 장난감입니다"라는 정답을 반환했습니다.

  • 그래서 이 완전한 문장을 영어 텍스트 음성 변환 세션에 입력했고, 다음과 같은 결과가 나왔습니다.

  • 나무 장난감인 켄미입니다.

  • 정확하지 않은 것 같습니다.

  • 대신 이 문장은 다음과 같이 들려야 합니다.

  • 켄다마라는 나무 장난감입니다.

  • 왜 이런 일이 발생했을까요?

  • 켄다마는 히라가나 '켄'과 한자 '다마'로 표기됩니다.

  • 영어 TTS 엔진은 'ken'과 같은 히라가나 발음은 잘 처리하지만 한자 부분에 도달하면 완전히 무너져 dama를 me와 비슷한 소리로 바꿉니다.

  • 언어 설정을 전환하지 않으면 여러 언어에서 다양한 방식으로 이러한 문제가 발생합니다.

  • 예를 들어 스페인어로는 다음과 같이 들릴 수 있습니다.

  • Suh-BOH-luh…

  • 실제로는…

  • Cebolla.

  • 마찬가지로 프랑스어로는 다음과 같이 들릴 수 있습니다.

  • 뺑 오 쇼콜라…

  • 실제로는…

  • 뺑 오 쇼콜라

  • 예, TTS 세션은 일반적으로 한 번에 하나의 언어로 설계되지만 언어 교사는 모든 문장에서 코드 전환을 해야 합니다.

  • 따라서 1단계에서는 모델이 스트리밍할 때 모든 도착어 단어 또는 구문을 도착어 XML 태그 내에 래핑하도록 요청합니다.

  • 청크가 도착하면 클라이언트는 타겟을 분할하고 각 스팬을 일치하는 TTS 언어(일본어, 스페인어, 프랑스어 또는 학습 중인 언어)로 라우팅한 다음 문장 중간에 억양을 전환하여 원활하게 재생하므로 켄다마와 같은 단어가 올바르게 발음됩니다.

  • 여기서 중요한 점은 모델이 이미 어떤 어휘가 어떤 언어에 속하는지, 어떤 단어가 어떤 언어에 속하는지 알고 있으며, 스트리밍 중에 타겟 스팬을 인라인으로 태그하도록 하면 이 모든 것을 나중에 직접 후처리하는 것보다 훨씬 더 깔끔하다는 것입니다.

  • 코드의 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에 전달하여 각 문장이 도착할 때마다 타겟 스팬을 텍스트 음성 변환으로 분할합니다.

  • 이제 두 번째 단계와 세 번째 팁을 살펴보겠습니다.

  • 가벼운 XML 태그를 사용하는 것은 실시간 오디오 스트림에 적합하지만, 앱에 데이터를 저장할 때는 깔끔한 유형의 객체를 반환하도록 빌드된 구조화된 출력을 사용하세요.

  • XR 언어 교사는 대화형이므로 사용자는 새로운 단어를 배우지 않는 후속 질문을 할 수도 있습니다.

  • 단어를 학습할 때 데이터베이스에는 어휘 카드를 만들기 위한 정리된 사전 필드가 필요합니다.

  • 따라서 첫 번째 단계의 스크립트를 파싱하기 위해 불안정한 정규 표현식을 작성하는 대신, 두 번째 단계에서 백그라운드에서 유형 안전 객체를 추출하도록 합니다.

  • 그리고 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을 허용하는 카드 필드를 래핑하여, 새로운 단어를 학습한 경우 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 갤러리 앱에 포함되어 있습니다.

  • 지금 다운로드하여 휴대전화에 설치하고 다른 언어로 말하는 방법을 배우고 싶은 것을 가리키면 됩니다.

  • 구축 방법을 알고 싶다면 GitHub에서 이 전체 구현을 오픈소스로 확인할 수 있습니다.

  • 모든 사용자가 오디오 또는 디스플레이 안경이나 지원되는 플래그십 Android 기기를 아직 가지고 있지 않더라도 걱정하지 마세요.

  • 코드베이스에는 동일한 환경을 모바일 전용 환경에 맞게 조정하는 방법의 예도 나와 있습니다.

  • 다른 플랫폼에도 도달하고 싶다면 Google AI Edge 갤러리 앱 코드를 통해 Google LiteRT 런타임과 MediaPipe Tasks API를 사용하여 이 기능을 빌드하는 방법을 확인할 수 있습니다.

  • 다른 오픈소스 온디바이스 오디오 또는 언어 모델을 가져오려는 경우 코드에서 가져오는 방법도 확인할 수 있습니다.

AI 에이전트를 사용한 다른 사용 사례에 적응

  • 따라서 프로토타입 제작을 더 쉽게 할 수 있도록 XR 언어 교사를 오픈소스로 공개했습니다.

  • 원하는 에이전트를 GitHub 저장소에 연결하여 이 실시간 온디바이스 AI 기반 환경을 다른 환경으로 전환하세요.

  • 박물관 가이드, 조리대를 보며 요리를 도와주는 어시스턴트, 수리 안내, 접근성 내레이터, 현장 검사 도구 등이 될 수 있습니다.

  • 모델, 런타임, 오픈소스 구현, 도구를 지금 바로 사용할 수 있으므로 올가을에 출시될 지능형 아이웨어에 대비할 수 있습니다.

  • 이 기능을 활용해 어떤 콘텐츠를 만드실지 무척 기대됩니다.

  • 마무리하자면, 오늘 Google은 기기 내에서 원활하게 실행되는 AI 모델로 무엇이 가능한지 보여주는 강력한 예시를 선보였습니다.

  • 다음은 시작하는 데 도움이 되는 몇 가지 방법입니다.

  • 먼저 화면에 표시된 리소스를 가져옵니다.

  • AI Edge Gallery 앱을 다운로드하여 데모를 사용해 보고 GitHub에서 참조 코드를 확인하세요.

  • QR 코드를 스캔하여 Android XR SDK, ML Kit API, Google AI Edge 제품에 액세스하세요.

  • 둘째, 지금 바로 휴대전화에서 실험을 시작하세요.

  • 지금까지 살펴본 많은 것들이 이미 모바일 중심 환경으로 구축될 수 있습니다.

  • 셋째, 이미 출시한 모바일 앱을 살펴보고 어떤 핵심 기능을 핸즈프리로 사용하면 더 좋을지 자문해 보세요.

  • 기존 APK에 투사된 Activity를 추가하기만 하면 글라스에서도 이 환경을 사용할 수 있습니다.

  • 오늘 함께해 주셔서 감사합니다.

  • Android와 지능형 글라스에서 어떤 놀라운 오디오 중심 환경을 구축하실지 기대됩니다.

  • 감사합니다.

코드

코드 스니펫

'Android XR용 빌드'의 2분 39초 지점에 있는 Kotlin 스니펫
dependencies {
   implementation("androidx.xr.projected:projected:1.0.0-alpha13")
}
'Android XR용 빌드'의 2분 45초 지점에 있는 Kotlin 스니펫
class ProjectedMainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           // Glasses content
       }
   }
}
'Android XR용 빌드'의 3분 2초 지점에 있는 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용 빌드'의 3분 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용 빌드'의 5분 5초 지점에 있는 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에 나오는 셸 스니펫
android skills add projected-hardware
android skills add --all
'오디오 중심 환경 설계'의 8분 14초 지점에 있는 Kotlin 스니펫
dependencies {
   // ML Kit GenAI TTS SDK
   implementation("com.google.mlkit:genai-text-to-speech:1.0.0-alpha1")
}
'오디오 중심 환경 설계'의 8분 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
}
'오디오 중심 환경 설계'의 9분 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>"
'2단계 설계로 실시간 지연 시간 우선순위 지정'의 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?
)
'2패스 설계로 실시간 지연 시간 우선순위 지정'의 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": "けん玉をください。", ...}