reduce duplication:

New shared controller for state + processing + distorted file cleanup
New shared UI section for anonymization header + preset selector
This commit is contained in:
davotoula
2026-01-12 22:27:00 +01:00
parent a4af706ebd
commit 666015b8eb
8 changed files with 265 additions and 220 deletions
@@ -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<VoicePreset, AnonymizedResult> 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<Float>?): List<Float>? =
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
}
}
@@ -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,
)
}
}
@@ -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) {
@@ -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),
}
@@ -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) },
@@ -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<UploadOrchestrator?>(null)
// Voice Anonymization
var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE)
var processingPreset: VoicePreset? by mutableStateOf(null)
var distortedFiles: Map<VoicePreset, AnonymizedResult> 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<Float>?
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
}
@@ -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) },
@@ -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<VoicePreset, AnonymizedResult> 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<Float>?
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() {