온디바이스 AI를 기반으로 강력한 핸즈프리 오디오 환경을 빌드하는 방법을 알아보세요. 안경 활동 설정, 마이크 및 카메라 피드 캡처, 음성과 기타 오디오 신호를 궁극적인 UI로 사용하는 방법 등 필요한 모든 내용을 다룹니다. Google AI Edge 갤러리 앱에 빌드된 오픈소스 XR Language Teacher의 흥미로운 실제 쇼케이스를 통해 Gemini Nano 4 (Gemma 4를 기반으로 빌드되고 Android 기기에 최적화됨)와 ML Kit를 조정하는 방법을 알아보고, LiteRT 런타임을 활용하여 자연스럽게 듣고 보고 말하는 빠른 XR 환경을 빌드하는 방법을 알아보세요.
스크립트
안녕하세요, 여러분 모두 환영합니다.
오늘 이렇게 만나 뵙게 되어 정말 반갑습니다.
참석해 주셔서 감사합니다.
에이미입니다.
오늘은 동료인 앨리스와 함께 Gemini Nano 4를 사용하여 지능형 글라스를 위한 놀라운 오디오 중심 환경을 구축하는 방법을 이야기해 보겠습니다.
그럼 시작해 보겠습니다.
지능형 글라스란 하루 종일 착용할 수 있는 가볍고 휴대성이 뛰어난 안경을 의미합니다.
이 카테고리에는 디스플레이 및 오디오 전용 안경이 모두 포함되며, 이러한 기기에는 다양한 하드웨어가 사용됩니다.
가장 먼저 눈에 띄는 것은 탭, 스와이프와 같은 사용자 입력에 사용할 수 있는 터치패드가 있다는 점입니다.
스피커, 카메라, 마이크도 있습니다.
디스플레이가 있을 수도 있고 없을 수도 있으며, 이 두 가지가 조합된 형태일 수도 있습니다.
오늘은 오디오 우선과 매력적이고 지능적인 이동 중 경험을 구축하는 방법에 대해 이야기하겠습니다.
오디오 전용에서 흥미로운 점 중 하나는 음성이 UI가 된다는 것입니다.
온디바이스 AI는 오프라인 및 이동 중 환경을 원하는 개발자에게 적합합니다.
좋은 소식이 있습니다.
따라서 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를 사용하여 사용자에게 콘텐츠가 저장되었음을 알립니다.
좋습니다.
지금까지 많은 내용을 다뤘습니다.
다소 추상적으로 느껴질 수 있습니다.
저와 같은 분이라면 실제 사례를 보고 싶으실 겁니다.
앨리스와 제가 바로 이 내용을 안내해 드리겠습니다.
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단계 설계로 실시간 지연 시간에 우선순위를 두는 것입니다.
오디오 글라스에서 "이게 뭐야?"라고 물으면
침묵의 순간은 모두 눈에 띕니다.
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를 사용하여 이 기능을 빌드하는 방법을 확인할 수 있습니다.
다른 오픈소스 온디바이스 오디오 또는 언어 모델을 가져오려는 경우 코드에서 가져오는 방법도 확인할 수 있습니다.
따라서 프로토타입 제작을 더 쉽게 할 수 있도록 XR 언어 교사를 오픈소스로 공개했습니다.
원하는 에이전트를 GitHub 저장소에 연결하여 이 실시간 온디바이스 AI 기반 환경을 다른 환경으로 전환하세요.
박물관 가이드, 조리대를 보며 요리를 도와주는 어시스턴트, 수리 안내, 접근성 내레이터, 현장 검사 도구 등이 될 수 있습니다.
모델, 런타임, 오픈소스 구현, 도구를 지금 바로 사용할 수 있으므로 올가을에 출시될 지능형 아이웨어에 대비할 수 있습니다.
이 기능을 활용해 어떤 콘텐츠를 만드실지 무척 기대됩니다.
마무리하자면, 오늘 Google은 기기 내에서 원활하게 실행되는 AI 모델로 무엇이 가능한지 보여주는 강력한 예시를 선보였습니다.
다음은 시작하는 데 도움이 되는 몇 가지 방법입니다.
먼저 화면에 표시된 리소스를 가져옵니다.
AI Edge Gallery 앱을 다운로드하여 데모를 사용해 보고 GitHub에서 참조 코드를 확인하세요.
QR 코드를 스캔하여 Android XR SDK, ML Kit API, Google AI Edge 제품에 액세스하세요.
둘째, 지금 바로 휴대전화에서 실험을 시작하세요.
지금까지 살펴본 많은 것들이 이미 모바일 중심 환경으로 구축될 수 있습니다.
셋째, 이미 출시한 모바일 앱을 살펴보고 어떤 핵심 기능을 핸즈프리로 사용하면 더 좋을지 자문해 보세요.
기존 APK에 투사된 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": "けん玉をください。", ...}