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.io.TarsosDSPAudioFormat
import be.tarsos.dsp.resample.RateTransposer import be.tarsos.dsp.resample.RateTransposer
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.coroutines.coroutineContext
import kotlin.math.abs import kotlin.math.abs
data class AnonymizedResult( data class AnonymizedResult(
@@ -147,7 +147,7 @@ class VoiceAnonymizer {
var inputDone = false var inputDone = false
var outputDone = false var outputDone = false
while (!outputDone && coroutineContext.isActive) { while (!outputDone && currentCoroutineContext().isActive) {
if (!inputDone) { if (!inputDone) {
val inputBufferIndex = decoder.dequeueInputBuffer(10000) val inputBufferIndex = decoder.dequeueInputBuffer(10000)
if (inputBufferIndex >= 0) { if (inputBufferIndex >= 0) {
@@ -20,16 +20,14 @@
*/ */
package com.vitorpamplona.amethyst.ui.actions.uploads package com.vitorpamplona.amethyst.ui.actions.uploads
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
enum class VoicePreset( enum class VoicePreset(
val pitchFactor: Double, val pitchFactor: Double,
val formantShift: Double, val labelRes: Int,
@StringRes val labelRes: Int,
) { ) {
NONE(1.0, 0.0, R.string.voice_preset_none), NONE(1.0, R.string.voice_preset_none),
DEEP(1.4, -3.0, R.string.voice_preset_deep), DEEP(1.4, R.string.voice_preset_deep),
HIGH(0.75, 4.0, R.string.voice_preset_high), HIGH(0.75, R.string.voice_preset_high),
NEUTRAL(0.9, -2.0, R.string.voice_preset_neutral), NEUTRAL(0.9, R.string.voice_preset_neutral),
} }
@@ -25,7 +25,6 @@ import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.viewmodel.compose.viewModel 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.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator 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.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePresetSelector
import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes 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.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp 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) // Voice anonymization section (only show when not uploading and voice is pending)
if (postViewModel.voiceRecording != null) { if (postViewModel.voiceRecording != null) {
Spacer(modifier = Modifier.height(16.dp)) VoiceAnonymizationSection(
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 = postViewModel.selectedPreset, selectedPreset = postViewModel.selectedPreset,
processingPreset = postViewModel.processingPreset, processingPreset = postViewModel.processingPreset,
onPresetSelected = { postViewModel.selectPreset(it) }, 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.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType 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.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing 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.actions.uploads.VoicePreset
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState 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 com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -199,26 +197,29 @@ open class ShortNotePostViewModel :
var voiceOrchestrator by mutableStateOf<UploadOrchestrator?>(null) var voiceOrchestrator by mutableStateOf<UploadOrchestrator?>(null)
// Voice Anonymization // Voice Anonymization
var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) private val voiceAnonymization =
var processingPreset: VoicePreset? by mutableStateOf(null) VoiceAnonymizationController(
var distortedFiles: Map<VoicePreset, AnonymizedResult> by mutableStateOf(emptyMap()) scope = viewModelScope,
private var processingJob: Job? = null logTag = "ShortNotePostViewModel",
onError = { error ->
accountViewModel.toastManager.toast(
stringRes(Amethyst.instance.appContext, R.string.error),
error.message ?: "Voice anonymization failed",
)
},
)
val activeFile: java.io.File? val activeFile: java.io.File?
get() = get() = voiceAnonymization.activeFile(voiceLocalFile)
if (selectedPreset == VoicePreset.NONE) {
voiceLocalFile
} else {
distortedFiles[selectedPreset]?.file
}
val activeWaveform: List<Float>? val activeWaveform: List<Float>?
get() = get() = voiceAnonymization.activeWaveform(voiceRecording?.amplitudes)
if (selectedPreset == VoicePreset.NONE) {
voiceRecording?.amplitudes val selectedPreset: VoicePreset
} else { get() = voiceAnonymization.selectedPreset
distortedFiles[selectedPreset]?.waveform
} val processingPreset: VoicePreset?
get() = voiceAnonymization.processingPreset
// Polls // Polls
var canUsePoll by mutableStateOf(false) var canUsePoll by mutableStateOf(false)
@@ -820,7 +821,7 @@ open class ShortNotePostViewModel :
multiOrchestrator = null multiOrchestrator = null
isUploadingImage = false isUploadingImage = false
processingJob?.cancel() voiceAnonymization.clear()
deleteVoiceLocalFile() deleteVoiceLocalFile()
voiceRecording = null voiceRecording = null
voiceLocalFile = null voiceLocalFile = null
@@ -828,8 +829,6 @@ open class ShortNotePostViewModel :
voiceMetadata = null voiceMetadata = null
voiceSelectedServer = null voiceSelectedServer = null
voiceOrchestrator = null voiceOrchestrator = null
selectedPreset = VoicePreset.NONE
processingPreset = null
pTags = null pTags = null
wantsPoll = false wantsPoll = false
@@ -981,13 +980,11 @@ open class ShortNotePostViewModel :
fun selectVoiceRecording(recording: RecordingResult) { fun selectVoiceRecording(recording: RecordingResult) {
// Cancel any ongoing processing and delete existing files // Cancel any ongoing processing and delete existing files
processingJob?.cancel() voiceAnonymization.clear()
deleteVoiceLocalFile() deleteVoiceLocalFile()
voiceRecording = recording voiceRecording = recording
voiceLocalFile = recording.file voiceLocalFile = recording.file
voiceMetadata = null voiceMetadata = null
selectedPreset = VoicePreset.NONE
processingPreset = null
} }
fun getVoicePreviewMetadata(): AudioMeta? = fun getVoicePreviewMetadata(): AudioMeta? =
@@ -1001,48 +998,11 @@ open class ShortNotePostViewModel :
} }
fun selectPreset(preset: VoicePreset) { fun selectPreset(preset: VoicePreset) {
if (processingPreset != null || preset == selectedPreset) return voiceAnonymization.selectPreset(preset, voiceLocalFile)
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
}
}
} }
fun removeVoiceMessage() { fun removeVoiceMessage() {
processingJob?.cancel() voiceAnonymization.clear()
deleteVoiceLocalFile() deleteVoiceLocalFile()
voiceRecording = null voiceRecording = null
voiceLocalFile = null voiceLocalFile = null
@@ -1050,8 +1010,6 @@ open class ShortNotePostViewModel :
voiceSelectedServer = null voiceSelectedServer = null
isUploadingVoice = false isUploadingVoice = false
voiceOrchestrator = null voiceOrchestrator = null
selectedPreset = VoicePreset.NONE
processingPreset = null
} }
private fun deleteVoiceLocalFile() { private fun deleteVoiceLocalFile() {
@@ -1065,18 +1023,6 @@ open class ShortNotePostViewModel :
Log.w("ShortNotePostViewModel", "Failed to delete voice file: ${file.absolutePath}", e) 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( suspend fun uploadVoiceMessageSync(
@@ -1128,6 +1074,7 @@ open class ShortNotePostViewModel :
) )
// Delete the local file after successful upload // Delete the local file after successful upload
deleteVoiceLocalFile() deleteVoiceLocalFile()
voiceAnonymization.deleteDistortedFiles()
voiceLocalFile = null voiceLocalFile = null
voiceRecording = 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.Mic
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -48,25 +47,21 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator 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.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePresetSelector
import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes 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.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.replyModifier
@@ -174,29 +169,7 @@ private fun VoiceReplyScreenBody(
} }
// Voice anonymization section // Voice anonymization section
Spacer(modifier = Modifier.height(16.dp)) VoiceAnonymizationSection(
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 = viewModel.selectedPreset, selectedPreset = viewModel.selectedPreset,
processingPreset = viewModel.processingPreset, processingPreset = viewModel.processingPreset,
onPresetSelected = { viewModel.selectPreset(it) }, 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.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType 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.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.actions.uploads.VoicePreset
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -70,26 +69,29 @@ class VoiceReplyViewModel : ViewModel() {
var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null)
var isUploading: Boolean by mutableStateOf(false) var isUploading: Boolean by mutableStateOf(false)
var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) private val voiceAnonymization =
var processingPreset: VoicePreset? by mutableStateOf(null) VoiceAnonymizationController(
var distortedFiles: Map<VoicePreset, AnonymizedResult> by mutableStateOf(emptyMap()) scope = viewModelScope,
private var processingJob: Job? = null logTag = "VoiceReplyViewModel",
onError = { error ->
accountViewModel.toastManager.toast(
stringRes(Amethyst.instance.appContext, R.string.error),
error.message ?: "Voice anonymization failed",
)
},
)
val activeFile: File? val activeFile: File?
get() = get() = voiceAnonymization.activeFile(voiceLocalFile)
if (selectedPreset == VoicePreset.NONE) {
voiceLocalFile
} else {
distortedFiles[selectedPreset]?.file
}
val activeWaveform: List<Float>? val activeWaveform: List<Float>?
get() = get() = voiceAnonymization.activeWaveform(voiceRecording?.amplitudes)
if (selectedPreset == VoicePreset.NONE) {
voiceRecording?.amplitudes val selectedPreset: VoicePreset
} else { get() = voiceAnonymization.selectedPreset
distortedFiles[selectedPreset]?.waveform
} val processingPreset: VoicePreset?
get() = voiceAnonymization.processingPreset
private var uploadJob: Job? = null private var uploadJob: Job? = null
@@ -137,13 +139,11 @@ class VoiceReplyViewModel : ViewModel() {
fun selectRecording(recording: RecordingResult) { fun selectRecording(recording: RecordingResult) {
cancelUpload() cancelUpload()
processingJob?.cancel() voiceAnonymization.clear()
processingPreset = null
deleteVoiceLocalFile() deleteVoiceLocalFile()
voiceRecording = recording voiceRecording = recording
voiceLocalFile = recording.file voiceLocalFile = recording.file
voiceMetadata = null voiceMetadata = null
selectedPreset = VoicePreset.NONE
} }
private fun cancelUpload() { private fun cancelUpload() {
@@ -162,61 +162,12 @@ class VoiceReplyViewModel : ViewModel() {
Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) 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 canSend(): Boolean = voiceRecording != null && !isUploading && processingPreset == null
fun selectPreset(preset: VoicePreset) { fun selectPreset(preset: VoicePreset) {
if (processingPreset != null || preset == selectedPreset) return voiceAnonymization.selectPreset(preset, voiceLocalFile)
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
}
}
} }
fun sendVoiceReply(onSuccess: () -> Unit) { fun sendVoiceReply(onSuccess: () -> Unit) {
@@ -326,6 +277,7 @@ class VoiceReplyViewModel : ViewModel() {
} }
deleteVoiceLocalFile() deleteVoiceLocalFile()
voiceAnonymization.deleteDistortedFiles()
voiceLocalFile = null voiceLocalFile = null
voiceRecording = null voiceRecording = null
voiceMetadata = audioMeta voiceMetadata = audioMeta
@@ -348,7 +300,7 @@ class VoiceReplyViewModel : ViewModel() {
fun cancel() { fun cancel() {
cancelUpload() cancelUpload()
processingJob?.cancel() voiceAnonymization.clear()
deleteVoiceLocalFile() deleteVoiceLocalFile()
voiceRecording = null voiceRecording = null
voiceLocalFile = null voiceLocalFile = null
@@ -356,8 +308,6 @@ class VoiceReplyViewModel : ViewModel() {
voiceSelectedServer = null voiceSelectedServer = null
isUploading = false isUploading = false
voiceOrchestrator = null voiceOrchestrator = null
selectedPreset = VoicePreset.NONE
processingPreset = null
} }
override fun onCleared() { override fun onCleared() {