Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,17 @@ import androidx.navigation.NavDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.firebase.quickstart.ai.feature.live.BidiViewModel
import com.google.firebase.quickstart.ai.feature.hybrid.HybridInferenceViewModel
import com.google.firebase.quickstart.ai.feature.live.StreamAudioViewModel
import com.google.firebase.quickstart.ai.feature.live.StreamVideoViewModel
import com.google.firebase.quickstart.ai.feature.text.ChatViewModel
import com.google.firebase.quickstart.ai.feature.text.ServerPromptTemplateViewModel
import com.google.firebase.quickstart.ai.feature.text.SvgViewModel
import com.google.firebase.quickstart.ai.ui.ChatScreen
import com.google.firebase.quickstart.ai.ui.HybridInferenceScreen
import com.google.firebase.quickstart.ai.ui.ServerPromptScreen
import com.google.firebase.quickstart.ai.ui.StreamRealtimeScreen
import com.google.firebase.quickstart.ai.ui.StreamRealtimeVideoScreen
import com.google.firebase.quickstart.ai.ui.HybridInferenceScreen
import com.google.firebase.quickstart.ai.ui.SvgScreen
import com.google.firebase.quickstart.ai.ui.navigation.FIREBASE_AI_SAMPLES
import com.google.firebase.quickstart.ai.ui.navigation.MainMenuScreen
Expand Down Expand Up @@ -107,14 +108,14 @@ class MainActivity : ComponentActivity() {
}

