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:
+126
@@ -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
|
||||
}
|
||||
}
|
||||
+79
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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),
|
||||
}
|
||||
|
||||
+2
-30
@@ -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) },
|
||||
|
||||
+25
-78
@@ -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
|
||||
}
|
||||
|
||||
+2
-29
@@ -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) },
|
||||
|
||||
+24
-74
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user