Merge pull request #1665 from davotoula/voice-distortion-poc
Voice distortion (basic)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class VoiceAnonymizationController(
|
||||
private val scope: CoroutineScope,
|
||||
private val logTag: String,
|
||||
private val onError: (Throwable) -> Unit,
|
||||
) {
|
||||
var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE)
|
||||
private set
|
||||
var processingPreset: VoicePreset? by mutableStateOf(null)
|
||||
private set
|
||||
var distortedFiles: Map<VoicePreset, AnonymizedResult> by mutableStateOf(emptyMap())
|
||||
private set
|
||||
|
||||
private var processingJob: Job? = null
|
||||
|
||||
fun activeFile(originalFile: File?): File? =
|
||||
if (selectedPreset == VoicePreset.NONE) {
|
||||
originalFile
|
||||
} else {
|
||||
distortedFiles[selectedPreset]?.file
|
||||
}
|
||||
|
||||
fun activeWaveform(originalWaveform: List<Float>?): List<Float>? =
|
||||
if (selectedPreset == VoicePreset.NONE) {
|
||||
originalWaveform
|
||||
} else {
|
||||
distortedFiles[selectedPreset]?.waveform
|
||||
}
|
||||
|
||||
fun selectPreset(
|
||||
preset: VoicePreset,
|
||||
originalFile: File?,
|
||||
) {
|
||||
if (processingPreset != null || preset == selectedPreset) return
|
||||
|
||||
if (preset == VoicePreset.NONE) {
|
||||
selectedPreset = preset
|
||||
return
|
||||
}
|
||||
|
||||
if (distortedFiles.containsKey(preset)) {
|
||||
selectedPreset = preset
|
||||
return
|
||||
}
|
||||
|
||||
val file = originalFile ?: return
|
||||
|
||||
processingJob?.cancel()
|
||||
processingJob =
|
||||
scope.launch {
|
||||
processingPreset = preset
|
||||
try {
|
||||
val anonymizer = VoiceAnonymizer()
|
||||
val result = anonymizer.anonymize(file, preset)
|
||||
|
||||
result
|
||||
.onSuccess { anonymizedResult ->
|
||||
distortedFiles = distortedFiles + (preset to anonymizedResult)
|
||||
selectedPreset = preset
|
||||
}.onFailure { error ->
|
||||
Log.w(logTag, "Failed to anonymize voice", error)
|
||||
onError(error)
|
||||
}
|
||||
} finally {
|
||||
processingPreset = null
|
||||
processingJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
cancelProcessing()
|
||||
deleteDistortedFiles()
|
||||
selectedPreset = VoicePreset.NONE
|
||||
}
|
||||
|
||||
fun deleteDistortedFiles() {
|
||||
distortedFiles.values.forEach { result ->
|
||||
try {
|
||||
if (result.file.exists()) {
|
||||
result.file.delete()
|
||||
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e)
|
||||
}
|
||||
}
|
||||
distortedFiles = emptyMap()
|
||||
}
|
||||
|
||||
private fun cancelProcessing() {
|
||||
processingJob?.cancel()
|
||||
processingJob = null
|
||||
processingPreset = null
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
|
||||
@Composable
|
||||
fun VoiceAnonymizationSection(
|
||||
selectedPreset: VoicePreset,
|
||||
processingPreset: VoicePreset?,
|
||||
onPresetSelected: (VoicePreset) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.voice_anonymize_title),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.voice_anonymize_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
VoicePresetSelector(
|
||||
selectedPreset = selectedPreset,
|
||||
processingPreset = processingPreset,
|
||||
onPresetSelected = onPresetSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
+450
@@ -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),
|
||||
}
|
||||
+81
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -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(
|
||||
|
||||
+42
-4
@@ -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
|
||||
}
|
||||
|
||||
+14
-2
@@ -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))
|
||||
|
||||
+41
-4
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user