ScreenType.BIDI -> {
(vm as? BidiViewModel)?.let {
(vm as? StreamAudioViewModel)?.let {
@SuppressLint("MissingPermission")
StreamRealtimeScreen(it)
}
}

ScreenType.BIDI_VIDEO -> {
(vm as? BidiViewModel)?.let {
(vm as? StreamVideoViewModel)?.let {
@SuppressLint("MissingPermission")
StreamRealtimeVideoScreen(it)
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
package com.google.firebase.quickstart.ai.feature.live

import android.annotation.SuppressLint
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.Firebase
import com.google.firebase.ai.ai
import com.google.firebase.ai.type.AudioTranscriptionConfig
import com.google.firebase.ai.type.FunctionCallPart
import com.google.firebase.ai.type.FunctionDeclaration
import com.google.firebase.ai.type.FunctionResponsePart
import com.google.firebase.ai.type.GenerativeBackend
import com.google.firebase.ai.type.LiveSession
import com.google.firebase.ai.type.PublicPreviewAPI
import com.google.firebase.ai.type.ResponseModality
import com.google.firebase.ai.type.Schema
import com.google.firebase.ai.type.SpeechConfig
import com.google.firebase.ai.type.Tool
import com.google.firebase.ai.type.Transcription
import com.google.firebase.ai.type.Voice
import com.google.firebase.ai.type.liveGenerationConfig
import com.google.firebase.quickstart.ai.feature.text.functioncalling.WeatherRepository.Companion.fetchWeather
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
Expand All @@ -22,12 +34,29 @@ import kotlinx.serialization.json.jsonPrimitive
@Serializable
object StreamRealtimeAudioRoute

enum class TranscriptionSpeaker {
USER,
MODEL
}

data class TranscriptionItem(
val speaker: TranscriptionSpeaker,
val text: String
)

@OptIn(PublicPreviewAPI::class)
class StreamAudioViewModel : BidiViewModel() {
class StreamAudioViewModel : ViewModel() {
private var liveSession: LiveSession

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Declaring liveSession as a non-nullable property initialized via runBlocking in the init block blocks the main thread during ViewModel creation to establish a network connection. This can cause UI freezes or ANR (Application Not Responding) errors. Instead, declare liveSession as lateinit var (or nullable) and connect to the live model asynchronously using viewModelScope.launch.

Suggested change
private var liveSession: LiveSession
private lateinit var liveSession: LiveSession


private val _transcriptions = MutableStateFlow<List<TranscriptionItem>>(emptyList())
val transcriptions: StateFlow<List<TranscriptionItem>> = _transcriptions.asStateFlow()

init {
val liveGenerationConfig = liveGenerationConfig {
speechConfig = SpeechConfig(voice = Voice("CHARON"))
responseModality = ResponseModality.AUDIO
inputAudioTranscription = AudioTranscriptionConfig()
outputAudioTranscription = AudioTranscriptionConfig()
}

val liveModel =
Expand Down Expand Up @@ -60,7 +89,7 @@ class StreamAudioViewModel : BidiViewModel() {
runBlocking { liveSession = liveModel.connect() }
}

override fun handler(functionCall: FunctionCallPart): FunctionResponsePart {
fun handleFunctionCall(functionCall: FunctionCallPart): FunctionResponsePart {
val response: JsonObject
if (functionCall.name == "fetchWeather") {
val city = functionCall.args["city"]?.jsonPrimitive?.content
Expand All @@ -79,4 +108,47 @@ class StreamAudioViewModel : BidiViewModel() {
}
return FunctionResponsePart(functionCall.name, response, functionCall.id)
}

private fun handleTranscription(input: Transcription?, output: Transcription?) {
input?.text?.let { text ->
if (text.isNotEmpty()) {
_transcriptions.update { current ->
val last = current.lastOrNull()
if (last != null && last.speaker == TranscriptionSpeaker.USER) {
current.dropLast(1) + last.copy(text = last.text + text)
} else {
current + TranscriptionItem(speaker = TranscriptionSpeaker.USER, text = text)
}
}
}
}
output?.text?.let { text ->
if (text.isNotEmpty()) {
_transcriptions.update { current ->
val last = current.lastOrNull()
if (last != null && last.speaker == TranscriptionSpeaker.MODEL) {
current.dropLast(1) + last.copy(text = last.text + text)
} else {
current + TranscriptionItem(speaker = TranscriptionSpeaker.MODEL, text = text)
}
}
}
}
}

// The permission check is handled by the view that calls this function.
@SuppressLint("MissingPermission")
fun startConversation() {
viewModelScope.launch(Dispatchers.IO) {
liveSession.startAudioConversation(::handleFunctionCall, ::handleTranscription)
}
}

fun endConversation() {
liveSession.stopAudioConversation()
}

fun clearTranscriptions() {
_transcriptions.value = emptyList()
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,47 @@
package com.google.firebase.quickstart.ai.feature.live

import android.annotation.SuppressLint
import android.graphics.Bitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.Firebase
import com.google.firebase.ai.ai
import com.google.firebase.ai.type.AudioTranscriptionConfig
import com.google.firebase.ai.type.GenerativeBackend
import com.google.firebase.ai.type.InlineData
import com.google.firebase.ai.type.LiveSession
import com.google.firebase.ai.type.PublicPreviewAPI
import com.google.firebase.ai.type.ResponseModality
import com.google.firebase.ai.type.SpeechConfig
import com.google.firebase.ai.type.Transcription
import com.google.firebase.ai.type.Voice
import com.google.firebase.ai.type.liveGenerationConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import java.io.ByteArrayOutputStream

@Serializable
object StreamRealtimeVideoRoute

@OptIn(PublicPreviewAPI::class)
class StreamVideoViewModel : BidiViewModel() {
class StreamVideoViewModel : ViewModel() {
private var liveSession: LiveSession

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Declaring liveSession as a non-nullable property initialized via runBlocking in the init block blocks the main thread during ViewModel creation to establish a network connection. This can cause UI freezes or ANR (Application Not Responding) errors. Instead, declare liveSession as lateinit var (or nullable) and connect to the live model asynchronously using viewModelScope.launch.

Suggested change
private var liveSession: LiveSession
private lateinit var liveSession: LiveSession


private val _transcriptions = MutableStateFlow<List<TranscriptionItem>>(emptyList())
val transcriptions: StateFlow<List<TranscriptionItem>> = _transcriptions.asStateFlow()

init {
val liveGenerationConfig = liveGenerationConfig {
speechConfig = SpeechConfig(voice = Voice("CHARON"))
responseModality = ResponseModality.AUDIO
inputAudioTranscription = AudioTranscriptionConfig()
outputAudioTranscription = AudioTranscriptionConfig()
}

// Note that each backend supports a different set of models.
Expand All @@ -33,4 +55,58 @@ class StreamVideoViewModel : BidiViewModel() {
)
runBlocking { liveSession = liveModel.connect() }
}

private fun handleTranscription(input: Transcription?, output: Transcription?) {
input?.text?.let { text ->
if (text.isNotEmpty()) {
_transcriptions.update { current ->
val last = current.lastOrNull()
if (last != null && last.speaker == TranscriptionSpeaker.USER) {
current.dropLast(1) + last.copy(text = last.text + text)
} else {
current + TranscriptionItem(speaker = TranscriptionSpeaker.USER, text = text)
}
}
}
}
output?.text?.let { text ->
if (text.isNotEmpty()) {
_transcriptions.update { current ->
val last = current.lastOrNull()
if (last != null && last.speaker == TranscriptionSpeaker.MODEL) {
current.dropLast(1) + last.copy(text = last.text + text)
} else {
current + TranscriptionItem(speaker = TranscriptionSpeaker.MODEL, text = text)
}
}
}
}
}

// The permission check is handled by the view that calls this function.
@SuppressLint("MissingPermission")
fun startConversation() {
viewModelScope.launch(Dispatchers.IO) {
liveSession.startAudioConversation(null, ::handleTranscription)
}
}

fun endConversation() {
liveSession.stopAudioConversation()
}

fun clearTranscriptions() {
_transcriptions.value = emptyList()
}

fun sendVideoFrame(frame: Bitmap) {
viewModelScope.launch {
// Directly compress the Bitmap to a ByteArray
val byteArrayOutputStream = ByteArrayOutputStream()
frame.compress(Bitmap.CompressFormat.JPEG, 80, byteArrayOutputStream)
val jpegBytes = byteArrayOutputStream.toByteArray()

liveSession.sendVideoRealtime(InlineData(jpegBytes, "image/jpeg"))
}
}
}
Loading
Loading