Merge pull request #1665 from davotoula/voice-distortion-poc

Voice distortion (basic)
This commit is contained in:
Vitor Pamplona
2026-01-13 08:13:02 -05:00
committed by GitHub
17 changed files with 918 additions and 12 deletions
+3
View File
@@ -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
@@ -0,0 +1,126 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.File
class VoiceAnonymizationController(
private val scope: CoroutineScope,
private val logTag: String,
private val onError: (Throwable) -> Unit,
) {
var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE)
private set
var processingPreset: VoicePreset? by mutableStateOf(null)
private set
var distortedFiles: Map<VoicePreset, AnonymizedResult> by mutableStateOf(emptyMap())
private set
private var processingJob: Job? = null
fun activeFile(originalFile: File?): File? =
if (selectedPreset == VoicePreset.NONE) {
originalFile
} else {
distortedFiles[selectedPreset]?.file
}
fun activeWaveform(originalWaveform: List<Float>?): List<Float>? =
if (selectedPreset == VoicePreset.NONE) {
originalWaveform
} else {
distortedFiles[selectedPreset]?.waveform
}
fun selectPreset(
preset: VoicePreset,
originalFile: File?,
) {
if (processingPreset != null || preset == selectedPreset) return
if (preset == VoicePreset.NONE) {
selectedPreset = preset
return
}
if (distortedFiles.containsKey(preset)) {
selectedPreset = preset
return
}
val file = originalFile ?: return
processingJob?.cancel()
processingJob =
scope.launch {
processingPreset = preset
try {
val anonymizer = VoiceAnonymizer()
val result = anonymizer.anonymize(file, preset)
result
.onSuccess { anonymizedResult ->
distortedFiles = distortedFiles + (preset to anonymizedResult)
selectedPreset = preset
}.onFailure { error ->
Log.w(logTag, "Failed to anonymize voice", error)
onError(error)
}
} finally {
processingPreset = null
processingJob = null
}
}
}
fun clear() {
cancelProcessing()
deleteDistortedFiles()
selectedPreset = VoicePreset.NONE
}
fun deleteDistortedFiles() {
distortedFiles.values.forEach { result ->
try {
if (result.file.exists()) {
result.file.delete()
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
}
} catch (e: Exception) {
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e)
}
}
distortedFiles = emptyMap()
}
private fun cancelProcessing() {
processingJob?.cancel()
processingJob = null
processingPreset = null
}
}
@@ -0,0 +1,79 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size5dp
@Composable
fun VoiceAnonymizationSection(
selectedPreset: VoicePreset,
processingPreset: VoicePreset?,
onPresetSelected: (VoicePreset) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
) {
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(thickness = DividerThickness)
Spacer(modifier = Modifier.height(12.dp))
Column(
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.voice_anonymize_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.voice_anonymize_description),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.height(8.dp))
VoicePresetSelector(
selectedPreset = selectedPreset,
processingPreset = processingPreset,
onPresetSelected = onPresetSelected,
)
}
}
@@ -0,0 +1,450 @@
/**
* 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.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.ByteOrder
import kotlin.math.abs
data class AnonymizedResult(
val file: File,
val waveform: List<Float>,
val duration: Int,
)
class VoiceAnonymizer {
companion object {
private const val TAG = "VoiceAnonymizer"
private const val CHANNELS = 1
private const val BIT_RATE = 128000
}
suspend fun anonymize(
inputFile: File,
preset: VoicePreset,
onProgress: (Float) -> Unit = {},
): Result<AnonymizedResult> =
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()
var decoder: MediaCodec? = null
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) {
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()
decoder = MediaCodec.createDecoderByType(mime)
decoder.configure(format, null, null, 0)
decoder.start()
val pcmSamples = mutableListOf<Float>()
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))
}
}
}
}
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()
}
}
private fun processPcmWithTarsos(
pcmData: FloatArray,
preset: VoicePreset,
sampleRate: Int,
onProgress: (Float) -> Unit,
): FloatArray {
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<Float>()
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<Float> {
val waveform = mutableListOf<Float>()
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)
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
var muxerStarted = false
try {
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
encoder.start()
var audioTrackIndex = -1
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
}
}
}
}
} finally {
encoder.stop()
encoder.release()
if (muxerStarted) {
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() {}
}
@@ -0,0 +1,33 @@
/**
* 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 com.vitorpamplona.amethyst.R
enum class VoicePreset(
val pitchFactor: Double,
val labelRes: Int,
) {
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),
}
@@ -0,0 +1,81 @@
/**
* 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,
processingPreset: VoicePreset?,
onPresetSelected: (VoicePreset) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val isProcessing = processingPreset != null
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
VoicePreset.entries.forEach { preset ->
val isSelected = preset == selectedPreset
val isThisProcessing = preset == processingPreset
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,
),
)
}
}
}
@@ -65,6 +65,7 @@ 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.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
@@ -366,11 +367,24 @@ 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 anonymization section (only show when not uploading and voice is pending)
if (postViewModel.voiceRecording != null) {
VoiceAnonymizationSection(
selectedPreset = postViewModel.selectedPreset,
processingPreset = postViewModel.processingPreset,
onPresetSelected = { postViewModel.selectPreset(it) },
)
}
}
FileServerSelectionRow(
@@ -53,6 +53,8 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
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.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
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
@@ -194,6 +196,31 @@ open class ShortNotePostViewModel :
var voiceSelectedServer by mutableStateOf<ServerName?>(null)
var voiceOrchestrator by mutableStateOf<UploadOrchestrator?>(null)
// Voice Anonymization
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() = voiceAnonymization.activeFile(voiceLocalFile)
val activeWaveform: List<Float>?
get() = voiceAnonymization.activeWaveform(voiceRecording?.amplitudes)
val selectedPreset: VoicePreset
get() = voiceAnonymization.selectedPreset
val processingPreset: VoicePreset?
get() = voiceAnonymization.processingPreset
// Polls
var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false)
@@ -794,6 +821,7 @@ open class ShortNotePostViewModel :
multiOrchestrator = null
isUploadingImage = false
voiceAnonymization.clear()
deleteVoiceLocalFile()
voiceRecording = null
voiceLocalFile = null
@@ -922,7 +950,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 && processingPreset == null
}
// Regular text/media posts require text
@@ -951,10 +979,12 @@ open class ShortNotePostViewModel :
}
fun selectVoiceRecording(recording: RecordingResult) {
// Delete any existing temp file before replacing
// Cancel any ongoing processing and delete existing files
voiceAnonymization.clear()
deleteVoiceLocalFile()
voiceRecording = recording
voiceLocalFile = recording.file
voiceMetadata = null
}
fun getVoicePreviewMetadata(): AudioMeta? =
@@ -967,7 +997,12 @@ open class ShortNotePostViewModel :
)
}
fun selectPreset(preset: VoicePreset) {
voiceAnonymization.selectPreset(preset, voiceLocalFile)
}
fun removeVoiceMessage() {
voiceAnonymization.clear()
deleteVoiceLocalFile()
voiceRecording = null
voiceLocalFile = null
@@ -995,6 +1030,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 +1043,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,10 +1070,11 @@ 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()
voiceAnonymization.deleteDistortedFiles()
voiceLocalFile = null
voiceRecording = null
}
@@ -53,6 +53,7 @@ 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.formatSecondsToTime
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
@@ -153,15 +154,26 @@ 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 anonymization section
VoiceAnonymizationSection(
selectedPreset = viewModel.selectedPreset,
processingPreset = viewModel.processingPreset,
onPresetSelected = { viewModel.selectPreset(it) },
)
}
Spacer(modifier = Modifier.height(16.dp))
@@ -35,6 +35,8 @@ 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.RecordingResult
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
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
@@ -67,6 +69,32 @@ class VoiceReplyViewModel : ViewModel() {
var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null)
var isUploading: Boolean by mutableStateOf(false)
private val voiceAnonymization =
VoiceAnonymizationController(
scope = viewModelScope,
logTag = "VoiceReplyViewModel",
onError = { error ->
if (::accountViewModel.isInitialized) {
accountViewModel.toastManager.toast(
stringRes(Amethyst.instance.appContext, R.string.error),
error.message ?: "Voice anonymization failed",
)
}
},
)
val activeFile: File?
get() = voiceAnonymization.activeFile(voiceLocalFile)
val activeWaveform: List<Float>?
get() = voiceAnonymization.activeWaveform(voiceRecording?.amplitudes)
val selectedPreset: VoicePreset
get() = voiceAnonymization.selectedPreset
val processingPreset: VoicePreset?
get() = voiceAnonymization.processingPreset
private var uploadJob: Job? = null
fun init(accountVM: AccountViewModel) {
@@ -113,6 +141,7 @@ class VoiceReplyViewModel : ViewModel() {
fun selectRecording(recording: RecordingResult) {
cancelUpload()
voiceAnonymization.clear()
deleteVoiceLocalFile()
voiceRecording = recording
voiceLocalFile = recording.file
@@ -137,11 +166,16 @@ class VoiceReplyViewModel : ViewModel() {
}
}
fun canSend(): Boolean = voiceRecording != null && !isUploading
fun canSend(): Boolean = voiceRecording != null && !isUploading && processingPreset == null
fun selectPreset(preset: VoicePreset) {
voiceAnonymization.selectPreset(preset, voiceLocalFile)
}
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 +188,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 +202,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 +226,7 @@ class VoiceReplyViewModel : ViewModel() {
private suspend fun handleUploadResult(
note: Note,
recording: RecordingResult,
waveform: List<Float>,
server: ServerName,
result: UploadingState,
onSuccess: () -> Unit,
@@ -211,7 +246,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
@@ -244,6 +279,7 @@ class VoiceReplyViewModel : ViewModel() {
}
deleteVoiceLocalFile()
voiceAnonymization.deleteDistortedFiles()
voiceLocalFile = null
voiceRecording = null
voiceMetadata = audioMeta
@@ -266,6 +302,7 @@ class VoiceReplyViewModel : ViewModel() {
fun cancel() {
cancelUpload()
voiceAnonymization.clear()
deleteVoiceLocalFile()
voiceRecording = null
voiceLocalFile = null
@@ -1228,4 +1228,10 @@
<string name="follow_set_delete">Smazat seznam</string>
<string name="follow_pack_delete">Smazat sadu doporučení</string>
<string name="kinds">Typy</string>
<string name="voice_preset_none">Žádný</string>
<string name="voice_preset_deep">Hluboký</string>
<string name="voice_preset_high">Vysoký</string>
<string name="voice_preset_neutral">Neutrální</string>
<string name="voice_anonymize_title">Anonymizovat</string>
<string name="voice_anonymize_description">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.</string>
</resources>
@@ -1233,4 +1233,10 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_delete">Liste löschen</string>
<string name="follow_pack_delete">Empfehlungspaket löschen</string>
<string name="kinds">Typen</string>
<string name="voice_preset_none">Keine</string>
<string name="voice_preset_deep">Tief</string>
<string name="voice_preset_high">Hoch</string>
<string name="voice_preset_neutral">Neutral</string>
<string name="voice_anonymize_title">Anonymisieren</string>
<string name="voice_anonymize_description">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.</string>
</resources>
@@ -1228,4 +1228,10 @@
<string name="follow_set_delete">Excluir lista</string>
<string name="follow_pack_delete">Excluir pacote de recomendações</string>
<string name="kinds">Tipos</string>
<string name="voice_preset_none">Nenhum</string>
<string name="voice_preset_deep">Grave</string>
<string name="voice_preset_high">Agudo</string>
<string name="voice_preset_neutral">Neutro</string>
<string name="voice_anonymize_title">Anonimizar</string>
<string name="voice_anonymize_description">Altera o tom da sua voz. Nota: alterações básicas de tom podem potencialmente ser revertidas por ouvintes determinados.</string>
</resources>
@@ -1227,4 +1227,10 @@
<string name="follow_set_delete">Ta bort lista</string>
<string name="follow_pack_delete">Ta bort rekommendationspaket</string>
<string name="kinds">Typer</string>
<string name="voice_preset_none">Ingen</string>
<string name="voice_preset_deep">Djup</string>
<string name="voice_preset_high">Hög</string>
<string name="voice_preset_neutral">Neutral</string>
<string name="voice_anonymize_title">Anonymisera</string>
<string name="voice_anonymize_description">Ändrar tonhöjden på din röst. Obs: enkla förändringar av tonhöjd kan potentiellt återskapas av målmedvetna lyssnare.</string>
</resources>
+6
View File
@@ -184,6 +184,12 @@
<string name="upload_error_voice_message_unexpected_state">Unexpected upload state</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 is not supported for voice messages yet</string>
<string name="upload_error_voice_message_exception">Voice upload failed: %1$s</string>
<string name="voice_preset_none">None</string>
<string name="voice_preset_deep">Deep</string>
<string name="voice_preset_high">High</string>
<string name="voice_preset_neutral">Neutral</string>
<string name="voice_anonymize_title">Anonymize</string>
<string name="voice_anonymize_description">Alters your voice pitch. Note: basic pitch changes can potentially be reversed by determined listeners.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address set up to receive sats</string>
<string name="reply_here">"reply here.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copies the Note ID to the clipboard for sharing in Nostr</string>
+2
View File
@@ -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" }
+1
View File
@@ -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" }
}
}