From bfd0fc01b94f400d91dfb45386b7ec665a280b9c Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 11 Jan 2026 19:08:47 +0100 Subject: [PATCH 01/12] add TarsosDSP dependency for voice anonymization --- amethyst/build.gradle | 3 +++ gradle/libs.versions.toml | 2 ++ 2 files changed, 5 insertions(+) diff --git a/amethyst/build.gradle b/amethyst/build.gradle index ee42fb543..2df5a4149 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -348,6 +348,9 @@ dependencies { // Image compression lib implementation libs.zelory.image.compressor + // Voice anonymization DSP + implementation libs.tarsosdsp + // Cbor for cashuB format implementation libs.kotlinx.serialization.cbor diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f1c53630..8f26e8b51 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ rfc3986 = "0.1.2" secp256k1KmpJniAndroid = "0.22.0" securityCryptoKtx = "1.1.0" spotless = "8.1.0" +tarsosdsp = "2.5" torAndroid = "0.4.8.21.1" translate = "17.0.3" unifiedpush = "3.0.10" @@ -145,6 +146,7 @@ rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } +tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" } From 83d4d3e756befeedbfe5db17ee416f47bcfdd810 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 11:44:40 +0100 Subject: [PATCH 02/12] add VoicePreset enum for voice anonymization Define preset options (None, Deep, High, Neutral) --- .../ui/actions/uploads/VoicePreset.kt | 35 +++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 4 +++ settings.gradle | 1 + 3 files changed, 40 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt 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 new file mode 100644 index 000000000..1d16a3c8e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt @@ -0,0 +1,35 @@ +/** + * 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.annotation.StringRes +import com.vitorpamplona.amethyst.R + +enum class VoicePreset( + val pitchFactor: Double, + val formantShift: Double, + @StringRes val labelRes: Int, +) { + NONE(1.0, 0.0, R.string.voice_preset_none), + DEEP(0.75, -3.0, R.string.voice_preset_deep), + HIGH(1.4, 4.0, R.string.voice_preset_high), + NEUTRAL(1.0, -2.0, R.string.voice_preset_neutral), +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e9b34537c..ebec66ae8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -184,6 +184,10 @@ Unexpected upload state NIP-95 is not supported for voice messages yet Voice upload failed: %1$s + None + Deep + High + Neutral User does not have a lightning address set up to receive sats "reply here.. " Copies the Note ID to the clipboard for sharing in Nostr diff --git a/settings.gradle b/settings.gradle index cad6e3772..5603e440e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -25,6 +25,7 @@ dependencyResolutionManagement { mavenCentral() maven { url = "https://jitpack.io" } maven { url = "https://raw.githubusercontent.com/guardianproject/gpmaven/master" } + maven { url = "https://mvn.0110.be/releases" } } } From 8de148e56248b96b80fa9c86ce8aacfa6f1df1ce Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 11:59:53 +0100 Subject: [PATCH 03/12] add VoiceAnonymizer for audio pitch/formant shifting --- .../ui/actions/uploads/VoiceAnonymizer.kt | 435 ++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt 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 new file mode 100644 index 000000000..08d15c859 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt @@ -0,0 +1,435 @@ +/** + * 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 android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaExtractor +import android.media.MediaFormat +import android.media.MediaMuxer +import android.util.Log +import be.tarsos.dsp.AudioDispatcher +import be.tarsos.dsp.AudioEvent +import be.tarsos.dsp.AudioProcessor +import be.tarsos.dsp.WaveformSimilarityBasedOverlapAdd +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.isActive +import kotlinx.coroutines.withContext +import java.io.File +import java.nio.ByteOrder +import kotlin.coroutines.coroutineContext +import kotlin.math.abs + +data class AnonymizedResult( + val file: File, + val waveform: List, + val duration: Int, +) + +class VoiceAnonymizer { + companion object { + private const val TAG = "VoiceAnonymizer" + private const val SAMPLE_RATE = 44100 + private const val CHANNELS = 1 + private const val BIT_RATE = 128000 + } + + suspend fun anonymize( + inputFile: File, + preset: VoicePreset, + onProgress: (Float) -> Unit = {}, + ): Result = + withContext(Dispatchers.IO) { + if (preset == VoicePreset.NONE) { + return@withContext Result.failure( + IllegalArgumentException("Cannot anonymize with NONE preset"), + ) + } + + try { + val outputFile = createOutputFile(inputFile, preset) + val (pcmData, sampleRate, duration) = + decodeAudioToPcm(inputFile) { progress -> + onProgress(progress * 0.3f) + } + + val processedPcm = + processPcmWithTarsos(pcmData, preset, sampleRate) { progress -> + onProgress(0.3f + progress * 0.4f) + } + + val waveform = extractWaveform(processedPcm, sampleRate) + + encodePcmToAac(processedPcm, sampleRate, outputFile) { progress -> + onProgress(0.7f + progress * 0.3f) + } + + onProgress(1f) + Result.success(AnonymizedResult(outputFile, waveform, duration)) + } catch (e: Exception) { + Log.e(TAG, "Failed to anonymize audio", e) + Result.failure(e) + } + } + + private fun createOutputFile( + inputFile: File, + preset: VoicePreset, + ): File { + val baseName = inputFile.nameWithoutExtension + val presetSuffix = preset.name.lowercase() + return File(inputFile.parentFile, "${baseName}_$presetSuffix.mp4") + } + + private data class DecodedAudio( + val pcmData: FloatArray, + val sampleRate: Int, + val duration: Int, + ) + + private suspend fun decodeAudioToPcm( + inputFile: File, + onProgress: (Float) -> Unit, + ): DecodedAudio { + val extractor = MediaExtractor() + extractor.setDataSource(inputFile.absolutePath) + + var audioTrackIndex = -1 + var format: MediaFormat? = null + for (i in 0 until extractor.trackCount) { + val trackFormat = extractor.getTrackFormat(i) + val mime = trackFormat.getString(MediaFormat.KEY_MIME) + if (mime?.startsWith("audio/") == true) { + audioTrackIndex = i + format = trackFormat + break + } + } + + if (audioTrackIndex == -1 || format == null) { + extractor.release() + throw IllegalStateException("No audio track found in file") + } + + extractor.selectTrack(audioTrackIndex) + val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm" + val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) + val durationUs = format.getLong(MediaFormat.KEY_DURATION) + val duration = (durationUs / 1_000_000).toInt() + + val decoder = MediaCodec.createDecoderByType(mime) + decoder.configure(format, null, null, 0) + decoder.start() + + val pcmSamples = mutableListOf() + val bufferInfo = MediaCodec.BufferInfo() + var inputDone = false + var outputDone = false + + while (!outputDone && coroutineContext.isActive) { + if (!inputDone) { + val inputBufferIndex = decoder.dequeueInputBuffer(10000) + if (inputBufferIndex >= 0) { + val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!! + val sampleSize = extractor.readSampleData(inputBuffer, 0) + if (sampleSize < 0) { + decoder.queueInputBuffer( + inputBufferIndex, + 0, + 0, + 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM, + ) + inputDone = true + } else { + val presentationTimeUs = extractor.sampleTime + decoder.queueInputBuffer( + inputBufferIndex, + 0, + sampleSize, + presentationTimeUs, + 0, + ) + extractor.advance() + if (durationUs > 0) { + onProgress((presentationTimeUs.toFloat() / durationUs).coerceIn(0f, 1f)) + } + } + } + } + + val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000) + if (outputBufferIndex >= 0) { + val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!! + val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer() + while (shortBuffer.hasRemaining()) { + pcmSamples.add(shortBuffer.get() / 32768f) + } + decoder.releaseOutputBuffer(outputBufferIndex, false) + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + outputDone = true + } + } + } + + decoder.stop() + decoder.release() + extractor.release() + + return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration) + } + + private fun processPcmWithTarsos( + pcmData: FloatArray, + preset: VoicePreset, + sampleRate: Int, + onProgress: (Float) -> Unit, + ): FloatArray { + val factor = preset.pitchFactor + val processedSamples = mutableListOf() + val totalSamples = pcmData.size + + val wsola = + WaveformSimilarityBasedOverlapAdd( + WaveformSimilarityBasedOverlapAdd.Parameters.musicDefaults( + factor, + sampleRate.toDouble(), + ), + ) + val rateTransposer = RateTransposer(factor) + + val bufferSize = wsola.inputBufferSize + val overlap = wsola.overlap + + val tarsosDspFormat = + TarsosDSPAudioFormat( + sampleRate.toFloat(), + 16, + 1, + true, + false, + ) + + val collector = + object : AudioProcessor { + override fun process(audioEvent: AudioEvent): Boolean { + val buffer = audioEvent.floatBuffer + for (i in 0 until audioEvent.bufferSize) { + processedSamples.add(buffer[i]) + } + return true + } + + override fun processingFinished() {} + } + + val dispatcher = + AudioDispatcher( + FloatArrayAudioInputStream(pcmData, tarsosDspFormat, pcmData.size.toLong()), + bufferSize, + overlap, + ) + + wsola.setDispatcher(dispatcher) + dispatcher.addAudioProcessor(wsola) + dispatcher.addAudioProcessor(rateTransposer) + dispatcher.addAudioProcessor(collector) + + var samplesProcessed = 0 + val progressProcessor = + object : AudioProcessor { + override fun process(audioEvent: AudioEvent): Boolean { + samplesProcessed += audioEvent.bufferSize + onProgress((samplesProcessed.toFloat() / totalSamples).coerceIn(0f, 1f)) + return true + } + + override fun processingFinished() {} + } + dispatcher.addAudioProcessor(progressProcessor) + + dispatcher.run() + + return processedSamples.toFloatArray() + } + + private fun extractWaveform( + pcmData: FloatArray, + sampleRate: Int, + ): List { + val waveform = mutableListOf() + var offset = 0 + + while (offset < pcmData.size) { + val end = minOf(offset + sampleRate, pcmData.size) + var maxAmplitude = 0f + for (i in offset until end) { + val amplitude = abs(pcmData[i]) + if (amplitude > maxAmplitude) { + maxAmplitude = amplitude + } + } + waveform.add(maxAmplitude * 32768f) + offset += sampleRate + } + + return waveform + } + + private fun encodePcmToAac( + pcmData: FloatArray, + sampleRate: Int, + outputFile: File, + onProgress: (Float) -> Unit, + ) { + val format = + MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, CHANNELS) + format.setInteger( + MediaFormat.KEY_AAC_PROFILE, + MediaCodecInfo.CodecProfileLevel.AACObjectLC, + ) + format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) + + val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + encoder.start() + + val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + var audioTrackIndex = -1 + var muxerStarted = false + + val bufferInfo = MediaCodec.BufferInfo() + var inputOffset = 0 + var inputDone = false + var outputDone = false + val totalSamples = pcmData.size + + while (!outputDone) { + if (!inputDone) { + val inputBufferIndex = encoder.dequeueInputBuffer(10000) + if (inputBufferIndex >= 0) { + val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!! + inputBuffer.clear() + + val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset) + if (samplesToWrite <= 0) { + encoder.queueInputBuffer( + inputBufferIndex, + 0, + 0, + 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM, + ) + inputDone = true + } else { + for (i in 0 until samplesToWrite) { + val sample = + (pcmData[inputOffset + i] * 32767) + .toInt() + .coerceIn(-32768, 32767) + .toShort() + inputBuffer.putShort(sample) + } + val presentationTimeUs = (inputOffset * 1_000_000L) / sampleRate + encoder.queueInputBuffer( + inputBufferIndex, + 0, + inputBuffer.position(), + presentationTimeUs, + 0, + ) + inputOffset += samplesToWrite + onProgress(inputOffset.toFloat() / totalSamples) + } + } + } + + val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000) + when { + outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + audioTrackIndex = muxer.addTrack(encoder.outputFormat) + muxer.start() + muxerStarted = true + } + + outputBufferIndex >= 0 -> { + val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!! + if (muxerStarted && bufferInfo.size > 0) { + outputBuffer.position(bufferInfo.offset) + outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo) + } + encoder.releaseOutputBuffer(outputBufferIndex, false) + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + outputDone = true + } + } + } + } + + encoder.stop() + encoder.release() + muxer.stop() + muxer.release() + } +} + +private class FloatArrayAudioInputStream( + private val floatArray: FloatArray, + private val format: TarsosDSPAudioFormat, + private val frameLength: Long, +) : be.tarsos.dsp.io.TarsosDSPAudioInputStream { + private var position = 0 + + override fun getFormat(): TarsosDSPAudioFormat = format + + override fun getFrameLength(): Long = frameLength + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + val converter = TarsosDSPAudioFloatConverter.getConverter(format) + val floatBuffer = FloatArray(length / 2) + val samplesToRead = minOf(floatBuffer.size, floatArray.size - position) + + if (samplesToRead <= 0) return -1 + + System.arraycopy(floatArray, position, floatBuffer, 0, samplesToRead) + position += samplesToRead + + converter.toByteArray(floatBuffer, samplesToRead, buffer, offset) + return samplesToRead * 2 + } + + override fun skip(bytesToSkip: Long): Long { + val samplesToSkip = (bytesToSkip / 2).toInt() + val actualSkip = minOf(samplesToSkip, floatArray.size - position) + position += actualSkip + return actualSkip.toLong() * 2 + } + + override fun close() {} +} From 8182268a00585228a7fa8edf77f82ca5650492ee Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 12:02:31 +0100 Subject: [PATCH 04/12] add voice anonymization state to VoiceReplyViewModel --- .../loggedIn/home/VoiceReplyViewModel.kt | 92 ++++++++++++++++++- 1 file changed, 88 insertions(+), 4 deletions(-) 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 3bd707f81..799fbf0a5 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,7 +34,10 @@ 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.VoicePreset import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag @@ -67,6 +70,27 @@ class VoiceReplyViewModel : ViewModel() { var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) var isUploading: Boolean by mutableStateOf(false) + var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) + var isProcessingPreset: Boolean by mutableStateOf(false) + var distortedFiles: Map by mutableStateOf(emptyMap()) + private var processingJob: Job? = null + + val activeFile: File? + get() = + if (selectedPreset == VoicePreset.NONE) { + voiceLocalFile + } else { + distortedFiles[selectedPreset]?.file + } + + val activeWaveform: List? + get() = + if (selectedPreset == VoicePreset.NONE) { + voiceRecording?.amplitudes + } else { + distortedFiles[selectedPreset]?.waveform + } + private var uploadJob: Job? = null fun init(accountVM: AccountViewModel) { @@ -113,10 +137,12 @@ class VoiceReplyViewModel : ViewModel() { fun selectRecording(recording: RecordingResult) { cancelUpload() + processingJob?.cancel() deleteVoiceLocalFile() voiceRecording = recording voiceLocalFile = recording.file voiceMetadata = null + selectedPreset = VoicePreset.NONE } private fun cancelUpload() { @@ -135,13 +161,67 @@ 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 + fun canSend(): Boolean = voiceRecording != null && !isUploading && !isProcessingPreset + + fun selectPreset(preset: VoicePreset) { + if (isProcessingPreset || 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 { + isProcessingPreset = true + 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 { + isProcessingPreset = false + processingJob = null + } + } + } fun sendVoiceReply(onSuccess: () -> Unit) { val note = replyToNote ?: return val recording = voiceRecording ?: return + val fileToUpload = activeFile ?: recording.file val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer cancelUpload() @@ -154,7 +234,7 @@ class VoiceReplyViewModel : ViewModel() { try { val result = withContext(Dispatchers.IO) { - val uri = android.net.Uri.fromFile(recording.file) + val uri = android.net.Uri.fromFile(fileToUpload) orchestrator.upload( uri = uri, mimeType = recording.mimeType, @@ -168,7 +248,7 @@ class VoiceReplyViewModel : ViewModel() { ) } - handleUploadResult(note, recording, serverToUse, result, onSuccess) + handleUploadResult(note, recording, activeWaveform ?: recording.amplitudes, serverToUse, result, onSuccess) } catch (e: CancellationException) { Log.w("VoiceReplyViewModel", "User canceled, or ViewModel cleared", e) } catch (e: Exception) { @@ -192,6 +272,7 @@ class VoiceReplyViewModel : ViewModel() { private suspend fun handleUploadResult( note: Note, recording: RecordingResult, + waveform: List, server: ServerName, result: UploadingState, onSuccess: () -> Unit, @@ -211,7 +292,7 @@ class VoiceReplyViewModel : ViewModel() { mimeType = recording.mimeType, hash = orchestratorResult.fileHeader.hash, duration = recording.duration, - waveform = recording.amplitudes, + waveform = waveform, ) // Check if replying to a voice event @@ -266,6 +347,7 @@ class VoiceReplyViewModel : ViewModel() { fun cancel() { cancelUpload() + processingJob?.cancel() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -273,6 +355,8 @@ class VoiceReplyViewModel : ViewModel() { voiceSelectedServer = null isUploading = false voiceOrchestrator = null + selectedPreset = VoicePreset.NONE + isProcessingPreset = false } override fun onCleared() { From 63d7ba07b95353c6fb23676fd99062e22a0a5e55 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 15:11:33 +0100 Subject: [PATCH 05/12] add voice preset selector UI to VoiceReplyScreen --- .../ui/actions/uploads/VoicePresetSelector.kt | 80 +++++++++++++++++++ .../screen/loggedIn/home/VoiceReplyScreen.kt | 17 +++- 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt new file mode 100644 index 000000000..aae4818af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt @@ -0,0 +1,80 @@ +/** + * 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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun VoicePresetSelector( + selectedPreset: VoicePreset, + isProcessing: Boolean, + onPresetSelected: (VoicePreset) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + VoicePreset.entries.forEach { preset -> + val isSelected = preset == selectedPreset + val isThisProcessing = isProcessing && preset == selectedPreset + val isEnabled = !isProcessing || preset == VoicePreset.NONE + + FilterChip( + selected = isSelected, + onClick = { if (isEnabled) onPresetSelected(preset) }, + enabled = isEnabled, + label = { + if (isThisProcessing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text(stringRes(context, preset.labelRes)) + } + }, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + } + } +} 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 15f30bb19..3794060bc 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 @@ -54,6 +54,7 @@ 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.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 @@ -153,15 +154,27 @@ private fun VoiceReplyScreenBody( UploadProgressIndicator(orchestrator) } ?: run { viewModel.getVoicePreviewMetadata()?.let { metadata -> + val displayMetadata = + metadata.copy( + waveform = viewModel.activeWaveform ?: metadata.waveform, + ) VoiceMessagePreview( - voiceMetadata = metadata, - localFile = viewModel.voiceLocalFile, + voiceMetadata = displayMetadata, + localFile = viewModel.activeFile, onRemove = { viewModel.cancel() nav.popBack() }, ) } + + // Voice preset selector (only show when not uploading) + Spacer(modifier = Modifier.height(12.dp)) + VoicePresetSelector( + selectedPreset = viewModel.selectedPreset, + isProcessing = viewModel.isProcessingPreset, + onPresetSelected = { viewModel.selectPreset(it) }, + ) } Spacer(modifier = Modifier.height(16.dp)) From 9c0fbbf8a40b6ff21db286bd491026b7edd70456 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 15:54:15 +0100 Subject: [PATCH 06/12] correct pitch selection Add to voice note screen (new note) --- .../ui/actions/uploads/VoicePreset.kt | 6 +- .../loggedIn/home/ShortNotePostScreen.kt | 19 +++- .../loggedIn/home/ShortNotePostViewModel.kt | 99 ++++++++++++++++++- 3 files changed, 115 insertions(+), 9 deletions(-) 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 1d16a3c8e..176baab62 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 @@ -29,7 +29,7 @@ enum class VoicePreset( @StringRes val labelRes: Int, ) { NONE(1.0, 0.0, R.string.voice_preset_none), - DEEP(0.75, -3.0, R.string.voice_preset_deep), - HIGH(1.4, 4.0, R.string.voice_preset_high), - NEUTRAL(1.0, -2.0, R.string.voice_preset_neutral), + 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), } 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 504c723be..325d4b3e1 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 @@ -66,6 +66,7 @@ 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.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 @@ -366,11 +367,25 @@ private fun NewPostScreenBody( postViewModel.voiceOrchestrator?.let { orchestrator -> UploadProgressIndicator(orchestrator) } ?: run { + val displayMetadata = + metadata.copy( + waveform = postViewModel.activeWaveform ?: metadata.waveform, + ) VoiceMessagePreview( - voiceMetadata = metadata, - localFile = postViewModel.voiceLocalFile, + voiceMetadata = displayMetadata, + localFile = postViewModel.activeFile, onRemove = { postViewModel.removeVoiceMessage() }, ) + + // Voice preset selector (only show when not uploading and voice is pending) + if (postViewModel.voiceRecording != null) { + Spacer(modifier = Modifier.height(12.dp)) + VoicePresetSelector( + selectedPreset = postViewModel.selectedPreset, + isProcessing = postViewModel.isProcessingPreset, + onPresetSelected = { postViewModel.selectPreset(it) }, + ) + } } FileServerSelectionRow( 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 d810088ed..9f4e5c237 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,9 +50,12 @@ 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.VoicePreset 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.location.ILocationGrabber @@ -128,6 +131,7 @@ 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 @@ -194,6 +198,28 @@ open class ShortNotePostViewModel : var voiceSelectedServer by mutableStateOf(null) var voiceOrchestrator by mutableStateOf(null) + // Voice Anonymization + var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) + var isProcessingPreset: Boolean by mutableStateOf(false) + var distortedFiles: Map by mutableStateOf(emptyMap()) + private var processingJob: Job? = null + + val activeFile: java.io.File? + get() = + if (selectedPreset == VoicePreset.NONE) { + voiceLocalFile + } else { + distortedFiles[selectedPreset]?.file + } + + val activeWaveform: List? + get() = + if (selectedPreset == VoicePreset.NONE) { + voiceRecording?.amplitudes + } else { + distortedFiles[selectedPreset]?.waveform + } + // Polls var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) @@ -794,6 +820,7 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false + processingJob?.cancel() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -801,6 +828,8 @@ open class ShortNotePostViewModel : voiceMetadata = null voiceSelectedServer = null voiceOrchestrator = null + selectedPreset = VoicePreset.NONE + isProcessingPreset = false pTags = null wantsPoll = false @@ -922,7 +951,7 @@ open class ShortNotePostViewModel : fun canPost(): Boolean { // Voice messages can be posted without text (with either uploaded or pending recording) if (voiceMetadata != null || voiceRecording != null) { - return !isUploadingVoice && !isUploadingImage + return !isUploadingVoice && !isUploadingImage && !isProcessingPreset } // Regular text/media posts require text @@ -951,10 +980,14 @@ open class ShortNotePostViewModel : } fun selectVoiceRecording(recording: RecordingResult) { - // Delete any existing temp file before replacing + // Cancel any ongoing processing and delete existing files + processingJob?.cancel() deleteVoiceLocalFile() voiceRecording = recording voiceLocalFile = recording.file + voiceMetadata = null + selectedPreset = VoicePreset.NONE + isProcessingPreset = false } fun getVoicePreviewMetadata(): AudioMeta? = @@ -967,7 +1000,49 @@ open class ShortNotePostViewModel : ) } + fun selectPreset(preset: VoicePreset) { + if (isProcessingPreset || 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 { + isProcessingPreset = true + 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 { + isProcessingPreset = false + processingJob = null + } + } + } + fun removeVoiceMessage() { + processingJob?.cancel() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -975,6 +1050,8 @@ open class ShortNotePostViewModel : voiceSelectedServer = null isUploadingVoice = false voiceOrchestrator = null + selectedPreset = VoicePreset.NONE + isProcessingPreset = false } private fun deleteVoiceLocalFile() { @@ -988,6 +1065,18 @@ 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( @@ -995,6 +1084,8 @@ open class ShortNotePostViewModel : onError: (title: String, message: String) -> Unit, ) { val recording = voiceRecording ?: return + val fileToUpload = activeFile ?: recording.file + val waveform = activeWaveform ?: recording.amplitudes val appContext = Amethyst.instance.appContext val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) @@ -1006,7 +1097,7 @@ open class ShortNotePostViewModel : isUploadingVoice = true try { - val uri = android.net.Uri.fromFile(recording.file) + val uri = android.net.Uri.fromFile(fileToUpload) val orchestrator = UploadOrchestrator() voiceOrchestrator = orchestrator @@ -1033,7 +1124,7 @@ open class ShortNotePostViewModel : mimeType = recording.mimeType, hash = orchestratorResult.fileHeader.hash, duration = recording.duration, - waveform = recording.amplitudes, + waveform = waveform, ) // Delete the local file after successful upload deleteVoiceLocalFile() From 6f8533f5e1f5435cb4e6cf58b50b410f6e1375bc Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 21:51:22 +0100 Subject: [PATCH 07/12] added explainer to UI --- .../loggedIn/home/ShortNotePostScreen.kt | 29 ++++++++++++++++++- .../screen/loggedIn/home/VoiceReplyScreen.kt | 28 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 2 ++ 3 files changed, 57 insertions(+), 2 deletions(-) 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 325d4b3e1..029fdc454 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,6 +25,7 @@ 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 @@ -38,20 +39,24 @@ 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 @@ -96,6 +101,7 @@ 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 @@ -377,9 +383,30 @@ private fun NewPostScreenBody( onRemove = { postViewModel.removeVoiceMessage() }, ) - // Voice preset selector (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) { + 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 = postViewModel.selectedPreset, isProcessing = postViewModel.isProcessingPreset, 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 3794060bc..a6d7b888a 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,6 +37,7 @@ 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 @@ -47,6 +48,8 @@ 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 @@ -61,7 +64,9 @@ 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 @@ -168,8 +173,29 @@ private fun VoiceReplyScreenBody( ) } - // Voice preset selector (only show when not uploading) + // 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( selectedPreset = viewModel.selectedPreset, isProcessing = viewModel.isProcessingPreset, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ebec66ae8..a78c01f31 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -188,6 +188,8 @@ Deep High Neutral + Anonymize + Alters your voice pitch. Note: basic pitch changes can potentially be reversed by determined listeners. User does not have a lightning address set up to receive sats "reply here.. " Copies the Note ID to the clipboard for sharing in Nostr From a4af706ebd9ad3f291d086fa785954069400385f Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 21:58:40 +0100 Subject: [PATCH 08/12] correct the item where progress spinner happens on --- .../ui/actions/uploads/VoicePresetSelector.kt | 5 +++-- .../screen/loggedIn/home/ShortNotePostScreen.kt | 2 +- .../loggedIn/home/ShortNotePostViewModel.kt | 16 ++++++++-------- .../ui/screen/loggedIn/home/VoiceReplyScreen.kt | 2 +- .../screen/loggedIn/home/VoiceReplyViewModel.kt | 13 +++++++------ 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt index aae4818af..44da46683 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePresetSelector.kt @@ -39,11 +39,12 @@ import com.vitorpamplona.amethyst.ui.stringRes @Composable fun VoicePresetSelector( selectedPreset: VoicePreset, - isProcessing: Boolean, + processingPreset: VoicePreset?, onPresetSelected: (VoicePreset) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current + val isProcessing = processingPreset != null Row( modifier = modifier.fillMaxWidth(), @@ -51,7 +52,7 @@ fun VoicePresetSelector( ) { VoicePreset.entries.forEach { preset -> val isSelected = preset == selectedPreset - val isThisProcessing = isProcessing && preset == selectedPreset + val isThisProcessing = preset == processingPreset val isEnabled = !isProcessing || preset == VoicePreset.NONE FilterChip( 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 029fdc454..88ac4f5a4 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 @@ -409,7 +409,7 @@ private fun NewPostScreenBody( Spacer(modifier = Modifier.height(8.dp)) VoicePresetSelector( selectedPreset = postViewModel.selectedPreset, - isProcessing = postViewModel.isProcessingPreset, + 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 9f4e5c237..cd0030f0a 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 @@ -200,7 +200,7 @@ open class ShortNotePostViewModel : // Voice Anonymization var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) - var isProcessingPreset: Boolean by mutableStateOf(false) + var processingPreset: VoicePreset? by mutableStateOf(null) var distortedFiles: Map by mutableStateOf(emptyMap()) private var processingJob: Job? = null @@ -829,7 +829,7 @@ open class ShortNotePostViewModel : voiceSelectedServer = null voiceOrchestrator = null selectedPreset = VoicePreset.NONE - isProcessingPreset = false + processingPreset = null pTags = null wantsPoll = false @@ -951,7 +951,7 @@ open class ShortNotePostViewModel : fun canPost(): Boolean { // Voice messages can be posted without text (with either uploaded or pending recording) if (voiceMetadata != null || voiceRecording != null) { - return !isUploadingVoice && !isUploadingImage && !isProcessingPreset + return !isUploadingVoice && !isUploadingImage && processingPreset == null } // Regular text/media posts require text @@ -987,7 +987,7 @@ open class ShortNotePostViewModel : voiceLocalFile = recording.file voiceMetadata = null selectedPreset = VoicePreset.NONE - isProcessingPreset = false + processingPreset = null } fun getVoicePreviewMetadata(): AudioMeta? = @@ -1001,7 +1001,7 @@ open class ShortNotePostViewModel : } fun selectPreset(preset: VoicePreset) { - if (isProcessingPreset || preset == selectedPreset) return + if (processingPreset != null || preset == selectedPreset) return if (preset == VoicePreset.NONE) { selectedPreset = preset @@ -1018,7 +1018,7 @@ open class ShortNotePostViewModel : processingJob?.cancel() processingJob = viewModelScope.launch { - isProcessingPreset = true + processingPreset = preset try { val anonymizer = VoiceAnonymizer() val result = anonymizer.anonymize(originalFile, preset) @@ -1035,7 +1035,7 @@ open class ShortNotePostViewModel : ) } } finally { - isProcessingPreset = false + processingPreset = null processingJob = null } } @@ -1051,7 +1051,7 @@ open class ShortNotePostViewModel : isUploadingVoice = false voiceOrchestrator = null selectedPreset = VoicePreset.NONE - isProcessingPreset = false + processingPreset = null } private fun deleteVoiceLocalFile() { 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 a6d7b888a..f1dae3aa5 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 @@ -198,7 +198,7 @@ private fun VoiceReplyScreenBody( Spacer(modifier = Modifier.height(8.dp)) VoicePresetSelector( selectedPreset = viewModel.selectedPreset, - isProcessing = viewModel.isProcessingPreset, + 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 799fbf0a5..1c99db810 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 @@ -71,7 +71,7 @@ class VoiceReplyViewModel : ViewModel() { var isUploading: Boolean by mutableStateOf(false) var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) - var isProcessingPreset: Boolean by mutableStateOf(false) + var processingPreset: VoicePreset? by mutableStateOf(null) var distortedFiles: Map by mutableStateOf(emptyMap()) private var processingJob: Job? = null @@ -138,6 +138,7 @@ class VoiceReplyViewModel : ViewModel() { fun selectRecording(recording: RecordingResult) { cancelUpload() processingJob?.cancel() + processingPreset = null deleteVoiceLocalFile() voiceRecording = recording voiceLocalFile = recording.file @@ -175,10 +176,10 @@ class VoiceReplyViewModel : ViewModel() { distortedFiles = emptyMap() } - fun canSend(): Boolean = voiceRecording != null && !isUploading && !isProcessingPreset + fun canSend(): Boolean = voiceRecording != null && !isUploading && processingPreset == null fun selectPreset(preset: VoicePreset) { - if (isProcessingPreset || preset == selectedPreset) return + if (processingPreset != null || preset == selectedPreset) return if (preset == VoicePreset.NONE) { selectedPreset = preset @@ -195,7 +196,7 @@ class VoiceReplyViewModel : ViewModel() { processingJob?.cancel() processingJob = viewModelScope.launch { - isProcessingPreset = true + processingPreset = preset try { val anonymizer = VoiceAnonymizer() val result = anonymizer.anonymize(originalFile, preset) @@ -212,7 +213,7 @@ class VoiceReplyViewModel : ViewModel() { ) } } finally { - isProcessingPreset = false + processingPreset = null processingJob = null } } @@ -356,7 +357,7 @@ class VoiceReplyViewModel : ViewModel() { isUploading = false voiceOrchestrator = null selectedPreset = VoicePreset.NONE - isProcessingPreset = false + processingPreset = null } override fun onCleared() { From 666015b8eb5b9050a42acdcc3b3959ff437789ea Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 22:27:00 +0100 Subject: [PATCH 09/12] 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() { From 3287e3c031dd399f34ef3a16ee72393608524286 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 22:46:03 +0100 Subject: [PATCH 10/12] =?UTF-8?q?add=20a=20=C2=B110%=20random=20variation?= =?UTF-8?q?=20to=20HIGH=20and=20DEEP=20pitch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../amethyst/ui/actions/uploads/VoiceAnonymizer.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 1359f3318..ee036c482 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 @@ -50,7 +50,6 @@ data class AnonymizedResult( class VoiceAnonymizer { companion object { private const val TAG = "VoiceAnonymizer" - private const val SAMPLE_RATE = 44100 private const val CHANNELS = 1 private const val BIT_RATE = 128000 } @@ -206,7 +205,16 @@ class VoiceAnonymizer { sampleRate: Int, onProgress: (Float) -> Unit, ): FloatArray { - val factor = preset.pitchFactor + val baseFactor = preset.pitchFactor + val factor = + when (preset) { + VoicePreset.DEEP, VoicePreset.HIGH -> { + // Add ±10% random variation + val randomShift = 0.9 + (Math.random() * 0.2) + baseFactor * randomShift + } + else -> baseFactor + } val processedSamples = mutableListOf() val totalSamples = pcmData.size From f13a9164c2d931ad9b8998e2b3e944cf37d0e88b Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 22:55:53 +0100 Subject: [PATCH 11/12] code review: resource leaks and init guard --- .../ui/actions/uploads/VoiceAnonymizer.kt | 289 +++++++++--------- .../loggedIn/home/VoiceReplyViewModel.kt | 10 +- 2 files changed, 154 insertions(+), 145 deletions(-) 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 ee036c482..391b79349 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 @@ -112,91 +112,94 @@ class VoiceAnonymizer { onProgress: (Float) -> Unit, ): DecodedAudio { val extractor = MediaExtractor() - extractor.setDataSource(inputFile.absolutePath) + var decoder: MediaCodec? = null - var audioTrackIndex = -1 - var format: MediaFormat? = null - for (i in 0 until extractor.trackCount) { - val trackFormat = extractor.getTrackFormat(i) - val mime = trackFormat.getString(MediaFormat.KEY_MIME) - if (mime?.startsWith("audio/") == true) { - audioTrackIndex = i - format = trackFormat - break + try { + extractor.setDataSource(inputFile.absolutePath) + + var audioTrackIndex = -1 + var format: MediaFormat? = null + for (i in 0 until extractor.trackCount) { + val trackFormat = extractor.getTrackFormat(i) + val mime = trackFormat.getString(MediaFormat.KEY_MIME) + if (mime?.startsWith("audio/") == true) { + audioTrackIndex = i + format = trackFormat + break + } } - } - if (audioTrackIndex == -1 || format == null) { - extractor.release() - throw IllegalStateException("No audio track found in file") - } + if (audioTrackIndex == -1 || format == null) { + throw IllegalStateException("No audio track found in file") + } - extractor.selectTrack(audioTrackIndex) - val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm" - val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) - val durationUs = format.getLong(MediaFormat.KEY_DURATION) - val duration = (durationUs / 1_000_000).toInt() + extractor.selectTrack(audioTrackIndex) + val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm" + val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) + val durationUs = format.getLong(MediaFormat.KEY_DURATION) + val duration = (durationUs / 1_000_000).toInt() - val decoder = MediaCodec.createDecoderByType(mime) - decoder.configure(format, null, null, 0) - decoder.start() + decoder = MediaCodec.createDecoderByType(mime) + decoder.configure(format, null, null, 0) + decoder.start() - val pcmSamples = mutableListOf() - val bufferInfo = MediaCodec.BufferInfo() - var inputDone = false - var outputDone = false + val pcmSamples = mutableListOf() + val bufferInfo = MediaCodec.BufferInfo() + var inputDone = false + var outputDone = false - while (!outputDone && currentCoroutineContext().isActive) { - if (!inputDone) { - val inputBufferIndex = decoder.dequeueInputBuffer(10000) - if (inputBufferIndex >= 0) { - val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!! - val sampleSize = extractor.readSampleData(inputBuffer, 0) - if (sampleSize < 0) { - decoder.queueInputBuffer( - inputBufferIndex, - 0, - 0, - 0, - MediaCodec.BUFFER_FLAG_END_OF_STREAM, - ) - inputDone = true - } else { - val presentationTimeUs = extractor.sampleTime - decoder.queueInputBuffer( - inputBufferIndex, - 0, - sampleSize, - presentationTimeUs, - 0, - ) - extractor.advance() - if (durationUs > 0) { - onProgress((presentationTimeUs.toFloat() / durationUs).coerceIn(0f, 1f)) + while (!outputDone && currentCoroutineContext().isActive) { + if (!inputDone) { + val inputBufferIndex = decoder.dequeueInputBuffer(10000) + if (inputBufferIndex >= 0) { + val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!! + val sampleSize = extractor.readSampleData(inputBuffer, 0) + if (sampleSize < 0) { + decoder.queueInputBuffer( + inputBufferIndex, + 0, + 0, + 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM, + ) + inputDone = true + } else { + val presentationTimeUs = extractor.sampleTime + decoder.queueInputBuffer( + inputBufferIndex, + 0, + sampleSize, + presentationTimeUs, + 0, + ) + extractor.advance() + if (durationUs > 0) { + onProgress((presentationTimeUs.toFloat() / durationUs).coerceIn(0f, 1f)) + } } } } + + val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000) + if (outputBufferIndex >= 0) { + val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!! + val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer() + while (shortBuffer.hasRemaining()) { + pcmSamples.add(shortBuffer.get() / 32768f) + } + decoder.releaseOutputBuffer(outputBufferIndex, false) + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + outputDone = true + } + } } - val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000) - if (outputBufferIndex >= 0) { - val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!! - val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer() - while (shortBuffer.hasRemaining()) { - pcmSamples.add(shortBuffer.get() / 32768f) - } - decoder.releaseOutputBuffer(outputBufferIndex, false) - if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - outputDone = true - } - } + return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration) + } finally { + decoder?.stop() + decoder?.release() + extractor.release() } - - decoder.stop() - decoder.release() - extractor.release() - - return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration) } private fun processPcmWithTarsos( @@ -320,86 +323,90 @@ class VoiceAnonymizer { format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) - encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - encoder.start() - val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) - var audioTrackIndex = -1 var muxerStarted = false - val bufferInfo = MediaCodec.BufferInfo() - var inputOffset = 0 - var inputDone = false - var outputDone = false - val totalSamples = pcmData.size + try { + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + encoder.start() - while (!outputDone) { - if (!inputDone) { - val inputBufferIndex = encoder.dequeueInputBuffer(10000) - if (inputBufferIndex >= 0) { - val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!! - inputBuffer.clear() + var audioTrackIndex = -1 + val bufferInfo = MediaCodec.BufferInfo() + var inputOffset = 0 + var inputDone = false + var outputDone = false + val totalSamples = pcmData.size - val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset) - if (samplesToWrite <= 0) { - encoder.queueInputBuffer( - inputBufferIndex, - 0, - 0, - 0, - MediaCodec.BUFFER_FLAG_END_OF_STREAM, - ) - inputDone = true - } else { - for (i in 0 until samplesToWrite) { - val sample = - (pcmData[inputOffset + i] * 32767) - .toInt() - .coerceIn(-32768, 32767) - .toShort() - inputBuffer.putShort(sample) + while (!outputDone) { + if (!inputDone) { + val inputBufferIndex = encoder.dequeueInputBuffer(10000) + if (inputBufferIndex >= 0) { + val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!! + inputBuffer.clear() + + val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset) + if (samplesToWrite <= 0) { + encoder.queueInputBuffer( + inputBufferIndex, + 0, + 0, + 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM, + ) + inputDone = true + } else { + for (i in 0 until samplesToWrite) { + val sample = + (pcmData[inputOffset + i] * 32767) + .toInt() + .coerceIn(-32768, 32767) + .toShort() + inputBuffer.putShort(sample) + } + val presentationTimeUs = (inputOffset * 1_000_000L) / sampleRate + encoder.queueInputBuffer( + inputBufferIndex, + 0, + inputBuffer.position(), + presentationTimeUs, + 0, + ) + inputOffset += samplesToWrite + onProgress(inputOffset.toFloat() / totalSamples) + } + } + } + + val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000) + when { + outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + audioTrackIndex = muxer.addTrack(encoder.outputFormat) + muxer.start() + muxerStarted = true + } + + outputBufferIndex >= 0 -> { + val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!! + if (muxerStarted && bufferInfo.size > 0) { + outputBuffer.position(bufferInfo.offset) + outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo) + } + encoder.releaseOutputBuffer(outputBufferIndex, false) + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + outputDone = true } - val presentationTimeUs = (inputOffset * 1_000_000L) / sampleRate - encoder.queueInputBuffer( - inputBufferIndex, - 0, - inputBuffer.position(), - presentationTimeUs, - 0, - ) - inputOffset += samplesToWrite - onProgress(inputOffset.toFloat() / totalSamples) } } } - - val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000) - when { - outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { - audioTrackIndex = muxer.addTrack(encoder.outputFormat) - muxer.start() - muxerStarted = true - } - - outputBufferIndex >= 0 -> { - val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!! - if (muxerStarted && bufferInfo.size > 0) { - outputBuffer.position(bufferInfo.offset) - outputBuffer.limit(bufferInfo.offset + bufferInfo.size) - muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo) - } - encoder.releaseOutputBuffer(outputBufferIndex, false) - if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - outputDone = true - } - } + } finally { + encoder.stop() + encoder.release() + if (muxerStarted) { + muxer.stop() } + muxer.release() } - - encoder.stop() - encoder.release() - muxer.stop() - muxer.release() } } 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 eea2a0af6..5d0099b89 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 @@ -74,10 +74,12 @@ class VoiceReplyViewModel : ViewModel() { scope = viewModelScope, logTag = "VoiceReplyViewModel", onError = { error -> - accountViewModel.toastManager.toast( - stringRes(Amethyst.instance.appContext, R.string.error), - error.message ?: "Voice anonymization failed", - ) + if (::accountViewModel.isInitialized) { + accountViewModel.toastManager.toast( + stringRes(Amethyst.instance.appContext, R.string.error), + error.message ?: "Voice anonymization failed", + ) + } }, ) From ec159ff85f8617429d30a27e7d1eb8e672717efa Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 12 Jan 2026 23:02:33 +0100 Subject: [PATCH 12/12] add translations --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 6 ++++++ amethyst/src/main/res/values-de-rDE/strings.xml | 6 ++++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 6 ++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 6 ++++++ 4 files changed, 24 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 275889023..6c1aa14d8 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1228,4 +1228,10 @@ Smazat seznam Smazat sadu doporučení Typy + Žádný + Hluboký + Vysoký + Neutrální + Anonymizovat + Upravuje výšku vašeho hlasu. Poznámka: základní změny výšky hlasu mohou být odhodlanými posluchači potenciálně zpětně rozpoznány. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 7a521e907..586128b78 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1233,4 +1233,10 @@ anz der Bedingungen ist erforderlich Liste löschen Empfehlungspaket löschen Typen + Keine + Tief + Hoch + Neutral + Anonymisieren + Verändert die Tonhöhe deiner Stimme. Hinweis: einfache Tonhöhenänderungen können von entschlossenen Zuhörern möglicherweise rückgängig gemacht werden. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index a3f8b297c..97ff4065d 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1228,4 +1228,10 @@ Excluir lista Excluir pacote de recomendações Tipos + Nenhum + Grave + Agudo + Neutro + Anonimizar + Altera o tom da sua voz. Nota: alterações básicas de tom podem potencialmente ser revertidas por ouvintes determinados. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 7a56f1c6e..de6dae781 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1227,4 +1227,10 @@ Ta bort lista Ta bort rekommendationspaket Typer + Ingen + Djup + Hög + Neutral + Anonymisera + Ändrar tonhöjden på din röst. Obs: enkla förändringar av tonhöjd kan potentiellt återskapas av målmedvetna lyssnare.