package com.suno.android.ui.screens.create.text import android.content.res.Resources import androidx.compose.runtime.Stable import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import com.suno.android.captcha.CaptchaManager import com.suno.android.common_analytics.managers.AnalyticsManager import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.environment.CreateDataStore import com.suno.android.common_core_utils.environment.UserPrefsDataStoreManager import com.suno.android.common_core_utils.extensions.upsert import com.suno.android.common_core_utils.global_errors.DialogSpec import com.suno.android.common_core_utils.global_errors.TopLevelErrorManager import com.suno.android.common_core_utils.model.UiString import com.suno.android.common_core_utils.model.create.LocalCreateMode import com.suno.android.common_data.billing.SelectedModelProvider import com.suno.android.common_data.billing.SunoBillingRepo import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.mappers.projects.ProjectMetadata import com.suno.android.common_data.mappers.projects.xAsProjectListItem import com.suno.android.common_data.repos.ClipsRepository import com.suno.android.common_data.repos.GenerationRepository import com.suno.android.common_data.repos.ProjectsRepository import com.suno.android.common_mvi.MviEffect import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_mvi.MviViewModel import com.suno.android.common_networking.extensions.ErrorResponse import com.suno.android.common_networking.extensions.toThrowable import com.suno.android.common_networking.extensions.xGetErrorResponse import com.suno.android.common_networking.remote.captcha_check.CaptchaCheckService import com.suno.android.common_networking.remote.entities.CheckCaptchaRequest import com.suno.android.common_networking.remote.entities.GenControlSlidersSpec import com.suno.android.common_networking.remote.entities.GenLyricsSpec import com.suno.android.common_networking.remote.entities.GenMetadataSpec import com.suno.android.common_networking.remote.entities.GenParamsSpec import com.suno.android.common_networking.remote.entities.GenUpsampleTagsSpec import com.suno.android.common_networking.remote.generate.GenerateService import com.suno.android.common_networking.remote.recommend_styles.RecommendStylesRequest import com.suno.android.common_networking.remote.recommend_styles.RecommendStylesService import com.suno.android.common_res.R import com.suno.android.common_ui.models.asString import com.suno.android.gating.Feature import com.suno.android.gating.FeatureManager import com.suno.android.review.AppReviewManager import com.suno.android.ui.screens.create.audio.CreateAudioController import com.suno.android.ui.screens.create.audio.CreateAudioEvent import com.suno.android.ui.screens.create.audio.CreateAudioState import com.suno.android.ui.screens.create.audio.model.AudioTaskParametersState import com.suno.android.ui.screens.create.text.CreateTextEffect.TextGenCreationSuccess import com.suno.android.utils.UndoRedoState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import retrofit2.HttpException import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit @HiltViewModel class CreateTextScreenVM @Inject constructor( processorFactory: MviProcessorFactory, private val generateService: GenerateService, private val captchaCheckService: CaptchaCheckService, private val recommendStylesService: RecommendStylesService, private val appReviewManager: AppReviewManager, private val billingRepo: SunoBillingRepo, private val selectedModelProvider: SelectedModelProvider, private val userPrefsDataStoreManager: UserPrefsDataStoreManager, private val projectsRepo: ProjectsRepository, private val captchaManager: CaptchaManager, private val analyticsManager: AnalyticsManager, private val topLevelErrorManager: TopLevelErrorManager, private val clipsRepository: ClipsRepository, private val resources: Resources, featureManager: FeatureManager, private val createDataStore: CreateDataStore, createAudioController: CreateAudioController.Factory, private val generationRepository: GenerationRepository, ) : MviViewModel( processorFactory = processorFactory, initialState = CreateTextState( availableModels = billingRepo.billingStateFlow().value?.models ?: emptyList(), accessibleFeatures = billingRepo.billingStateFlow().value?.accessibleFeatures ?: emptyList(), isShowCustomAdvancedOptionsGateEnabled = featureManager.hasFeature(Feature.ShowCustomAdvancedOptions), isEnhanceStyleGateEnabled = featureManager.hasFeature(Feature.CreateCustomEnhanceStyle), isResetFieldsGateEnabled = featureManager.hasFeature(Feature.ShowCreateCustomModeResetFields), isShowAudioInfluenceSliderGateEnabled = featureManager.hasFeature(Feature.ShowAudioInfluenceSlider), isProjectSelectionGateEnabled = featureManager.hasFeature(Feature.CreateProjectSelection), isWorkspacesGateEnabled = featureManager.hasFeature(Feature.Workspaces), isShowAudioOnSimpleModeGateEnabled = featureManager.hasFeature(Feature.ShowAudioCreateOnSimple), isOrpheusChatEnabled = featureManager.hasFeature(Feature.OrpheusChat), isCustom = featureManager.hasFeature(Feature.OrpheusChat), ), ) { private val audioController = createAudioController.create(viewModelScope).also { it.state.onEach { audioState -> updateState { currentState -> currentState.copy(audioState = audioState) } }.launchIn(viewModelScope) } private var lyricsHistory: UndoRedoState @Stable val audioEffects get() = audioController.effects private val _isGeneratingStateFlow = MutableStateFlow(false) private val effectDebounceJob = MutableStateFlow(null) private fun emitDebouncedEffect( effect: MviEffect, ) { effectDebounceJob.value?.cancel() effectDebounceJob.value = viewModelScope.launch { delay(500.milliseconds) emitEffect(effect as CreateTextEffect) } } init { loadPersistedState() refreshRecommendStyles(CreateTextState().recommendStyles) viewModelScope.launch { billingRepo.refreshBillingState() } updateSelectedModelAndRemainingSongs() lyricsHistory = UndoRedoState(state.value.lyricsInput) } private fun loadPersistedState() { viewModelScope.launch { loadPersistedCustomModeState() } viewModelScope.launch { loadPersistedProjectState() } viewModelScope.launch { fetchProjectsPage() } } private suspend fun loadPersistedCustomModeState() { if (state.value.isOrpheusChatEnabled) return val lastUsedMode = createDataStore.getDefaultCreateMode().firstOrNull() ?: return updateState { currentState -> currentState.copy( isCustom = when (lastUsedMode) { LocalCreateMode.Simple -> false LocalCreateMode.Custom -> true }, ) } } private suspend fun loadPersistedProjectState() { val selectedProject = createDataStore.getSelectedProject().firstOrNull()?.getOrNull() ?: return updateState { currentState -> currentState.copy( selectedProject = selectedProject.xAsProjectListItem(), ) } } private fun updateSelectedModelAndRemainingSongs() { combine( selectedModelProvider.getSelectedModelFlow(), billingRepo.billingStateFlow(), ::Pair, ).onEach { (selectedModel, billingInfo) -> val accessibleFeatures = billingInfo?.accessibleFeatures ?: emptyList() updateState { it.copy( activeModel = selectedModel, accessibleFeatures = accessibleFeatures, maxSongDescriptionContextLength = selectedModel.maxLengths?.gptDescriptionPrompt ?: 200, maxLyricsContextLength = selectedModel.maxLengths?.prompt ?: 3000, maxStyleContextLength = selectedModel.maxLengths?.tags ?: 200, maxTitleContextLength = selectedModel.maxLengths?.title ?: 200, creditCount = billingInfo?.totalCreditsLeft ?: 0, subscriptionInfo = billingInfo, ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } var projectsCount = 0 private suspend fun fetchProjectsPage( replaceList: Boolean = false, ) { updateState { currentState -> currentState.copy( projectsLoading = replaceList, loadingMoreProjects = !replaceList, ) } val projectsMetadata = projectsRepo.getMyProjects(currentWorkspacesPage) val projects = projectsMetadata?.projects?.map { it.xAsProjectListItem() } ?: emptyList() updateState { oldState -> val oldProjects = oldState.projects val upsertedProjects = if (replaceList) { projects } else { oldProjects.upsert(projects) { it.id } }.sortedByDescending { it.lastUpdatedClipTime }.toImmutableList() projectsCount = projectsMetadata?.numTotalResults ?: 0 oldState.copy( projects = upsertedProjects, projectsLoading = false, loadingMoreProjects = false, ) } } private var currentWorkspacesPage = 0 suspend fun fetchNextProjectsPage() { if (projectsCount == state.value.projects.size) { return updateState { it.copy(loadingMoreProjects = false) } } currentWorkspacesPage++ fetchProjectsPage() } override suspend fun reduceEvent( currentState: CreateTextState, event: CreateTextEvent, emitEffect: suspend (CreateTextEffect) -> Unit, ): CreateTextState { val availableModels = billingRepo.billingStateFlow().value?.models ?: emptyList() return when (event) { is CreateTextEvent.OnNoCreditsRemainingDialogDismissed -> { currentState.copy(showingNoCreditsRemainingDialog = false) } is CreateTextEvent.OnNoCreditsRemainingDialogUpgradeClicked -> { viewModelScope.launch { emitEffect(CreateTextEffect.RedirectToBilling) } currentState.copy(showingNoCreditsRemainingDialog = false) } is CreateTextEvent.OnTitleTextInput -> { currentState.copy(titleInput = event.titleText) } is CreateTextEvent.OnLyricsTextInput -> { currentState.copy(lyricsInput = event.lyricsText) } is CreateTextEvent.OnStyleTextInput -> { currentState.copy(styleInput = event.styleText) } is CreateTextEvent.OnSongDescriptionTextInput -> { currentState.copy(songDescriptionInput = event.songDescriptionText) } is CreateTextEvent.OnWeirdnessChanged -> { emitDebouncedEffect(CreateTextEffect.TrackWeirdnessChange(event.weirdnessValue)) currentState.copy(weirdnessValue = event.weirdnessValue) } is CreateTextEvent.OnStyleInfluenceChanged -> { emitDebouncedEffect(CreateTextEffect.TrackStyleInfluenceChange(event.styleInfluenceValue)) currentState.copy(styleInfluenceValue = event.styleInfluenceValue) } is CreateTextEvent.OnAudioInfluenceChanged -> { emitDebouncedEffect(CreateTextEffect.TrackAudioInfluenceChange(event.audioInfluenceValue)) currentState.copy(audioInfluenceValue = event.audioInfluenceValue) } is CreateTextEvent.OnStylesToExcludeChanged -> { currentState.copy(stylesToExclude = event.stylesToExclude) } is CreateTextEvent.OnUpsellDialogDismissed -> { currentState.copy( showAukUpsellDialog = false, showV45PlusUpsellDialog = false, showV5UpsellDialog = false, ) } is CreateTextEvent.OnCustomModeToggled -> { viewModelScope.launch { saveCustomModeState(event) } currentState.copy(isCustom = event.isCustomMode) } is CreateTextEvent.OnInstrumentalModeToggled -> { currentState.copy(isInstrumental = event.isInstrumentalMode) } is CreateTextEvent.OnEnhanceLyrics -> { lyricsHistory.pushState(currentState.lyricsInput) startEnhanceLyricGenerationPollingJob( lyricsInput = currentState.lyricsInput, styleInput = currentState.styleInput, ) currentState.copy(lyricGenerationLoading = true) } is CreateTextEvent.OnResetLyrics -> { currentState.copy(lyricsInput = "") } is CreateTextEvent.OnUndoLyrics -> { lyricsHistory.undo() currentState.copy( lyricsInput = lyricsHistory.currentValue ?: "", canUndoLyrics = lyricsHistory.canUndo, ) } is CreateTextEvent.OnResetStyle -> { currentState.copy(styleInput = "") } is CreateTextEvent.OnRefreshRecommendedStyles -> { val existingStyles = currentState.recommendStyles refreshRecommendStyles(existingStyles) currentState } is CreateTextEvent.OnHandleStarterPrompt -> { val prompt = event.prompt prompt.remixClipId?.let { clipId -> viewModelScope.launch { val clip = clipsRepository.getClipById(Id(clipId)) .getOrElse { error -> logger.e(error) return@launch } if (clip != null) { audioController.sendEvent( CreateAudioEvent.OnLoadClipFromLocal( clip = clip, createTask = prompt.task ?: CreateAudioState.AudioCreateTask.Cover, ), ) updateState { oldState -> oldState.copy( isCustom = true, isRemix = true, lyricsInput = clip.prompt ?: oldState.lyricsInput, styleInput = clip.tags ?: oldState.styleInput, titleInput = if (clip.nowPlayingTitle?.isNotEmpty() == true) { clip.nowPlayingTitle + " (Remix)" } else { oldState.titleInput }, ) } } } } currentState.copy( isCustom = true, styleInput = prompt.tags.orEmpty(), titleInput = prompt.title.orEmpty(), lyricsInput = prompt.prompt.orEmpty(), ) } is CreateTextEvent.OnLoadClipFromOrpheusChat -> { audioController.sendEvent( CreateAudioEvent.OnLoadClipFromLocal( clip = event.clip, createTask = CreateAudioState.AudioCreateTask.Cover, ), ) currentState.copy( lyricsInput = event.clip.prompt.orEmpty(), styleInput = event.clip.tags.orEmpty(), titleInput = event.clip.nowPlayingTitle.orEmpty(), songDescriptionInput = event.clip.gptPrompt.orEmpty(), sourceMessageId = event.messageId, ) } is CreateTextEvent.OnGenerateClicked -> { if (!_isGeneratingStateFlow.compareAndSet(expect = false, update = true)) { return currentState } if (billingRepo.billingStateFlow().value?.totalCreditsLeft == 0 && currentState.activeModel?.name != "v4" ) { return currentState.copy(showingNoCreditsRemainingDialog = true) } viewModelScope.launch { startGeneration(currentState) } currentState.copy( isCreateSubmitLoading = true, ) } is CreateTextEvent.OnAttemptToSelectModel -> { val attemptedModelSelection = availableModels.find { it.name == event.selectedModelName } return attemptedModelSelection?.let { if (attemptedModelSelection.canUse == true) { userPrefsDataStoreManager.setSelectedModel(event.selectedModelName) updateSelectedModelAndRemainingSongs() currentState } else { return if (event.selectedModelName == "v4.5") { currentState.copy(showAukUpsellDialog = true) } else if (event.selectedModelName == "v4.5+") { currentState.copy(showV45PlusUpsellDialog = true) } else if (event.selectedModelName == "v5") { currentState.copy(showV5UpsellDialog = true) } else { currentState } } } ?: currentState } is CreateTextEvent.OnAttemptToResetCreate -> { topLevelErrorManager.broadcastTopLevelDialogError( DialogSpec( title = UiString.Resource(R.string.start_new_song), body = UiString.Resource(R.string.reset_create_warning), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.confirm), onClick = { dismissDialog -> sendEvent(CreateTextEvent.OnResetCreateScreen) dismissDialog() }, type = DialogSpec.Button.DialogButtonType.PRIMARY, ), DialogSpec.Button( label = UiString.Resource(R.string.cancel), type = DialogSpec.Button.DialogButtonType.DEFAULT, ), ), ), ) currentState } is CreateTextEvent.OnResetAdvancedOptions -> { currentState.copy( weirdnessValue = WEIRDNESS_DEFAULT, styleInfluenceValue = STYLE_INFLUENCE_DEFAULT, audioInfluenceValue = AUDIO_INFLUENCE_DEFAULT, stylesToExclude = null, ) } is CreateTextEvent.OnResetCreateScreen -> { audioController.resetAudioController() return if (currentState.isCustom) { currentState.copy( weirdnessValue = WEIRDNESS_DEFAULT, styleInfluenceValue = STYLE_INFLUENCE_DEFAULT, stylesToExclude = null, titleInput = "", lyricsInput = if (currentState.isInstrumental) currentState.lyricsInput else "", styleInput = "", isRemix = false, ) } else { currentState.copy(songDescriptionInput = "", isRemix = false) } } is CreateTextEvent.AudioEvent -> { audioController.sendEvent(event.event) currentState } is CreateTextEvent.DismissBottomSheet -> { audioController.sendEvent(CreateAudioEvent.DismissBottomSheet) currentState } is CreateTextEvent.OnEnhanceStyle -> { generateService.runUpsampleTagsGenerationFlow( GenUpsampleTagsSpec(originalTags = currentState.songDescriptionInput), ).onEach { response -> val body = response.body() if (response.isSuccessful && body != null) { updateState { it.copy( styleInput = body.upsampledTags, styleEnhanceLoading = false, ) } } }.catch { exception -> logger.e(exception) updateState { it.copy(styleEnhanceLoading = false) } }.launchIn(viewModelScope) currentState.copy(styleEnhanceLoading = true) } is CreateTextEvent.OnProjectSelected -> { currentState.copy( selectedProject = event.projectMetadata, ) } is CreateTextEvent.OnLoadMoreProjects -> { viewModelScope.launch { fetchNextProjectsPage() } currentState.copy(loadingMoreProjects = true) } is CreateTextEvent.OnCreateProject -> { currentState } } } private suspend fun saveCustomModeState( event: CreateTextEvent.OnCustomModeToggled, ) { createDataStore.setDefaultCreateMode( if (event.isCustomMode) { LocalCreateMode.Custom } else { LocalCreateMode.Simple }, ) } private fun startEnhanceLyricGenerationPollingJob( lyricsInput: String, styleInput: String, ) { viewModelScope.launch { try { val lyricGenerationJobResponse = generateService.runLyricsGeneration( genLyricsSpec = GenLyricsSpec( prompt = lyricsInput, tags = styleInput, ), ) lyricGenerationJobResponse.body()?.id?.let { lyricGenJobId -> pollForLyricGenerationCompletion(lyricGenJobId) } } catch (e: Exception) { logger.e(e) } } } private suspend fun pollForLyricGenerationCompletion( lyricsJobId: String, ) { val lyricsGenerationResponse = generateService.getLyricsGeneration(lyricsJobId) lyricsGenerationResponse.body()?.let { requestBody -> when (requestBody.status) { "running" -> { delay(3.seconds) pollForLyricGenerationCompletion(lyricsJobId) } "complete" -> { lyricsHistory.pushState(requestBody.text) updateState { it.copy( lyricGenerationLoading = false, titleInput = requestBody.title, lyricsInput = requestBody.text, canUndoLyrics = lyricsHistory.canUndo, ) } } } } } private fun refreshRecommendStyles( existingStyles: List, ) { viewModelScope.launch { val response = recommendStylesService.refreshRecommendStyles(RecommendStylesRequest(excludeStyles = existingStyles)) .getOrElse { error -> logger.e(error.toThrowable()) return@launch } response.recommendStyles?.let { songStyles -> updateState { oldState -> oldState.copy(recommendStyles = existingStyles + songStyles) } } } } private fun handleErrorResponse( errorResponse: ErrorResponse?, ) { when (errorResponse?.detail) { "Too many running jobs." -> { viewModelScope.launch { topLevelErrorManager.broadcastTopLevelDialogError( errorMessage = DialogSpec( title = UiString.Resource(R.string.hold_up), body = UiString.Resource(R.string.too_many_jobs), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.wait), type = DialogSpec.Button.DialogButtonType.DEFAULT, ), DialogSpec.Button( label = UiString.Resource(R.string.upgrade), onClick = { dismissDialog -> emitEffect(CreateTextEffect.RedirectToBilling) dismissDialog() }, type = DialogSpec.Button.DialogButtonType.PRIMARY, ), ), ), ) } } "Insufficient credits." -> { updateState { it.copy(showingNoCreditsRemainingDialog = true) } } else -> { viewModelScope.launch { topLevelErrorManager.broadcastTopLevelDialogError( errorMessage = DialogSpec( title = UiString.Resource(R.string.create_error), body = UiString.Resource(R.string.create_failed_try_again), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.ok), type = DialogSpec.Button.DialogButtonType.PRIMARY, ), ), ), ) } } } } fun setProject( project: ProjectMetadata, ) { updateState { it.copy(selectedProject = project) } } private suspend fun startGeneration( currentState: CreateTextState, ) { audioController.stopAudioPlayback() try { val request = CheckCaptchaRequest(captchaType = "generation") val actionRequiresToken = captchaCheckService.checkCaptchaIsRequired(request).body()?.required logger.d { "generate actionRequiresToken: $actionRequiresToken" } val selectedModelKey = currentState.activeModel?.externalKey ?: SelectedModelProvider.FALLBACK_MODEL_KEY // current user's plan id is passed into user_tier in metadata (including free plan id) val billingInfo = billingRepo.billingStateFlow().value val planId = billingInfo?.plan?.id ?: billingInfo?.plans?.first { it.level == 0 }?.id val projectId = currentState.selectedProject?.id // only use for auk model and above val hasAdvancedSettings = (currentState.activeModel?.majorVersion ?: 0) >= ADVANCED_SETTINGS_MIN_VERSION val weirdnessConstraint = currentState.weirdnessValue.takeIf { it != WEIRDNESS_DEFAULT } val styleInfluence = currentState.styleInfluenceValue.takeIf { it != STYLE_INFLUENCE_DEFAULT } val audioInfluence = currentState.audioInfluenceValueIfDisplayed.takeIf { it != AUDIO_INFLUENCE_DEFAULT } val controlSlidersSpec = GenControlSlidersSpec( weirdnessConstraint = weirdnessConstraint, styleInfluence = styleInfluence, audioWeight = audioInfluence, ) val selectedTask = currentState.audioState.configurations.selectedCreateTask val genParamsSpec = GenParamsSpec( isRemix = currentState.isRemix, prompt = if (currentState.isCustom) currentState.lyricsInput else "", gptDescriptionPrompt = if (currentState.isCustom.not()) { currentState.songDescriptionInput } else { null }, makeInstrumental = currentState.isInstrumental, title = if (currentState.isCustom) currentState.title.asString(resources) else null, tags = if (currentState.isCustom) currentState.styleInput else null, negativeTags = if (currentState.isCustom && hasAdvancedSettings) { currentState.stylesToExclude } else { null }, modelVersionName = selectedModelKey, generationType = GenParamsSpec.GenerationType.TEXT, token = if (actionRequiresToken == true) captchaManager.requestToken() else null, coverClipId = if (currentState.audioState is CreateAudioState.Completed && selectedTask == CreateAudioState.AudioCreateTask.Cover ) { currentState.audioState.clip.clipId.value } else { null }, continueClipId = if (currentState.audioState is CreateAudioState.Completed && currentState.audioState.taskParametersState is AudioTaskParametersState.Extend ) { currentState.audioState.clip.clipId.value } else { null }, continueAt = if (currentState.audioState is CreateAudioState.Completed && currentState.audioState.taskParametersState is AudioTaskParametersState.Extend ) { currentState.audioState.taskParametersState.controller.state.value.startTime.toDouble( DurationUnit.SECONDS, ) } else { null }, task = if (currentState.audioState is CreateAudioState.Completed) { selectedTask.apiName } else { null }, metadata = GenMetadataSpec( userTier = planId, controlSliders = if (hasAdvancedSettings) controlSlidersSpec else null, ), projectId = projectId?.value, ) logger.d { genParamsSpec } val response = generationRepository.startSongGeneration(genParamsSpec) updateState { it.copy(isCreateSubmitLoading = false) } response.fold( ifLeft = { errorResponse -> handleErrorResponse(errorResponse = errorResponse) }, ifRight = { responseBody -> val generatingClipIds = responseBody.clips.mapTo(mutableSetOf()) { Id(it.id) } emitEffect( CreateTextEffect.CreateSubmissionComplete( clipIds = generatingClipIds, messageId = currentState.sourceMessageId, ), ) emitEffect(TextGenCreationSuccess(isSuccessful = true)) appReviewManager.onCreate() }, ) } catch (exception: Exception) { logger.e(exception) { "Error with captcha" } when (exception) { is HttpException -> { val errorResponse = exception.xGetErrorResponse() handleErrorResponse(errorResponse = errorResponse) analyticsManager.trackGenerationFailed(errorResponse?.detail) } else -> { analyticsManager.trackGenerationFailed(exception.message) handleErrorResponse(null) } } } finally { _isGeneratingStateFlow.value = false } } override fun onCleared() { super.onCleared() audioController.onClear() } }