From 5ce4391db4505a1e224bee3934469e1727e3c194 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 10:29:06 +0100 Subject: [PATCH 01/12] guard encoder / decoder .stop --- .../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 391b79349..c8742fdb8 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 @@ -196,7 +196,11 @@ class VoiceAnonymizer { return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration) } finally { - decoder?.stop() + try { + decoder?.stop() + } catch (_: IllegalStateException) { + // Decoder was never started + } decoder?.release() extractor.release() } @@ -400,7 +404,11 @@ class VoiceAnonymizer { } } } finally { - encoder.stop() + try { + encoder.stop() + } catch (_: IllegalStateException) { + // Encoder was never started + } encoder.release() if (muxerStarted) { muxer.stop() From c6d437b98e367ae5f65c4744a6b41b255415a45e Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 10:47:03 +0100 Subject: [PATCH 02/12] confirmed: <1 results in Higher pitch. Adjusted NEUTRAL to slightly lower pitch --- .../amethyst/ui/actions/uploads/VoiceAnonymizationController.kt | 1 + .../vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 index b720e20d8..33adff8b6 100644 --- 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 @@ -61,6 +61,7 @@ class VoiceAnonymizationController( preset: VoicePreset, originalFile: File?, ) { + Log.d(logTag, "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}") if (processingPreset != null || preset == selectedPreset) return if (preset == VoicePreset.NONE) { 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 49544e51a..b6c67d244 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,5 +29,5 @@ enum class VoicePreset( 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), + NEUTRAL(1.1, R.string.voice_preset_neutral), } From 708c6da9fe85afabe1c2aac6822d38aa3646f0ab Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 10:52:58 +0100 Subject: [PATCH 03/12] add docs --- .../ui/actions/uploads/VoiceAnonymizer.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 c8742fdb8..7240c87ea 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 @@ -41,12 +41,26 @@ import java.io.File import java.nio.ByteOrder import kotlin.math.abs +/** + * Result of voice anonymization processing. + * + * @property file The output audio file (AAC in MP4 container) + * @property waveform Amplitude data for waveform visualization (one value per second) + * @property duration Audio duration in seconds + */ data class AnonymizedResult( val file: File, val waveform: List, val duration: Int, ) +/** + * Processes audio files to alter voice characteristics for privacy. + * + * Uses TarsosDSP's WSOLA (Waveform Similarity Overlap-Add) algorithm combined with + * rate transposition to shift pitch while preserving duration. Note that in TarsosDSP, + * pitch factors work inversely: factor < 1 raises pitch, factor > 1 lowers pitch. + */ class VoiceAnonymizer { companion object { private const val TAG = "VoiceAnonymizer" @@ -54,6 +68,20 @@ class VoiceAnonymizer { private const val BIT_RATE = 128000 } + /** + * Applies voice anonymization to an audio file. + * + * The process involves three stages: + * 1. Decode input audio to PCM (0-30% progress) + * 2. Apply pitch shifting with TarsosDSP (30-70% progress) + * 3. Encode processed audio to AAC (70-100% progress) + * + * @param inputFile Source audio file (supports formats decodable by MediaCodec) + * @param preset Voice transformation preset (NONE is not allowed) + * @param onProgress Callback invoked with progress value from 0.0 to 1.0 + * @return [Result.success] with [AnonymizedResult] containing the output file, + * waveform data, and duration; or [Result.failure] with the exception + */ suspend fun anonymize( inputFile: File, preset: VoicePreset, From 001dce8fef74644d8fbacbb46cb024743565817b Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 10:56:28 +0100 Subject: [PATCH 04/12] guard for null inputFile.parentFile --- .../amethyst/ui/actions/uploads/VoiceAnonymizer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 7240c87ea..db831a2ae 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 @@ -126,7 +126,8 @@ class VoiceAnonymizer { ): File { val baseName = inputFile.nameWithoutExtension val presetSuffix = preset.name.lowercase() - return File(inputFile.parentFile, "${baseName}_$presetSuffix.mp4") + val parentDir = inputFile.parentFile ?: inputFile.absoluteFile.parentFile + return File(parentDir, "${baseName}_$presetSuffix.mp4") } private data class DecodedAudio( From 0b233f3a3f4a347dcade1183337d374e9b11496d Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 11:00:42 +0100 Subject: [PATCH 05/12] use colorScheme.onSurfaceVariant instead of Gray --- .../amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 3b7f02028..ad4cce69d 100644 --- 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 @@ -30,7 +30,6 @@ 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 @@ -63,7 +62,7 @@ fun VoiceAnonymizationSection( Text( text = stringRes(R.string.voice_anonymize_description), style = MaterialTheme.typography.bodySmall, - color = Color.Gray, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, ) From 50b0e8bd17e90c21056fadc8859496bf220c8a17 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 11:33:30 +0100 Subject: [PATCH 06/12] Moved processingPreset = preset to execute synchronously before launching the coroutine --- .../amethyst/ui/actions/uploads/VoiceAnonymizationController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 33adff8b6..fdb54523e 100644 --- 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 @@ -77,9 +77,9 @@ class VoiceAnonymizationController( val file = originalFile ?: return processingJob?.cancel() + processingPreset = preset processingJob = scope.launch { - processingPreset = preset try { val anonymizer = VoiceAnonymizer() val result = anonymizer.anonymize(file, preset) From db75a7c910c030fc15bd749ac11c9d8bcc3eeb98 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 11:40:38 +0100 Subject: [PATCH 07/12] preallocate list capacity --- .../amethyst/ui/actions/uploads/VoiceAnonymizer.kt | 5 +++-- 1 file changed, 3 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 db831a2ae..71838a176 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 @@ -172,7 +172,8 @@ class VoiceAnonymizer { decoder.configure(format, null, null, 0) decoder.start() - val pcmSamples = mutableListOf() + val estimatedSamples = (sampleRate.toLong() * durationUs / 1_000_000).toInt() + val pcmSamples = ArrayList(estimatedSamples) val bufferInfo = MediaCodec.BufferInfo() var inputDone = false var outputDone = false @@ -251,8 +252,8 @@ class VoiceAnonymizer { } else -> baseFactor } - val processedSamples = mutableListOf() val totalSamples = pcmData.size + val processedSamples = ArrayList(totalSamples) val wsola = WaveformSimilarityBasedOverlapAdd( From 5be16338438cfdd61a2bda46192006d3d012a3fd Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 12:59:25 +0100 Subject: [PATCH 08/12] limit voice recording to 180s --- .../amethyst/ui/actions/uploads/RecordAudio.kt | 7 +++++++ .../amethyst/ui/actions/uploads/RecordVoiceButton.kt | 6 +++++- .../com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 2 ++ .../ui/screen/loggedIn/home/ShortNotePostScreen.kt | 2 ++ .../amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt | 2 ++ 5 files changed, 18 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 32af08e0b..c7fe883aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -42,11 +42,14 @@ import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +const val MAX_VOICE_RECORD_SECONDS = 180 + @OptIn(ExperimentalPermissionsApi::class) @Composable fun RecordAudioBox( modifier: Modifier, onRecordTaken: (RecordingResult) -> Unit, + maxDurationSeconds: Int? = null, content: @Composable (Boolean, Int) -> Unit, ) { val mediaRecorder = remember { mutableStateOf(null) } @@ -107,6 +110,10 @@ fun RecordAudioBox( while (isActive) { delay(1000) elapsedSeconds++ + if (maxDurationSeconds != null && elapsedSeconds >= maxDurationSeconds) { + stopRecording() + break + } } } else { // Reset elapsed time when not recording diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index 9cedeeab6..5d9fd487f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -42,7 +42,10 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes @Composable -fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { +fun RecordVoiceButton( + onVoiceTaken: (RecordingResult) -> Unit, + maxDurationSeconds: Int? = null, +) { var isRecording by remember { mutableStateOf(false) } var elapsedSeconds by remember { mutableIntStateOf(0) } @@ -61,6 +64,7 @@ fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { elapsedSeconds = 0 onVoiceTaken(recording) }, + maxDurationSeconds = maxDurationSeconds, ) { recordingState, elapsed -> // Update parent state after composition completes SideEffect { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a2814a464..ffcb0a2d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -113,6 +113,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator +import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox @@ -635,6 +636,7 @@ fun ReplyViaVoiceReaction( ) } }, + maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, ) { isRecording, elapsedSeconds -> if (voiceRecordingState != null) { SideEffect { 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 4318f13c8..e61651c12 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 @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -497,6 +498,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { onVoiceTaken = { recording -> postViewModel.selectVoiceRecording(recording) }, + maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, ) if (postViewModel.canUsePoll) { 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 a2573663b..7eeac6f77 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 @@ -51,6 +51,7 @@ 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.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection @@ -226,6 +227,7 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) { onRecordTaken = { recording -> viewModel.selectRecording(recording) }, + maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, ) { isRecording, elapsedSeconds -> val contentColor = if (isRecording) { From 76a05f7d71e3efe42f4861bdc5113a8bc5caddf7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 13:56:49 +0100 Subject: [PATCH 09/12] moved re-record button inline and added to VoiceMessagePreview.kt --- .../ui/actions/uploads/VoiceMessagePreview.kt | 202 +++++++++++++----- .../loggedIn/home/ShortNotePostScreen.kt | 2 + .../screen/loggedIn/home/VoiceReplyScreen.kt | 98 +-------- 3 files changed, 148 insertions(+), 154 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index 7633a47d8..40f60124e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -35,8 +35,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -67,6 +69,8 @@ fun VoiceMessagePreview( voiceMetadata: AudioMeta, localFile: File? = null, onRemove: () -> Unit, + onReRecord: ((RecordingResult) -> Unit)? = null, + isUploading: Boolean = false, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -101,77 +105,159 @@ fun VoiceMessagePreview( shape = RoundedCornerShape(8.dp), ).padding(12.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - // Play/Pause Button - IconButton( - onClick = { - handlePlayPauseClick( - mediaPlayer = mediaPlayer, - isPlaying = isPlaying, - progress = progress, - onProgressReset = { progress = 0f }, - onPlayingChanged = { isPlaying = it }, - ) - }, - modifier = Modifier.size(48.dp), + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon( - imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play), - tint = MaterialTheme.colorScheme.primary, - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - // Waveform and Duration - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - ) { - AudioWaveformReadOnly( - amplitudes = voiceMetadata.waveform ?: emptyList(), - progress = progress, - waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), - progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), - onProgressChange = { newProgress -> - handleWaveformScrub( - newProgress = newProgress, + // Play/Pause Button + IconButton( + onClick = { + handlePlayPauseClick( mediaPlayer = mediaPlayer, - onProgressChanged = { progress = it }, + isPlaying = isPlaying, + progress = progress, + onProgressReset = { progress = 0f }, + onPlayingChanged = { isPlaying = it }, ) }, - ) + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play), + tint = MaterialTheme.colorScheme.primary, + ) + } - Text( - text = formatSecondsToTime(voiceMetadata.duration ?: 0), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) + Spacer(modifier = Modifier.width(8.dp)) + + // Waveform and Duration + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + AudioWaveformReadOnly( + amplitudes = voiceMetadata.waveform ?: emptyList(), + progress = progress, + waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), + progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), + onProgressChange = { newProgress -> + handleWaveformScrub( + newProgress = newProgress, + mediaPlayer = mediaPlayer, + onProgressChanged = { progress = it }, + ) + }, + ) + + Text( + text = formatSecondsToTime(voiceMetadata.duration ?: 0), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Remove Button + IconButton( + onClick = onRemove, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringRes(context, R.string.remove), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - Spacer(modifier = Modifier.width(8.dp)) - - // Remove Button - IconButton( - onClick = onRemove, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringRes(context, R.string.remove), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + if (onReRecord != null) { + Spacer(modifier = Modifier.size(8.dp)) + ReRecordButton( + isUploading = isUploading, + isPlaying = isPlaying, + onRecordTaken = onReRecord, ) } } } } +@Composable +private fun ReRecordButton( + isUploading: Boolean, + isPlaying: Boolean, + onRecordTaken: (RecordingResult) -> Unit, +) { + if (isUploading || isPlaying) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringRes(id = R.string.re_record), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + + RecordAudioBox( + modifier = Modifier, + onRecordTaken = onRecordTaken, + maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, + ) { isRecording, elapsedSeconds -> + val contentColor = + if (isRecording) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val icon = + if (isRecording) { + Icons.Default.Stop + } else { + Icons.Default.Mic + } + val label = + if (isRecording) { + formatSecondsToTime(elapsedSeconds) + } else { + stringRes(id = R.string.re_record) + } + val iconDescription = + if (isRecording) { + stringRes(id = R.string.recording_indicator_description) + } else { + stringRes(id = R.string.record_a_message) + } + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = icon, + contentDescription = iconDescription, + tint = contentColor, + ) + Text( + text = label, + color = contentColor, + ) + } + } +} + @Composable private fun ManageMediaPlayer( voiceMetadata: AudioMeta, 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 e61651c12..99f6d42e0 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 @@ -375,6 +375,8 @@ private fun NewPostScreenBody( VoiceMessagePreview( voiceMetadata = displayMetadata, localFile = postViewModel.activeFile, + onReRecord = { recording -> postViewModel.selectVoiceRecording(recording) }, + isUploading = postViewModel.isUploadingVoice, onRemove = { postViewModel.removeVoiceMessage() }, ) 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 7eeac6f77..5baff1a94 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 @@ -21,47 +21,32 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -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.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.MAX_VOICE_RECORD_SECONDS -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.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.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -104,9 +89,6 @@ fun VoiceReplyScreen( }, ) }, - bottomBar = { - ReRecordButton(viewModel) - }, ) { pad -> Surface( modifier = @@ -162,6 +144,8 @@ private fun VoiceReplyScreenBody( VoiceMessagePreview( voiceMetadata = displayMetadata, localFile = viewModel.activeFile, + onReRecord = { recording -> viewModel.selectRecording(recording) }, + isUploading = viewModel.isUploading, onRemove = { viewModel.cancel() nav.popBack() @@ -193,81 +177,3 @@ private fun VoiceReplyScreenBody( Spacer(modifier = Modifier.height(80.dp)) } } - -@Composable -private fun ReRecordButton(viewModel: VoiceReplyViewModel) { - Column( - modifier = - Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(vertical = 16.dp, horizontal = Size10dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (viewModel.isUploading) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Default.Mic, - contentDescription = stringRes(id = R.string.record_a_message), - tint = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringRes(id = R.string.re_record), - color = MaterialTheme.colorScheme.onBackground, - ) - } - return - } - - RecordAudioBox( - modifier = Modifier, - onRecordTaken = { recording -> - viewModel.selectRecording(recording) - }, - maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, - ) { isRecording, elapsedSeconds -> - val contentColor = - if (isRecording) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onBackground - } - val icon = - if (isRecording) { - Icons.Default.Stop - } else { - Icons.Default.Mic - } - val label = - if (isRecording) { - formatSecondsToTime(elapsedSeconds) - } else { - stringRes(id = R.string.re_record) - } - val iconDescription = - if (isRecording) { - stringRes(id = R.string.recording_indicator_description) - } else { - stringRes(id = R.string.record_a_message) - } - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = icon, - contentDescription = iconDescription, - tint = contentColor, - ) - Text( - text = label, - color = contentColor, - ) - } - } - } -} From 835fbb90b7da16175aa91803c3aeccd373e42097 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 14:00:13 +0100 Subject: [PATCH 10/12] Do something with the "Boolean" value returned by "delete". --- .../ui/actions/uploads/VoiceAnonymizationController.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index fdb54523e..e154f0a27 100644 --- 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 @@ -109,8 +109,11 @@ class VoiceAnonymizationController( distortedFiles.values.forEach { result -> try { if (result.file.exists()) { - result.file.delete() - Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}") + if (result.file.delete()) { + Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}") + } else { + Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}") + } } } catch (e: Exception) { Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e) From 40d694343909b760b77a9a562878c50631525e55 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 14:04:59 +0100 Subject: [PATCH 11/12] sonar fixes --- .../ui/actions/uploads/VoiceAnonymizer.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 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 71838a176..8c4210bc4 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 @@ -158,9 +158,7 @@ class VoiceAnonymizer { } } - if (audioTrackIndex == -1 || format == null) { - throw IllegalStateException("No audio track found in file") - } + check(audioTrackIndex != -1 && format != null) { "No audio track found in file" } extractor.selectTrack(audioTrackIndex) val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm" @@ -286,7 +284,9 @@ class VoiceAnonymizer { return true } - override fun processingFinished() {} + override fun processingFinished() { + // No-op: no cleanup needed + } } val dispatcher = @@ -310,7 +310,9 @@ class VoiceAnonymizer { return true } - override fun processingFinished() {} + override fun processingFinished() { + // No-op: no cleanup needed + } } dispatcher.addAudioProcessor(progressProcessor) @@ -484,5 +486,7 @@ private class FloatArrayAudioInputStream( return actualSkip.toLong() * 2 } - override fun close() {} + override fun close() { + // No-op: no cleanup needed + } } From 505a6583ee32dec83a146f6f79c42bedda325952 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 14:20:05 +0100 Subject: [PATCH 12/12] remove unused imports --- .../main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt | 2 -- .../amethyst/commons/model/privateChats/Chatroom.kt | 1 - .../com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt | 1 - .../vitorpamplona/amethyst/commons/richtext/RichTextParser.kt | 1 - .../amethyst/desktop/network/DesktopRelayConnectionManager.kt | 1 - .../kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt | 1 - 6 files changed, 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt index 557e26c46..2b4bb2fe2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import android.R.attr.label -import android.R.attr.maxLines import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 5485bf5bf..dbf99854d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -34,7 +34,6 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import java.lang.ref.WeakReference -import kotlin.collections.plus @Stable class Chatroom : NotesGatherer { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt index b588ea572..428428bce 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import java.util.Base64 object Base64Image { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 70d90061d..b5dcf22fc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -45,7 +45,6 @@ import java.net.MalformedURLException import java.net.URISyntaxException import java.net.URL import kotlin.coroutines.cancellation.CancellationException -import kotlin.text.iterator class RichTextParser { fun createMediaContent( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt index 18e7a9873..1b0a019ed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.network -import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt index b8ac14da8..018385c6b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt @@ -49,7 +49,6 @@ import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.serialization.json.JsonNull.content @Immutable class TextNoteEvent(