From 02261061f13f9ebea59177368254637da7b4e969 Mon Sep 17 00:00:00 2001 From: Marina Coelho Date: Mon, 21 Sep 2026 14:35:26 +0100 Subject: [PATCH 1/5] Adding TTS to 'pairing' feature --- .../data/datasource/AIRemoteDataSource.kt | 50 +++++++ .../datasource/DatabaseRemoteDataSource.kt | 8 +- .../friendlymeals/data/model/Recipe.kt | 3 +- .../data/repository/AIRepository.kt | 8 ++ .../data/repository/DatabaseRepository.kt | 4 + .../friendlymeals/ui/recipe/RecipeScreen.kt | 125 ++++++++++++++++- .../ui/recipe/RecipeViewModel.kt | 129 +++++++++++++++++- .../ui/recipe/RecipeViewState.kt | 11 +- .../ui/recipe/audio/PcmAudioPlayer.kt | 123 +++++++++++++++++ app/src/main/res/drawable/ic_drink.xml | 9 ++ app/src/main/res/drawable/ic_pause.xml | 9 ++ app/src/main/res/drawable/ic_play_arrow.xml | 9 ++ app/src/main/res/drawable/ic_replay.xml | 9 ++ app/src/main/res/drawable/ic_volume_up.xml | 9 ++ app/src/main/res/values/strings.xml | 5 + 15 files changed, 502 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/audio/PcmAudioPlayer.kt create mode 100644 app/src/main/res/drawable/ic_drink.xml create mode 100644 app/src/main/res/drawable/ic_pause.xml create mode 100644 app/src/main/res/drawable/ic_play_arrow.xml create mode 100644 app/src/main/res/drawable/ic_replay.xml create mode 100644 app/src/main/res/drawable/ic_volume_up.xml diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt index 11d99b8..3b2a9e6 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt @@ -16,8 +16,13 @@ import com.google.firebase.ai.OnDeviceModelStatus.Companion.DOWNLOADABLE import com.google.firebase.ai.OnDeviceModelStatus.Companion.DOWNLOADING import com.google.firebase.ai.OnDeviceModelStatus.Companion.UNAVAILABLE import com.google.firebase.ai.type.ImagePart +import com.google.firebase.ai.type.InlineDataPart 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.Voice import com.google.firebase.ai.type.content +import com.google.firebase.ai.type.generationConfig import com.google.firebase.example.friendlymeals.data.schema.MealSchema import com.google.firebase.example.friendlymeals.data.schema.RecipeSchema import com.google.firebase.perf.performance @@ -152,6 +157,46 @@ class AIRemoteDataSource @Inject constructor( } } + suspend fun craftRecipePairing(dishTitle: String, ingredients: List): String { + val model = aiModel.generativeModel( + modelName = remoteConfig.getString(HYBRID_CLOUD_MODEL_KEY) + ) + val prompt = """ + Craft a concise, expert pairing recommendation (strictly 2 or 3 sentences) for the dish "$dishTitle" (key ingredients: ${ingredients.joinToString()}). + Recommend the ideal drink (such as a specific wine, beer, cocktail, or non-alcoholic beverage) that pairs nicely with this dish, and explain why their flavor profiles and characteristics complement each other. + Do not use markdown formatting or bullet points; write in clear conversational prose suitable for being read aloud by a narrator. Keep it strictly to 2 or 3 sentences. + """.trimIndent() + + val response = model.generateContent(prompt) + return response.text.orEmpty().trim() + } + + suspend fun generateSpeech(text: String): ByteArray? { + val config = generationConfig { + responseModalities = listOf(ResponseModality.AUDIO) + speechConfig = SpeechConfig( + voice = Voice(TTS_VOICE), + languageCode = LANGUAGE_CODE + ) + } + + val model = aiModel.generativeModel( + modelName = TTS_MODEL_NAME, + generationConfig = config + ) + + val prompt = """ + [Audio Profile: A charming, low pitch and professor-like voice] + [Scene: An elegant culinary academy classroom during a masterclass on food and beverage pairing] + [Director's Notes: Speak as if you're the professor in a classroom on a pairing course, with charming warmth, knowledgeable enthusiasm, and a distinguished professorial cadence] + $text + """.trimIndent() + + val response = model.generateContent(prompt) + val part = response.candidates.firstOrNull()?.content?.parts?.firstOrNull() + return (part as? InlineDataPart)?.inlineData + } + suspend fun loadOnDeviceModel() { when (hybridGenerativeModel.onDeviceExtension?.checkStatus()) { UNAVAILABLE -> { @@ -207,6 +252,11 @@ class AIRemoteDataSource @Inject constructor( //Grounding with Maps config private const val LANGUAGE = "en_US" + //TTS Config + private const val TTS_MODEL_NAME = "gemini-3.1-flash-tts-preview" + private const val TTS_VOICE = "Charon" + private const val LANGUAGE_CODE = "en-US" + //Class TAG private const val TAG = "AIRemoteDataSource" } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt index dde709b..0419f33 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt @@ -58,6 +58,10 @@ class DatabaseRemoteDataSource @Inject constructor( .execute().await().results.toRecipe() } + suspend fun updateRecipePairing(recipeId: String, pairing: String) { + firestore.collection(RECIPES_COLLECTION).document(recipeId).update(PAIRING_FIELD, pairing).await() + } + suspend fun getAllRecipes(): List { return firestore .pipeline() @@ -232,7 +236,8 @@ class DatabaseRemoteDataSource @Inject constructor( prepTime = itemData[PREP_TIME_FIELD] as? String ?: "", cookTime = itemData[COOK_TIME_FIELD] as? String ?: "", servings = itemData[SERVINGS_FIELD] as? String ?: "", - imageUri = itemData[IMAGE_URI_FIELD] as? String + imageUri = itemData[IMAGE_URI_FIELD] as? String, + pairing = itemData[PAIRING_FIELD] as? String ) } @@ -330,6 +335,7 @@ class DatabaseRemoteDataSource @Inject constructor( private const val RECIPE_ID_FIELD = "recipeId" private const val USER_ID_FIELD = "userId" private const val CHECKED_FIELD = "checked" + private const val PAIRING_FIELD = "pairing" //Field aliases private const val AVG_RATING_ALIAS = "avg_rating" diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt index 2e222ce..ca97383 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt @@ -12,5 +12,6 @@ data class Recipe( val prepTime: String = "", val cookTime: String = "", val servings: String = "", - val imageUri: String? = null + val imageUri: String? = null, + val pairing: String? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt index 2d945b4..f1dcd52 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt @@ -42,6 +42,14 @@ class AIRepository @Inject constructor( return aiRemoteDataSource.scanMeal(imageData) } + suspend fun craftRecipePairing(dishTitle: String, ingredients: List): String { + return aiRemoteDataSource.craftRecipePairing(dishTitle, ingredients) + } + + suspend fun generateSpeech(text: String): ByteArray? { + return aiRemoteDataSource.generateSpeech(text) + } + suspend fun loadOnDeviceModel() { aiRemoteDataSource.loadOnDeviceModel() } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt index 951ef93..c84a4b0 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt @@ -26,6 +26,10 @@ class DatabaseRepository @Inject constructor( return databaseRemoteDataSource.getRecipe(recipeId) } + suspend fun updateRecipePairing(recipeId: String, pairing: String) { + databaseRemoteDataSource.updateRecipePairing(recipeId, pairing) + } + suspend fun getAllRecipes(): List { return databaseRemoteDataSource.getAllRecipes() } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt index f547bca..4d57ebf 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold @@ -82,7 +83,8 @@ fun RecipeScreen( viewModel.addIngredientsToGroceryList(recipeViewState.value.recipe.ingredients) { Toast.makeText(context, groceryListToast, Toast.LENGTH_SHORT).show() } - } + }, + onLearnMoreClick = viewModel::onLearnMoreClick ) } @@ -93,7 +95,8 @@ fun RecipeScreenContent( leaveReview: (Int) -> Unit = {}, recipeViewState: RecipeViewState, onLiveAssistantClick: () -> Unit = {}, - onAddIngredientsToGrocery: () -> Unit = {} + onAddIngredientsToGrocery: () -> Unit = {}, + onLearnMoreClick: () -> Unit = {} ) { val context = LocalContext.current val multiplePermissionsLauncher = rememberLauncherForActivityResult( @@ -238,6 +241,13 @@ fun RecipeScreenContent( } } + Spacer(modifier = Modifier.height(16.dp)) + + PairingGuideButton( + audioState = recipeViewState.audioState, + onClick = onLearnMoreClick + ) + Spacer(modifier = Modifier.height(24.dp)) Row( @@ -461,6 +471,117 @@ fun IngredientRow(text: String) { } } +@Composable +fun PairingGuideButton( + audioState: RecipeAudioState, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val containerColor = when (audioState) { + is RecipeAudioState.Playing, is RecipeAudioState.Paused -> Teal + is RecipeAudioState.LoadingAudio -> LightTeal + is RecipeAudioState.Error -> Color(0xFFFFEBEE) + is RecipeAudioState.Idle -> LightTeal + } + + val contentColor = when (audioState) { + is RecipeAudioState.Playing, is RecipeAudioState.Paused -> Color.White + is RecipeAudioState.Error -> Color.Red + else -> Teal + } + + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor + ), + shape = RoundedCornerShape(16.dp), + modifier = modifier + .fillMaxWidth() + .height(52.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + when (audioState) { + is RecipeAudioState.LoadingAudio -> { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = Teal, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.recipe_pairing_loading_audio), + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = Teal + ) + } + + is RecipeAudioState.Playing -> { + Icon( + painter = painterResource(R.drawable.ic_pause), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.recipe_pairing_playing), + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + + is RecipeAudioState.Paused -> { + Icon( + painter = painterResource(R.drawable.ic_play_arrow), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.recipe_pairing_paused), + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + + is RecipeAudioState.Error -> { + Text( + text = stringResource(R.string.recipe_pairing_retry), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.Red + ) + } + + is RecipeAudioState.Idle -> { + Icon( + painter = painterResource(R.drawable.ic_drink), + contentDescription = null, + tint = Teal, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.recipe_pairing_learn_how), + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = Teal + ) + } + } + } + } +} + @Preview @Composable fun RecipeScreenPreview() { diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt index 17d5d4d..0f22a50 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt @@ -1,13 +1,18 @@ package com.google.firebase.example.friendlymeals.ui.recipe +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.navigation.toRoute import com.google.firebase.example.friendlymeals.MainViewModel -import com.google.firebase.example.friendlymeals.data.model.Review import com.google.firebase.example.friendlymeals.data.model.Like +import com.google.firebase.example.friendlymeals.data.model.Recipe +import com.google.firebase.example.friendlymeals.data.model.Review +import com.google.firebase.example.friendlymeals.data.repository.AIRepository import com.google.firebase.example.friendlymeals.data.repository.AuthRepository import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository +import com.google.firebase.example.friendlymeals.ui.recipe.audio.PcmAudioPlayer import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,10 +22,16 @@ import javax.inject.Inject class RecipeViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val authRepository: AuthRepository, - private val databaseRepository: DatabaseRepository + private val databaseRepository: DatabaseRepository, + private val aiRepository: AIRepository ) : MainViewModel() { private val recipeRoute = savedStateHandle.toRoute() private val recipeId: String = recipeRoute.recipeId + private val pcmAudioPlayer = PcmAudioPlayer() + private var cachedAudioData: ByteArray? = null + private var cachedPairingText: String? = null + private var pairingGenerationJob: Job? = null + private var isUserWaitingForAudio: Boolean = false private val _recipeViewState = MutableStateFlow(RecipeViewState()) val recipeViewState: StateFlow @@ -34,11 +45,116 @@ class RecipeViewModel @Inject constructor( fun loadRecipe() { launchCatching { + val recipe = databaseRepository.getRecipe(recipeId) _recipeViewState.value = RecipeViewState( recipeId = recipeId, - recipe = databaseRepository.getRecipe(recipeId), + recipe = recipe, favorite = loadFavorite(), - rating = loadRating() + rating = loadRating(), + audioState = RecipeAudioState.Idle + ) + + // First try to read from the field "pairing" in the "recipe" document + if (!recipe.pairing.isNullOrBlank()) { + // If the field "pairing" is not empty or null, and contains a text, stop the logic. + cachedPairingText = recipe.pairing + } else { + // If the "pairing" field is null or empty, generate the pairing recommendation + // and update the firestore document with it. + preparePairingInBackground(recipe) + } + } + } + + private fun preparePairingInBackground(recipe: Recipe) { + pairingGenerationJob = launchCatching { + try { + val pairing = aiRepository.craftRecipePairing(recipe.title, recipe.ingredients) + if (pairing.isNotBlank()) { + cachedPairingText = pairing + _recipeViewState.value = _recipeViewState.value.copy( + recipe = _recipeViewState.value.recipe.copy(pairing = pairing) + ) + databaseRepository.updateRecipePairing(recipeId, pairing) + + if (isUserWaitingForAudio) { + isUserWaitingForAudio = false + generateAndPlayAudio(pairing) + } + } + } catch (e: Exception) { + Log.e("RecipeViewModel", "Failed to generate recipe pairing in background", e) + if (isUserWaitingForAudio) { + isUserWaitingForAudio = false + _recipeViewState.value = _recipeViewState.value.copy( + audioState = RecipeAudioState.Error(e.message ?: "Failed to load audio") + ) + } + } + } + } + + fun onLearnMoreClick() { + when (_recipeViewState.value.audioState) { + is RecipeAudioState.Playing -> { + pcmAudioPlayer.pause() + _recipeViewState.value = _recipeViewState.value.copy( + audioState = RecipeAudioState.Paused + ) + } + is RecipeAudioState.Paused -> { + pcmAudioPlayer.resume() + _recipeViewState.value = _recipeViewState.value.copy( + audioState = RecipeAudioState.Playing + ) + } + is RecipeAudioState.LoadingAudio -> { + // Already loading audio, do nothing + } + is RecipeAudioState.Idle, is RecipeAudioState.Error -> { + if (cachedAudioData != null) { + playCachedAudio() + } else if (!cachedPairingText.isNullOrBlank()) { + launchCatching { + generateAndPlayAudio(cachedPairingText!!) + } + } else { + // Pairing text is still generating in the background + isUserWaitingForAudio = true + _recipeViewState.value = _recipeViewState.value.copy( + audioState = RecipeAudioState.LoadingAudio + ) + } + } + } + } + + private fun playCachedAudio() { + val audioData = cachedAudioData ?: return + _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Playing) + pcmAudioPlayer.play(audioData) { + _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Idle) + } + } + + private suspend fun generateAndPlayAudio(pairingText: String) { + _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.LoadingAudio) + try { + val audio = aiRepository.generateSpeech(pairingText) + if (audio != null && audio.isNotEmpty()) { + cachedAudioData = audio + _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Playing) + pcmAudioPlayer.play(audio) { + _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Idle) + } + } else { + _recipeViewState.value = _recipeViewState.value.copy( + audioState = RecipeAudioState.Error("Could not generate audio") + ) + } + } catch (e: Exception) { + _recipeViewState.value = _recipeViewState.value.copy( + audioState = RecipeAudioState.Error(e.message ?: "Failed to generate audio") ) } } @@ -94,4 +210,9 @@ class RecipeViewModel @Inject constructor( onSuccess() } } + + override fun onCleared() { + pcmAudioPlayer.release() + super.onCleared() + } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt index 1e8aee9..dcaa2e6 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt @@ -2,9 +2,18 @@ package com.google.firebase.example.friendlymeals.ui.recipe import com.google.firebase.example.friendlymeals.data.model.Recipe +sealed interface RecipeAudioState { + object Idle : RecipeAudioState + object LoadingAudio : RecipeAudioState + object Playing : RecipeAudioState + object Paused : RecipeAudioState + data class Error(val message: String) : RecipeAudioState +} + data class RecipeViewState( val recipeId: String = "", val recipe: Recipe = Recipe(), val favorite: Boolean = false, - val rating: Int = 0 + val rating: Int = 0, + val audioState: RecipeAudioState = RecipeAudioState.Idle ) \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/audio/PcmAudioPlayer.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/audio/PcmAudioPlayer.kt new file mode 100644 index 0000000..f550117 --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/audio/PcmAudioPlayer.kt @@ -0,0 +1,123 @@ +package com.google.firebase.example.friendlymeals.ui.recipe.audio + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import android.os.Handler +import android.os.Looper +import android.util.Log + +class PcmAudioPlayer { + private var audioTrack: AudioTrack? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var isPaused = false + + fun play(pcmData: ByteArray, onCompletion: () -> Unit) { + stop() + + if (pcmData.isEmpty()) { + onCompletion() + return + } + + try { + val sampleRate = 24000 + val channelConfig = AudioFormat.CHANNEL_OUT_MONO + val audioFormat = AudioFormat.ENCODING_PCM_16BIT + val frameSize = 2 // 16-bit mono = 2 bytes per frame + val frameCount = pcmData.size / frameSize + + val track = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + ) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(audioFormat) + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .build() + ) + .setBufferSizeInBytes(pcmData.size) + .setTransferMode(AudioTrack.MODE_STATIC) + .build() + + track.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener { + override fun onMarkerReached(track: AudioTrack?) { + mainHandler.post { + onCompletion() + } + } + + override fun onPeriodicNotification(track: AudioTrack?) {} + }, mainHandler) + + track.notificationMarkerPosition = frameCount + + track.write(pcmData, 0, pcmData.size) + track.play() + audioTrack = track + isPaused = false + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize or play AudioTrack", e) + onCompletion() + } + } + + fun pause() { + try { + if (audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING) { + audioTrack?.pause() + isPaused = true + } + } catch (e: Exception) { + Log.e(TAG, "Error pausing AudioTrack", e) + } + } + + fun resume() { + try { + if (audioTrack?.playState == AudioTrack.PLAYSTATE_PAUSED) { + audioTrack?.play() + isPaused = false + } + } catch (e: Exception) { + Log.e(TAG, "Error resuming AudioTrack", e) + } + } + + fun stop() { + try { + audioTrack?.let { + if (it.playState != AudioTrack.PLAYSTATE_STOPPED) { + it.stop() + } + it.release() + } + } catch (e: Exception) { + Log.e(TAG, "Error stopping AudioTrack", e) + } finally { + audioTrack = null + isPaused = false + } + } + + fun isPlaying(): Boolean { + return audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING + } + + fun isPaused(): Boolean { + return isPaused + } + + fun release() { + stop() + } + + companion object { + private const val TAG = "PcmAudioPlayer" + } +} diff --git a/app/src/main/res/drawable/ic_drink.xml b/app/src/main/res/drawable/ic_drink.xml new file mode 100644 index 0000000..1a3680f --- /dev/null +++ b/app/src/main/res/drawable/ic_drink.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..de759cf --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow.xml b/app/src/main/res/drawable/ic_play_arrow.xml new file mode 100644 index 0000000..370d19a --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_replay.xml b/app/src/main/res/drawable/ic_replay.xml new file mode 100644 index 0000000..d0dc727 --- /dev/null +++ b/app/src/main/res/drawable/ic_replay.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up.xml b/app/src/main/res/drawable/ic_volume_up.xml new file mode 100644 index 0000000..824cf37 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de67148..bc0c50d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,4 +76,9 @@ 🅿️ Parking Available No Parking Store Finder + Learn how to pair this dish with a drink + Loading audio… + Playing pairing guide • Tap to pause + Paused • Tap to resume + Error playing audio • Tap to retry \ No newline at end of file From 7eaa8f80cc984cd8cc2c353695cf163acaa40628 Mon Sep 17 00:00:00 2001 From: Marina Coelho Date: Tue, 22 Sep 2026 12:23:09 +0100 Subject: [PATCH 2/5] Improving audio generation flow, deleting unused icons --- .../datasource/DatabaseRemoteDataSource.kt | 4 --- .../data/repository/DatabaseRepository.kt | 4 --- .../friendlymeals/ui/recipe/RecipeScreen.kt | 6 ++-- .../ui/recipe/RecipeViewModel.kt | 15 +++++----- .../AudioComponent.kt} | 28 +++++++------------ app/src/main/res/drawable/ic_replay.xml | 9 ------ app/src/main/res/drawable/ic_volume_up.xml | 9 ------ 7 files changed, 20 insertions(+), 55 deletions(-) rename app/src/main/java/com/google/firebase/example/friendlymeals/ui/{recipe/audio/PcmAudioPlayer.kt => shared/AudioComponent.kt} (82%) delete mode 100644 app/src/main/res/drawable/ic_replay.xml delete mode 100644 app/src/main/res/drawable/ic_volume_up.xml diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt index 0419f33..f2e24ef 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt @@ -58,10 +58,6 @@ class DatabaseRemoteDataSource @Inject constructor( .execute().await().results.toRecipe() } - suspend fun updateRecipePairing(recipeId: String, pairing: String) { - firestore.collection(RECIPES_COLLECTION).document(recipeId).update(PAIRING_FIELD, pairing).await() - } - suspend fun getAllRecipes(): List { return firestore .pipeline() diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt index c84a4b0..951ef93 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt @@ -26,10 +26,6 @@ class DatabaseRepository @Inject constructor( return databaseRemoteDataSource.getRecipe(recipeId) } - suspend fun updateRecipePairing(recipeId: String, pairing: String) { - databaseRemoteDataSource.updateRecipePairing(recipeId, pairing) - } - suspend fun getAllRecipes(): List { return databaseRemoteDataSource.getAllRecipes() } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt index 4d57ebf..6c10b89 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt @@ -84,7 +84,7 @@ fun RecipeScreen( Toast.makeText(context, groceryListToast, Toast.LENGTH_SHORT).show() } }, - onLearnMoreClick = viewModel::onLearnMoreClick + onPairingClick = viewModel::onPairingClick ) } @@ -96,7 +96,7 @@ fun RecipeScreenContent( recipeViewState: RecipeViewState, onLiveAssistantClick: () -> Unit = {}, onAddIngredientsToGrocery: () -> Unit = {}, - onLearnMoreClick: () -> Unit = {} + onPairingClick: () -> Unit = {} ) { val context = LocalContext.current val multiplePermissionsLauncher = rememberLauncherForActivityResult( @@ -245,7 +245,7 @@ fun RecipeScreenContent( PairingGuideButton( audioState = recipeViewState.audioState, - onClick = onLearnMoreClick + onClick = onPairingClick ) Spacer(modifier = Modifier.height(24.dp)) diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt index 0f22a50..67b27db 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt @@ -10,7 +10,7 @@ import com.google.firebase.example.friendlymeals.data.model.Review import com.google.firebase.example.friendlymeals.data.repository.AIRepository import com.google.firebase.example.friendlymeals.data.repository.AuthRepository import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository -import com.google.firebase.example.friendlymeals.ui.recipe.audio.PcmAudioPlayer +import com.google.firebase.example.friendlymeals.ui.shared.AudioComponent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -27,7 +27,7 @@ class RecipeViewModel @Inject constructor( ) : MainViewModel() { private val recipeRoute = savedStateHandle.toRoute() private val recipeId: String = recipeRoute.recipeId - private val pcmAudioPlayer = PcmAudioPlayer() + private val audioComponent = AudioComponent() private var cachedAudioData: ByteArray? = null private var cachedPairingText: String? = null private var pairingGenerationJob: Job? = null @@ -75,7 +75,6 @@ class RecipeViewModel @Inject constructor( _recipeViewState.value = _recipeViewState.value.copy( recipe = _recipeViewState.value.recipe.copy(pairing = pairing) ) - databaseRepository.updateRecipePairing(recipeId, pairing) if (isUserWaitingForAudio) { isUserWaitingForAudio = false @@ -97,13 +96,13 @@ class RecipeViewModel @Inject constructor( fun onLearnMoreClick() { when (_recipeViewState.value.audioState) { is RecipeAudioState.Playing -> { - pcmAudioPlayer.pause() + audioComponent.pause() _recipeViewState.value = _recipeViewState.value.copy( audioState = RecipeAudioState.Paused ) } is RecipeAudioState.Paused -> { - pcmAudioPlayer.resume() + audioComponent.resume() _recipeViewState.value = _recipeViewState.value.copy( audioState = RecipeAudioState.Playing ) @@ -132,7 +131,7 @@ class RecipeViewModel @Inject constructor( private fun playCachedAudio() { val audioData = cachedAudioData ?: return _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Playing) - pcmAudioPlayer.play(audioData) { + audioComponent.play(audioData) { _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Idle) } } @@ -144,7 +143,7 @@ class RecipeViewModel @Inject constructor( if (audio != null && audio.isNotEmpty()) { cachedAudioData = audio _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Playing) - pcmAudioPlayer.play(audio) { + audioComponent.play(audio) { _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Idle) } } else { @@ -212,7 +211,7 @@ class RecipeViewModel @Inject constructor( } override fun onCleared() { - pcmAudioPlayer.release() + audioComponent.release() super.onCleared() } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/audio/PcmAudioPlayer.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/AudioComponent.kt similarity index 82% rename from app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/audio/PcmAudioPlayer.kt rename to app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/AudioComponent.kt index f550117..800132a 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/audio/PcmAudioPlayer.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/AudioComponent.kt @@ -1,4 +1,4 @@ -package com.google.firebase.example.friendlymeals.ui.recipe.audio +package com.google.firebase.example.friendlymeals.ui.shared import android.media.AudioAttributes import android.media.AudioFormat @@ -7,15 +7,15 @@ import android.os.Handler import android.os.Looper import android.util.Log -class PcmAudioPlayer { +class AudioComponent { private var audioTrack: AudioTrack? = null private val mainHandler = Handler(Looper.getMainLooper()) private var isPaused = false - fun play(pcmData: ByteArray, onCompletion: () -> Unit) { + fun play(data: ByteArray, onCompletion: () -> Unit) { stop() - if (pcmData.isEmpty()) { + if (data.isEmpty()) { onCompletion() return } @@ -24,8 +24,8 @@ class PcmAudioPlayer { val sampleRate = 24000 val channelConfig = AudioFormat.CHANNEL_OUT_MONO val audioFormat = AudioFormat.ENCODING_PCM_16BIT - val frameSize = 2 // 16-bit mono = 2 bytes per frame - val frameCount = pcmData.size / frameSize + val frameSize = 2 + val frameCount = data.size / frameSize val track = AudioTrack.Builder() .setAudioAttributes( @@ -41,7 +41,7 @@ class PcmAudioPlayer { .setChannelMask(channelConfig) .build() ) - .setBufferSizeInBytes(pcmData.size) + .setBufferSizeInBytes(data.size) .setTransferMode(AudioTrack.MODE_STATIC) .build() @@ -57,7 +57,7 @@ class PcmAudioPlayer { track.notificationMarkerPosition = frameCount - track.write(pcmData, 0, pcmData.size) + track.write(data, 0, data.size) track.play() audioTrack = track isPaused = false @@ -105,19 +105,11 @@ class PcmAudioPlayer { } } - fun isPlaying(): Boolean { - return audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING - } - - fun isPaused(): Boolean { - return isPaused - } - fun release() { stop() } companion object { - private const val TAG = "PcmAudioPlayer" + private const val TAG = "AudioComponent" } -} +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_replay.xml b/app/src/main/res/drawable/ic_replay.xml deleted file mode 100644 index d0dc727..0000000 --- a/app/src/main/res/drawable/ic_replay.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_volume_up.xml b/app/src/main/res/drawable/ic_volume_up.xml deleted file mode 100644 index 824cf37..0000000 --- a/app/src/main/res/drawable/ic_volume_up.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - From 4d78b5c9d256ea97a68cf8699a2c29e04657ceda Mon Sep 17 00:00:00 2001 From: Marina Coelho Date: Tue, 22 Sep 2026 12:48:12 +0100 Subject: [PATCH 3/5] New flow for generating the pairing recommendation, with a new voice profile --- .../data/datasource/AIRemoteDataSource.kt | 16 +-- .../datasource/DatabaseRemoteDataSource.kt | 4 +- .../friendlymeals/data/model/Recipe.kt | 3 +- .../data/repository/AIRepository.kt | 2 +- .../ui/recipe/RecipeViewModel.kt | 109 ++++++------------ 5 files changed, 50 insertions(+), 84 deletions(-) diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt index 3b2a9e6..1a26301 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt @@ -162,16 +162,18 @@ class AIRemoteDataSource @Inject constructor( modelName = remoteConfig.getString(HYBRID_CLOUD_MODEL_KEY) ) val prompt = """ - Craft a concise, expert pairing recommendation (strictly 2 or 3 sentences) for the dish "$dishTitle" (key ingredients: ${ingredients.joinToString()}). - Recommend the ideal drink (such as a specific wine, beer, cocktail, or non-alcoholic beverage) that pairs nicely with this dish, and explain why their flavor profiles and characteristics complement each other. - Do not use markdown formatting or bullet points; write in clear conversational prose suitable for being read aloud by a narrator. Keep it strictly to 2 or 3 sentences. + Craft a concise, expert wine pairing recommendation (strictly 2 or 3 sentences) for the dish "$dishTitle" (key ingredients: ${ingredients.joinToString()}). + Recommend the ideal French wine that pairs nicely with this dish, and explain why their flavor profiles and characteristics complement each other. It should ALWAYS be paired with a FRENCH WINE. + Do not use markdown formatting or bullet points; write in clear conversational prose suitable for being read aloud by a French chef. Keep it strictly to 2 or 3 sentences. """.trimIndent() val response = model.generateContent(prompt) return response.text.orEmpty().trim() } - suspend fun generateSpeech(text: String): ByteArray? { + suspend fun generateSpeech(text: String?): ByteArray? { + if (text.isNullOrBlank()) return null + val config = generationConfig { responseModalities = listOf(ResponseModality.AUDIO) speechConfig = SpeechConfig( @@ -186,9 +188,9 @@ class AIRemoteDataSource @Inject constructor( ) val prompt = """ - [Audio Profile: A charming, low pitch and professor-like voice] - [Scene: An elegant culinary academy classroom during a masterclass on food and beverage pairing] - [Director's Notes: Speak as if you're the professor in a classroom on a pairing course, with charming warmth, knowledgeable enthusiasm, and a distinguished professorial cadence] + [Audio Profile: A French chef who is an expert in pairing with wines, with a charming French accent] + [Scene: An elegant Parisian restaurant during a masterclass on food and wine pairing] + [Director's Notes: Speak as if you're a French chef who is an expert in pairing with wines. It should ALWAYS be paired with a FRENCH WINE. The speaker should have a charming French accent, with warm enthusiasm, culinary sophistication, and flair.] $text """.trimIndent() diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt index f2e24ef..dde709b 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt @@ -232,8 +232,7 @@ class DatabaseRemoteDataSource @Inject constructor( prepTime = itemData[PREP_TIME_FIELD] as? String ?: "", cookTime = itemData[COOK_TIME_FIELD] as? String ?: "", servings = itemData[SERVINGS_FIELD] as? String ?: "", - imageUri = itemData[IMAGE_URI_FIELD] as? String, - pairing = itemData[PAIRING_FIELD] as? String + imageUri = itemData[IMAGE_URI_FIELD] as? String ) } @@ -331,7 +330,6 @@ class DatabaseRemoteDataSource @Inject constructor( private const val RECIPE_ID_FIELD = "recipeId" private const val USER_ID_FIELD = "userId" private const val CHECKED_FIELD = "checked" - private const val PAIRING_FIELD = "pairing" //Field aliases private const val AVG_RATING_ALIAS = "avg_rating" diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt index ca97383..2e222ce 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt @@ -12,6 +12,5 @@ data class Recipe( val prepTime: String = "", val cookTime: String = "", val servings: String = "", - val imageUri: String? = null, - val pairing: String? = null + val imageUri: String? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt index f1dcd52..3f574d7 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt @@ -46,7 +46,7 @@ class AIRepository @Inject constructor( return aiRemoteDataSource.craftRecipePairing(dishTitle, ingredients) } - suspend fun generateSpeech(text: String): ByteArray? { + suspend fun generateSpeech(text: String?): ByteArray? { return aiRemoteDataSource.generateSpeech(text) } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt index 67b27db..c4b8cc9 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt @@ -1,18 +1,15 @@ package com.google.firebase.example.friendlymeals.ui.recipe -import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.navigation.toRoute import com.google.firebase.example.friendlymeals.MainViewModel import com.google.firebase.example.friendlymeals.data.model.Like -import com.google.firebase.example.friendlymeals.data.model.Recipe import com.google.firebase.example.friendlymeals.data.model.Review import com.google.firebase.example.friendlymeals.data.repository.AIRepository import com.google.firebase.example.friendlymeals.data.repository.AuthRepository import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository import com.google.firebase.example.friendlymeals.ui.shared.AudioComponent import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -27,11 +24,6 @@ class RecipeViewModel @Inject constructor( ) : MainViewModel() { private val recipeRoute = savedStateHandle.toRoute() private val recipeId: String = recipeRoute.recipeId - private val audioComponent = AudioComponent() - private var cachedAudioData: ByteArray? = null - private var cachedPairingText: String? = null - private var pairingGenerationJob: Job? = null - private var isUserWaitingForAudio: Boolean = false private val _recipeViewState = MutableStateFlow(RecipeViewState()) val recipeViewState: StateFlow @@ -39,6 +31,10 @@ class RecipeViewModel @Inject constructor( val userId: String get() = authRepository.currentUser?.uid.orEmpty() + private val audioComponent = AudioComponent() + private var cachedAudioData: ByteArray? = null + private var cachedPairingText: String? = null + init { loadRecipe() } @@ -53,47 +49,10 @@ class RecipeViewModel @Inject constructor( rating = loadRating(), audioState = RecipeAudioState.Idle ) - - // First try to read from the field "pairing" in the "recipe" document - if (!recipe.pairing.isNullOrBlank()) { - // If the field "pairing" is not empty or null, and contains a text, stop the logic. - cachedPairingText = recipe.pairing - } else { - // If the "pairing" field is null or empty, generate the pairing recommendation - // and update the firestore document with it. - preparePairingInBackground(recipe) - } - } - } - - private fun preparePairingInBackground(recipe: Recipe) { - pairingGenerationJob = launchCatching { - try { - val pairing = aiRepository.craftRecipePairing(recipe.title, recipe.ingredients) - if (pairing.isNotBlank()) { - cachedPairingText = pairing - _recipeViewState.value = _recipeViewState.value.copy( - recipe = _recipeViewState.value.recipe.copy(pairing = pairing) - ) - - if (isUserWaitingForAudio) { - isUserWaitingForAudio = false - generateAndPlayAudio(pairing) - } - } - } catch (e: Exception) { - Log.e("RecipeViewModel", "Failed to generate recipe pairing in background", e) - if (isUserWaitingForAudio) { - isUserWaitingForAudio = false - _recipeViewState.value = _recipeViewState.value.copy( - audioState = RecipeAudioState.Error(e.message ?: "Failed to load audio") - ) - } - } } } - fun onLearnMoreClick() { + fun onPairingClick() { when (_recipeViewState.value.audioState) { is RecipeAudioState.Playing -> { audioComponent.pause() @@ -113,16 +72,8 @@ class RecipeViewModel @Inject constructor( is RecipeAudioState.Idle, is RecipeAudioState.Error -> { if (cachedAudioData != null) { playCachedAudio() - } else if (!cachedPairingText.isNullOrBlank()) { - launchCatching { - generateAndPlayAudio(cachedPairingText!!) - } } else { - // Pairing text is still generating in the background - isUserWaitingForAudio = true - _recipeViewState.value = _recipeViewState.value.copy( - audioState = RecipeAudioState.LoadingAudio - ) + launchCatching { generatePairingAndAudio() } } } } @@ -130,32 +81,48 @@ class RecipeViewModel @Inject constructor( private fun playCachedAudio() { val audioData = cachedAudioData ?: return - _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Playing) + + _recipeViewState.value = _recipeViewState.value.copy( + audioState = RecipeAudioState.Playing + + ) audioComponent.play(audioData) { - _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Idle) + _recipeViewState.value = _recipeViewState.value.copy( + audioState = RecipeAudioState.Idle + ) } } - private suspend fun generateAndPlayAudio(pairingText: String) { - _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.LoadingAudio) - try { - val audio = aiRepository.generateSpeech(pairingText) - if (audio != null && audio.isNotEmpty()) { - cachedAudioData = audio - _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Playing) - audioComponent.play(audio) { - _recipeViewState.value = _recipeViewState.value.copy(audioState = RecipeAudioState.Idle) - } - } else { + private suspend fun generatePairingAndAudio() { + _recipeViewState.value = _recipeViewState.value.copy( + audioState = RecipeAudioState.LoadingAudio + ) + + + if (cachedPairingText.isNullOrBlank()) { + val recipe = _recipeViewState.value.recipe + val generated = aiRepository.craftRecipePairing(recipe.title, recipe.ingredients) + cachedPairingText = generated + } + + val audio = aiRepository.generateSpeech(cachedPairingText) + + if (audio != null && audio.isNotEmpty()) { + cachedAudioData = audio + _recipeViewState.value = _recipeViewState.value.copy( + audioState = RecipeAudioState.Playing + ) + audioComponent.play(audio) { _recipeViewState.value = _recipeViewState.value.copy( - audioState = RecipeAudioState.Error("Could not generate audio") + audioState = RecipeAudioState.Idle ) } - } catch (e: Exception) { + } else { _recipeViewState.value = _recipeViewState.value.copy( - audioState = RecipeAudioState.Error(e.message ?: "Failed to generate audio") + audioState = RecipeAudioState.Error("Could not generate audio") ) } + } private suspend fun loadFavorite(): Boolean { From 37ffd8241934184d46fcd106e002b22b2b9d3133 Mon Sep 17 00:00:00 2001 From: Marina Coelho Date: Tue, 22 Sep 2026 13:44:38 +0100 Subject: [PATCH 4/5] Moved model config and prompt to the server --- .../data/datasource/AIRemoteDataSource.kt | 53 ++++++++----------- app/src/main/res/values/strings.xml | 2 +- .../main/res/xml/remote_config_defaults.xml | 12 +++++ 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt index 1a26301..1bb3a09 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt @@ -158,44 +158,35 @@ class AIRemoteDataSource @Inject constructor( } suspend fun craftRecipePairing(dishTitle: String, ingredients: List): String { - val model = aiModel.generativeModel( - modelName = remoteConfig.getString(HYBRID_CLOUD_MODEL_KEY) + val response = templateGenerativeModel.generateContent( + templateId = remoteConfig.getString(RECIPE_PAIRING_KEY), + inputs = mapOf( + RECIPE_TITLE_FIELD to dishTitle, + INGREDIENTS_FIELD to ingredients.joinToString() + ) ) - val prompt = """ - Craft a concise, expert wine pairing recommendation (strictly 2 or 3 sentences) for the dish "$dishTitle" (key ingredients: ${ingredients.joinToString()}). - Recommend the ideal French wine that pairs nicely with this dish, and explain why their flavor profiles and characteristics complement each other. It should ALWAYS be paired with a FRENCH WINE. - Do not use markdown formatting or bullet points; write in clear conversational prose suitable for being read aloud by a French chef. Keep it strictly to 2 or 3 sentences. - """.trimIndent() - val response = model.generateContent(prompt) return response.text.orEmpty().trim() } suspend fun generateSpeech(text: String?): ByteArray? { if (text.isNullOrBlank()) return null - val config = generationConfig { - responseModalities = listOf(ResponseModality.AUDIO) - speechConfig = SpeechConfig( - voice = Voice(TTS_VOICE), - languageCode = LANGUAGE_CODE - ) - } - - val model = aiModel.generativeModel( - modelName = TTS_MODEL_NAME, - generationConfig = config + val model = aiModel.generativeModel( + modelName = remoteConfig.getString(TTS_MODEL_KEY), + generationConfig = generationConfig { + responseModalities = listOf(ResponseModality.AUDIO) + speechConfig = SpeechConfig( + voice = Voice(TTS_VOICE), + languageCode = TTS_LANGUAGE + ) + } ) - val prompt = """ - [Audio Profile: A French chef who is an expert in pairing with wines, with a charming French accent] - [Scene: An elegant Parisian restaurant during a masterclass on food and wine pairing] - [Director's Notes: Speak as if you're a French chef who is an expert in pairing with wines. It should ALWAYS be paired with a FRENCH WINE. The speaker should have a charming French accent, with warm enthusiasm, culinary sophistication, and flair.] - $text - """.trimIndent() - - val response = model.generateContent(prompt) + val voiceProfile = remoteConfig.getString(VOICE_PROFILE_KEY) + val response = model.generateContent("$voiceProfile $text") val part = response.candidates.firstOrNull()?.content?.parts?.firstOrNull() + return (part as? InlineDataPart)?.inlineData } @@ -238,6 +229,9 @@ class AIRemoteDataSource @Inject constructor( private const val FIND_STORES_KEY = "find_stores" private const val HYBRID_CLOUD_MODEL_KEY = "hybrid_cloud_model" private const val HYBRID_INGREDIENTS_PROMPT_KEY = "hybrid_ingredients_prompt" + private const val RECIPE_PAIRING_KEY = "recipe_pairing" + private const val VOICE_PROFILE_KEY = "recipe_pairing_voice_profile" + private const val TTS_MODEL_KEY = "tts_model" //Template input fields private const val IMAGE_DATA_FIELD = "imageData" @@ -254,10 +248,9 @@ class AIRemoteDataSource @Inject constructor( //Grounding with Maps config private const val LANGUAGE = "en_US" - //TTS Config - private const val TTS_MODEL_NAME = "gemini-3.1-flash-tts-preview" + //TTS config private const val TTS_VOICE = "Charon" - private const val LANGUAGE_CODE = "en-US" + private const val TTS_LANGUAGE = "en-US" //Class TAG private const val TAG = "AIRemoteDataSource" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc0c50d..c47819d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,7 +76,7 @@ 🅿️ Parking Available No Parking Store Finder - Learn how to pair this dish with a drink + Learn how to pair this dish with wine Loading audio… Playing pairing guide • Tap to pause Paused • Tap to resume diff --git a/app/src/main/res/xml/remote_config_defaults.xml b/app/src/main/res/xml/remote_config_defaults.xml index 4673261..91176ae 100644 --- a/app/src/main/res/xml/remote_config_defaults.xml +++ b/app/src/main/res/xml/remote_config_defaults.xml @@ -3,6 +3,18 @@ find_stores find-stores-template-v1-0-0 + + recipe_pairing + recipe-pairing-v1-0-0 + + + recipe_pairing_voice_profile + [Audio Profile: A French chef with a charming French accent][Scene: An elegant Parisian restaurant during a masterclass on food and wine pairing][Director's Notes: Speak as if you're a French chef who is an expert in pairing dishes with wines. Speak with warm enthusiasm and culinary sophistication] + + + tts_model + gemini-3.1-flash-tts-preview + hybrid_cloud_model gemini-3.1-flash-lite From a16300ff144e1e246e419e4e61db5c3f5ab0df91 Mon Sep 17 00:00:00 2001 From: Marina Coelho Date: Tue, 22 Sep 2026 18:41:47 +0100 Subject: [PATCH 5/5] Rename button --- .../firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt index 6c10b89..949c572 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt @@ -571,7 +571,7 @@ fun PairingGuideButton( ) Spacer(modifier = Modifier.width(10.dp)) Text( - text = stringResource(R.string.recipe_pairing_learn_how), + text = stringResource(R.string.recipe_pairing_french_wine), fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Teal diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c47819d..2758621 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,7 +76,7 @@ 🅿️ Parking Available No Parking Store Finder - Learn how to pair this dish with wine + Pair this dish with French wine Loading audio… Playing pairing guide • Tap to pause Paused • Tap to resume