From 666015b8eb5b9050a42acdcc3b3959ff437789ea Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 22:27:00 +0100 Subject: [PATCH] reduce duplication: New shared controller for state + processing + distorted file cleanup New shared UI section for anonymization header + preset selector --- .../uploads/VoiceAnonymizationController.kt | 126 ++++++++++++++++++ .../uploads/VoiceAnonymizationSection.kt | 79 +++++++++++ .../ui/actions/uploads/VoiceAnonymizer.kt | 4 +- .../ui/actions/uploads/VoicePreset.kt | 12 +- .../loggedIn/home/ShortNotePostScreen.kt | 32 +---- .../loggedIn/home/ShortNotePostViewModel.kt | 103 ++++---------- .../screen/loggedIn/home/VoiceReplyScreen.kt | 31 +---- .../loggedIn/home/VoiceReplyViewModel.kt | 98 ++++---------- 8 files changed, 265 insertions(+), 220 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt new file mode 100644 index 000000000..b720e20d8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.io.File + +class VoiceAnonymizationController( + private val scope: CoroutineScope, + private val logTag: String, + private val onError: (Throwable) -> Unit, +) { + var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) + private set + var processingPreset: VoicePreset? by mutableStateOf(null) + private set + var distortedFiles: Map by mutableStateOf(emptyMap()) + private set + + private var processingJob: Job? = null + + fun activeFile(originalFile: File?): File? = + if (selectedPreset == VoicePreset.NONE) { + originalFile + } else { + distortedFiles[selectedPreset]?.file + } + + fun activeWaveform(originalWaveform: List?): List? = + if (selectedPreset == VoicePreset.NONE) { + originalWaveform + } else { + distortedFiles[selectedPreset]?.waveform + } + + fun selectPreset( + preset: VoicePreset, + originalFile: File?, + ) { + if (processingPreset != null || preset == selectedPreset) return + + if (preset == VoicePreset.NONE) { + selectedPreset = preset + return + } + + if (distortedFiles.containsKey(preset)) { + selectedPreset = preset + return + } + + val file = originalFile ?: return + + processingJob?.cancel() + processingJob = + scope.launch { + processingPreset = preset + try { + val anonymizer = VoiceAnonymizer() + val result = anonymizer.anonymize(file, preset) + + result + .onSuccess { anonymizedResult -> + distortedFiles = distortedFiles + (preset to anonymizedResult) + selectedPreset = preset + }.onFailure { error -> + Log.w(logTag, "Failed to anonymize voice", error) + onError(error) + } + } finally { + processingPreset = null + processingJob = null + } + } + } + + fun clear() { + cancelProcessing() + deleteDistortedFiles() + selectedPreset = VoicePreset.NONE + } + + fun deleteDistortedFiles() { + distortedFiles.values.forEach { result -> + try { + if (result.file.exists()) { + result.file.delete() + Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}") + } + } catch (e: Exception) { + Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e) + } + } + distortedFiles = emptyMap() + } + + private fun cancelProcessing() { + processingJob?.cancel() + processingJob = null + processingPreset = null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt new file mode 100644 index 000000000..3b7f02028 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size5dp + +@Composable +fun VoiceAnonymizationSection( + selectedPreset: VoicePreset, + processingPreset: VoicePreset?, + onPresetSelected: (VoicePreset) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(thickness = DividerThickness) + Spacer(modifier = Modifier.height(12.dp)) + + Column( + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + Text( + text = stringRes(R.string.voice_anonymize_title), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringRes(R.string.voice_anonymize_description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + VoicePresetSelector( + selectedPreset = selectedPreset, + processingPreset = processingPreset, + onPresetSelected = onPresetSelected, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt index 08d15c859..1359f3318 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt @@ -34,11 +34,11 @@ import be.tarsos.dsp.io.TarsosDSPAudioFloatConverter import be.tarsos.dsp.io.TarsosDSPAudioFormat import be.tarsos.dsp.resample.RateTransposer import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import java.io.File import java.nio.ByteOrder -import kotlin.coroutines.coroutineContext import kotlin.math.abs data class AnonymizedResult( @@ -147,7 +147,7 @@ class VoiceAnonymizer { var inputDone = false var outputDone = false - while (!outputDone && coroutineContext.isActive) { + while (!outputDone && currentCoroutineContext().isActive) { if (!inputDone) { val inputBufferIndex = decoder.dequeueInputBuffer(10000) if (inputBufferIndex >= 0) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt index 176baab62..49544e51a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt @@ -20,16 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.actions.uploads -import androidx.annotation.StringRes import com.vitorpamplona.amethyst.R enum class VoicePreset( val pitchFactor: Double, - val formantShift: Double, - @StringRes val labelRes: Int, + val labelRes: Int, ) { - NONE(1.0, 0.0, R.string.voice_preset_none), - DEEP(1.4, -3.0, R.string.voice_preset_deep), - HIGH(0.75, 4.0, R.string.voice_preset_high), - NEUTRAL(0.9, -2.0, R.string.voice_preset_neutral), + NONE(1.0, R.string.voice_preset_none), + DEEP(1.4, R.string.voice_preset_deep), + HIGH(0.75, R.string.voice_preset_high), + NEUTRAL(0.9, R.string.voice_preset_neutral), } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 88ac4f5a4..65a2b051d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -25,7 +25,6 @@ import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -39,24 +38,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel @@ -70,8 +65,8 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePresetSelector import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -101,7 +96,6 @@ import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp @@ -385,29 +379,7 @@ private fun NewPostScreenBody( // Voice anonymization section (only show when not uploading and voice is pending) if (postViewModel.voiceRecording != null) { - Spacer(modifier = Modifier.height(16.dp)) - HorizontalDivider(thickness = DividerThickness) - Spacer(modifier = Modifier.height(12.dp)) - - Column( - verticalArrangement = Arrangement.spacedBy(Size5dp), - ) { - Text( - text = stringRes(R.string.voice_anonymize_title), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringRes(R.string.voice_anonymize_description), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - VoicePresetSelector( + VoiceAnonymizationSection( selectedPreset = postViewModel.selectedPreset, processingPreset = postViewModel.processingPreset, onPresetSelected = { postViewModel.selectPreset(it) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index cd0030f0a..538057970 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -50,11 +50,10 @@ import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.amethyst.ui.actions.uploads.AnonymizedResult import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizer +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePreset import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState @@ -131,7 +130,6 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -199,26 +197,29 @@ open class ShortNotePostViewModel : var voiceOrchestrator by mutableStateOf(null) // Voice Anonymization - var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) - var processingPreset: VoicePreset? by mutableStateOf(null) - var distortedFiles: Map by mutableStateOf(emptyMap()) - private var processingJob: Job? = null + private val voiceAnonymization = + VoiceAnonymizationController( + scope = viewModelScope, + logTag = "ShortNotePostViewModel", + onError = { error -> + accountViewModel.toastManager.toast( + stringRes(Amethyst.instance.appContext, R.string.error), + error.message ?: "Voice anonymization failed", + ) + }, + ) val activeFile: java.io.File? - get() = - if (selectedPreset == VoicePreset.NONE) { - voiceLocalFile - } else { - distortedFiles[selectedPreset]?.file - } + get() = voiceAnonymization.activeFile(voiceLocalFile) val activeWaveform: List? - get() = - if (selectedPreset == VoicePreset.NONE) { - voiceRecording?.amplitudes - } else { - distortedFiles[selectedPreset]?.waveform - } + get() = voiceAnonymization.activeWaveform(voiceRecording?.amplitudes) + + val selectedPreset: VoicePreset + get() = voiceAnonymization.selectedPreset + + val processingPreset: VoicePreset? + get() = voiceAnonymization.processingPreset // Polls var canUsePoll by mutableStateOf(false) @@ -820,7 +821,7 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false - processingJob?.cancel() + voiceAnonymization.clear() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -828,8 +829,6 @@ open class ShortNotePostViewModel : voiceMetadata = null voiceSelectedServer = null voiceOrchestrator = null - selectedPreset = VoicePreset.NONE - processingPreset = null pTags = null wantsPoll = false @@ -981,13 +980,11 @@ open class ShortNotePostViewModel : fun selectVoiceRecording(recording: RecordingResult) { // Cancel any ongoing processing and delete existing files - processingJob?.cancel() + voiceAnonymization.clear() deleteVoiceLocalFile() voiceRecording = recording voiceLocalFile = recording.file voiceMetadata = null - selectedPreset = VoicePreset.NONE - processingPreset = null } fun getVoicePreviewMetadata(): AudioMeta? = @@ -1001,48 +998,11 @@ open class ShortNotePostViewModel : } fun selectPreset(preset: VoicePreset) { - if (processingPreset != null || preset == selectedPreset) return - - if (preset == VoicePreset.NONE) { - selectedPreset = preset - return - } - - if (distortedFiles.containsKey(preset)) { - selectedPreset = preset - return - } - - val originalFile = voiceLocalFile ?: return - - processingJob?.cancel() - processingJob = - viewModelScope.launch { - processingPreset = preset - try { - val anonymizer = VoiceAnonymizer() - val result = anonymizer.anonymize(originalFile, preset) - - result - .onSuccess { anonymizedResult -> - distortedFiles = distortedFiles + (preset to anonymizedResult) - selectedPreset = preset - }.onFailure { error -> - Log.w("ShortNotePostViewModel", "Failed to anonymize voice", error) - accountViewModel.toastManager.toast( - stringRes(Amethyst.instance.appContext, R.string.error), - error.message ?: "Voice anonymization failed", - ) - } - } finally { - processingPreset = null - processingJob = null - } - } + voiceAnonymization.selectPreset(preset, voiceLocalFile) } fun removeVoiceMessage() { - processingJob?.cancel() + voiceAnonymization.clear() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -1050,8 +1010,6 @@ open class ShortNotePostViewModel : voiceSelectedServer = null isUploadingVoice = false voiceOrchestrator = null - selectedPreset = VoicePreset.NONE - processingPreset = null } private fun deleteVoiceLocalFile() { @@ -1065,18 +1023,6 @@ open class ShortNotePostViewModel : Log.w("ShortNotePostViewModel", "Failed to delete voice file: ${file.absolutePath}", e) } } - - distortedFiles.values.forEach { result -> - try { - if (result.file.exists()) { - result.file.delete() - Log.d("ShortNotePostViewModel", "Deleted distorted file: ${result.file.absolutePath}") - } - } catch (e: Exception) { - Log.w("ShortNotePostViewModel", "Failed to delete distorted file: ${result.file.absolutePath}", e) - } - } - distortedFiles = emptyMap() } suspend fun uploadVoiceMessageSync( @@ -1128,6 +1074,7 @@ open class ShortNotePostViewModel : ) // Delete the local file after successful upload deleteVoiceLocalFile() + voiceAnonymization.deleteDistortedFiles() voiceLocalFile = null voiceRecording = null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index f1dae3aa5..a2573663b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -48,25 +47,21 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePresetSelector import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -174,29 +169,7 @@ private fun VoiceReplyScreenBody( } // Voice anonymization section - Spacer(modifier = Modifier.height(16.dp)) - HorizontalDivider(thickness = DividerThickness) - Spacer(modifier = Modifier.height(12.dp)) - - Column( - verticalArrangement = Arrangement.spacedBy(Size5dp), - ) { - Text( - text = stringRes(R.string.voice_anonymize_title), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringRes(R.string.voice_anonymize_description), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - VoicePresetSelector( + VoiceAnonymizationSection( selectedPreset = viewModel.selectedPreset, processingPreset = viewModel.processingPreset, onPresetSelected = { viewModel.selectPreset(it) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 1c99db810..eea2a0af6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -34,9 +34,8 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.amethyst.ui.actions.uploads.AnonymizedResult import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizer +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePreset import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -70,26 +69,29 @@ class VoiceReplyViewModel : ViewModel() { var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) var isUploading: Boolean by mutableStateOf(false) - var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) - var processingPreset: VoicePreset? by mutableStateOf(null) - var distortedFiles: Map by mutableStateOf(emptyMap()) - private var processingJob: Job? = null + private val voiceAnonymization = + VoiceAnonymizationController( + scope = viewModelScope, + logTag = "VoiceReplyViewModel", + onError = { error -> + accountViewModel.toastManager.toast( + stringRes(Amethyst.instance.appContext, R.string.error), + error.message ?: "Voice anonymization failed", + ) + }, + ) val activeFile: File? - get() = - if (selectedPreset == VoicePreset.NONE) { - voiceLocalFile - } else { - distortedFiles[selectedPreset]?.file - } + get() = voiceAnonymization.activeFile(voiceLocalFile) val activeWaveform: List? - get() = - if (selectedPreset == VoicePreset.NONE) { - voiceRecording?.amplitudes - } else { - distortedFiles[selectedPreset]?.waveform - } + get() = voiceAnonymization.activeWaveform(voiceRecording?.amplitudes) + + val selectedPreset: VoicePreset + get() = voiceAnonymization.selectedPreset + + val processingPreset: VoicePreset? + get() = voiceAnonymization.processingPreset private var uploadJob: Job? = null @@ -137,13 +139,11 @@ class VoiceReplyViewModel : ViewModel() { fun selectRecording(recording: RecordingResult) { cancelUpload() - processingJob?.cancel() - processingPreset = null + voiceAnonymization.clear() deleteVoiceLocalFile() voiceRecording = recording voiceLocalFile = recording.file voiceMetadata = null - selectedPreset = VoicePreset.NONE } private fun cancelUpload() { @@ -162,61 +162,12 @@ class VoiceReplyViewModel : ViewModel() { Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) } } - - distortedFiles.values.forEach { result -> - try { - if (result.file.exists()) { - result.file.delete() - Log.d("VoiceReplyViewModel", "Deleted distorted file: ${result.file.absolutePath}") - } - } catch (e: Exception) { - Log.w("VoiceReplyViewModel", "Failed to delete distorted file: ${result.file.absolutePath}", e) - } - } - distortedFiles = emptyMap() } fun canSend(): Boolean = voiceRecording != null && !isUploading && processingPreset == null fun selectPreset(preset: VoicePreset) { - if (processingPreset != null || preset == selectedPreset) return - - if (preset == VoicePreset.NONE) { - selectedPreset = preset - return - } - - if (distortedFiles.containsKey(preset)) { - selectedPreset = preset - return - } - - val originalFile = voiceLocalFile ?: return - - processingJob?.cancel() - processingJob = - viewModelScope.launch { - processingPreset = preset - try { - val anonymizer = VoiceAnonymizer() - val result = anonymizer.anonymize(originalFile, preset) - - result - .onSuccess { anonymizedResult -> - distortedFiles = distortedFiles + (preset to anonymizedResult) - selectedPreset = preset - }.onFailure { error -> - Log.w("VoiceReplyViewModel", "Failed to anonymize voice", error) - accountViewModel.toastManager.toast( - stringRes(Amethyst.instance.appContext, R.string.error), - error.message ?: "Voice anonymization failed", - ) - } - } finally { - processingPreset = null - processingJob = null - } - } + voiceAnonymization.selectPreset(preset, voiceLocalFile) } fun sendVoiceReply(onSuccess: () -> Unit) { @@ -326,6 +277,7 @@ class VoiceReplyViewModel : ViewModel() { } deleteVoiceLocalFile() + voiceAnonymization.deleteDistortedFiles() voiceLocalFile = null voiceRecording = null voiceMetadata = audioMeta @@ -348,7 +300,7 @@ class VoiceReplyViewModel : ViewModel() { fun cancel() { cancelUpload() - processingJob?.cancel() + voiceAnonymization.clear() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -356,8 +308,6 @@ class VoiceReplyViewModel : ViewModel() { voiceSelectedServer = null isUploading = false voiceOrchestrator = null - selectedPreset = VoicePreset.NONE - processingPreset = null } override fun onCleared() {