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
|
// Image compression lib
|
||||||
implementation libs.zelory.image.compressor
|
implementation libs.zelory.image.compressor
|
||||||
|
|
||||||
|
// Voice anonymization DSP
|
||||||
|
implementation libs.tarsosdsp
|
||||||
|
|
||||||
// Cbor for cashuB format
|
// Cbor for cashuB format
|
||||||
implementation libs.kotlinx.serialization.cbor
|
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.TakePictureButton
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
|
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.VoiceMessagePreview
|
||||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||||
@@ -366,11 +367,24 @@ private fun NewPostScreenBody(
|
|||||||
postViewModel.voiceOrchestrator?.let { orchestrator ->
|
postViewModel.voiceOrchestrator?.let { orchestrator ->
|
||||||
UploadProgressIndicator(orchestrator)
|
UploadProgressIndicator(orchestrator)
|
||||||
} ?: run {
|
} ?: run {
|
||||||
|
val displayMetadata =
|
||||||
|
metadata.copy(
|
||||||
|
waveform = postViewModel.activeWaveform ?: metadata.waveform,
|
||||||
|
)
|
||||||
VoiceMessagePreview(
|
VoiceMessagePreview(
|
||||||
voiceMetadata = metadata,
|
voiceMetadata = displayMetadata,
|
||||||
localFile = postViewModel.voiceLocalFile,
|
localFile = postViewModel.activeFile,
|
||||||
onRemove = { postViewModel.removeVoiceMessage() },
|
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(
|
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.RecordingResult
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
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.draftTags.DraftTagState
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||||
@@ -194,6 +196,31 @@ open class ShortNotePostViewModel :
|
|||||||
var voiceSelectedServer by mutableStateOf<ServerName?>(null)
|
var voiceSelectedServer by mutableStateOf<ServerName?>(null)
|
||||||
var voiceOrchestrator by mutableStateOf<UploadOrchestrator?>(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
|
// Polls
|
||||||
var canUsePoll by mutableStateOf(false)
|
var canUsePoll by mutableStateOf(false)
|
||||||
var wantsPoll by mutableStateOf(false)
|
var wantsPoll by mutableStateOf(false)
|
||||||
@@ -794,6 +821,7 @@ open class ShortNotePostViewModel :
|
|||||||
|
|
||||||
multiOrchestrator = null
|
multiOrchestrator = null
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
|
voiceAnonymization.clear()
|
||||||
deleteVoiceLocalFile()
|
deleteVoiceLocalFile()
|
||||||
voiceRecording = null
|
voiceRecording = null
|
||||||
voiceLocalFile = null
|
voiceLocalFile = null
|
||||||
@@ -922,7 +950,7 @@ open class ShortNotePostViewModel :
|
|||||||
fun canPost(): Boolean {
|
fun canPost(): Boolean {
|
||||||
// Voice messages can be posted without text (with either uploaded or pending recording)
|
// Voice messages can be posted without text (with either uploaded or pending recording)
|
||||||
if (voiceMetadata != null || voiceRecording != null) {
|
if (voiceMetadata != null || voiceRecording != null) {
|
||||||
return !isUploadingVoice && !isUploadingImage
|
return !isUploadingVoice && !isUploadingImage && processingPreset == null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular text/media posts require text
|
// Regular text/media posts require text
|
||||||
@@ -951,10 +979,12 @@ open class ShortNotePostViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun selectVoiceRecording(recording: RecordingResult) {
|
fun selectVoiceRecording(recording: RecordingResult) {
|
||||||
// Delete any existing temp file before replacing
|
// Cancel any ongoing processing and delete existing files
|
||||||
|
voiceAnonymization.clear()
|
||||||
deleteVoiceLocalFile()
|
deleteVoiceLocalFile()
|
||||||
voiceRecording = recording
|
voiceRecording = recording
|
||||||
voiceLocalFile = recording.file
|
voiceLocalFile = recording.file
|
||||||
|
voiceMetadata = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getVoicePreviewMetadata(): AudioMeta? =
|
fun getVoicePreviewMetadata(): AudioMeta? =
|
||||||
@@ -967,7 +997,12 @@ open class ShortNotePostViewModel :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun selectPreset(preset: VoicePreset) {
|
||||||
|
voiceAnonymization.selectPreset(preset, voiceLocalFile)
|
||||||
|
}
|
||||||
|
|
||||||
fun removeVoiceMessage() {
|
fun removeVoiceMessage() {
|
||||||
|
voiceAnonymization.clear()
|
||||||
deleteVoiceLocalFile()
|
deleteVoiceLocalFile()
|
||||||
voiceRecording = null
|
voiceRecording = null
|
||||||
voiceLocalFile = null
|
voiceLocalFile = null
|
||||||
@@ -995,6 +1030,8 @@ open class ShortNotePostViewModel :
|
|||||||
onError: (title: String, message: String) -> Unit,
|
onError: (title: String, message: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val recording = voiceRecording ?: return
|
val recording = voiceRecording ?: return
|
||||||
|
val fileToUpload = activeFile ?: recording.file
|
||||||
|
val waveform = activeWaveform ?: recording.amplitudes
|
||||||
val appContext = Amethyst.instance.appContext
|
val appContext = Amethyst.instance.appContext
|
||||||
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
|
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
|
||||||
val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported)
|
val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported)
|
||||||
@@ -1006,7 +1043,7 @@ open class ShortNotePostViewModel :
|
|||||||
isUploadingVoice = true
|
isUploadingVoice = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val uri = android.net.Uri.fromFile(recording.file)
|
val uri = android.net.Uri.fromFile(fileToUpload)
|
||||||
val orchestrator = UploadOrchestrator()
|
val orchestrator = UploadOrchestrator()
|
||||||
voiceOrchestrator = orchestrator
|
voiceOrchestrator = orchestrator
|
||||||
|
|
||||||
@@ -1033,10 +1070,11 @@ open class ShortNotePostViewModel :
|
|||||||
mimeType = recording.mimeType,
|
mimeType = recording.mimeType,
|
||||||
hash = orchestratorResult.fileHeader.hash,
|
hash = orchestratorResult.fileHeader.hash,
|
||||||
duration = recording.duration,
|
duration = recording.duration,
|
||||||
waveform = recording.amplitudes,
|
waveform = waveform,
|
||||||
)
|
)
|
||||||
// Delete the local file after successful upload
|
// Delete the local file after successful upload
|
||||||
deleteVoiceLocalFile()
|
deleteVoiceLocalFile()
|
||||||
|
voiceAnonymization.deleteDistortedFiles()
|
||||||
voiceLocalFile = null
|
voiceLocalFile = null
|
||||||
voiceRecording = 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.mediaServers.FileServerSelectionRow
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
|
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
|
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.VoiceMessagePreview
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime
|
import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||||
@@ -153,15 +154,26 @@ private fun VoiceReplyScreenBody(
|
|||||||
UploadProgressIndicator(orchestrator)
|
UploadProgressIndicator(orchestrator)
|
||||||
} ?: run {
|
} ?: run {
|
||||||
viewModel.getVoicePreviewMetadata()?.let { metadata ->
|
viewModel.getVoicePreviewMetadata()?.let { metadata ->
|
||||||
|
val displayMetadata =
|
||||||
|
metadata.copy(
|
||||||
|
waveform = viewModel.activeWaveform ?: metadata.waveform,
|
||||||
|
)
|
||||||
VoiceMessagePreview(
|
VoiceMessagePreview(
|
||||||
voiceMetadata = metadata,
|
voiceMetadata = displayMetadata,
|
||||||
localFile = viewModel.voiceLocalFile,
|
localFile = viewModel.activeFile,
|
||||||
onRemove = {
|
onRemove = {
|
||||||
viewModel.cancel()
|
viewModel.cancel()
|
||||||
nav.popBack()
|
nav.popBack()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voice anonymization section
|
||||||
|
VoiceAnonymizationSection(
|
||||||
|
selectedPreset = viewModel.selectedPreset,
|
||||||
|
processingPreset = viewModel.processingPreset,
|
||||||
|
onPresetSelected = { viewModel.selectPreset(it) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
|
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.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
|
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
|
||||||
@@ -67,6 +69,32 @@ class VoiceReplyViewModel : ViewModel() {
|
|||||||
var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null)
|
var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null)
|
||||||
var isUploading: Boolean by mutableStateOf(false)
|
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
|
private var uploadJob: Job? = null
|
||||||
|
|
||||||
fun init(accountVM: AccountViewModel) {
|
fun init(accountVM: AccountViewModel) {
|
||||||
@@ -113,6 +141,7 @@ class VoiceReplyViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun selectRecording(recording: RecordingResult) {
|
fun selectRecording(recording: RecordingResult) {
|
||||||
cancelUpload()
|
cancelUpload()
|
||||||
|
voiceAnonymization.clear()
|
||||||
deleteVoiceLocalFile()
|
deleteVoiceLocalFile()
|
||||||
voiceRecording = recording
|
voiceRecording = recording
|
||||||
voiceLocalFile = recording.file
|
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) {
|
fun sendVoiceReply(onSuccess: () -> Unit) {
|
||||||
val note = replyToNote ?: return
|
val note = replyToNote ?: return
|
||||||
val recording = voiceRecording ?: return
|
val recording = voiceRecording ?: return
|
||||||
|
val fileToUpload = activeFile ?: recording.file
|
||||||
val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer
|
val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer
|
||||||
|
|
||||||
cancelUpload()
|
cancelUpload()
|
||||||
@@ -154,7 +188,7 @@ class VoiceReplyViewModel : ViewModel() {
|
|||||||
try {
|
try {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val uri = android.net.Uri.fromFile(recording.file)
|
val uri = android.net.Uri.fromFile(fileToUpload)
|
||||||
orchestrator.upload(
|
orchestrator.upload(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = recording.mimeType,
|
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) {
|
} catch (e: CancellationException) {
|
||||||
Log.w("VoiceReplyViewModel", "User canceled, or ViewModel cleared", e)
|
Log.w("VoiceReplyViewModel", "User canceled, or ViewModel cleared", e)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -192,6 +226,7 @@ class VoiceReplyViewModel : ViewModel() {
|
|||||||
private suspend fun handleUploadResult(
|
private suspend fun handleUploadResult(
|
||||||
note: Note,
|
note: Note,
|
||||||
recording: RecordingResult,
|
recording: RecordingResult,
|
||||||
|
waveform: List<Float>,
|
||||||
server: ServerName,
|
server: ServerName,
|
||||||
result: UploadingState,
|
result: UploadingState,
|
||||||
onSuccess: () -> Unit,
|
onSuccess: () -> Unit,
|
||||||
@@ -211,7 +246,7 @@ class VoiceReplyViewModel : ViewModel() {
|
|||||||
mimeType = recording.mimeType,
|
mimeType = recording.mimeType,
|
||||||
hash = orchestratorResult.fileHeader.hash,
|
hash = orchestratorResult.fileHeader.hash,
|
||||||
duration = recording.duration,
|
duration = recording.duration,
|
||||||
waveform = recording.amplitudes,
|
waveform = waveform,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check if replying to a voice event
|
// Check if replying to a voice event
|
||||||
@@ -244,6 +279,7 @@ class VoiceReplyViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteVoiceLocalFile()
|
deleteVoiceLocalFile()
|
||||||
|
voiceAnonymization.deleteDistortedFiles()
|
||||||
voiceLocalFile = null
|
voiceLocalFile = null
|
||||||
voiceRecording = null
|
voiceRecording = null
|
||||||
voiceMetadata = audioMeta
|
voiceMetadata = audioMeta
|
||||||
@@ -266,6 +302,7 @@ class VoiceReplyViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun cancel() {
|
fun cancel() {
|
||||||
cancelUpload()
|
cancelUpload()
|
||||||
|
voiceAnonymization.clear()
|
||||||
deleteVoiceLocalFile()
|
deleteVoiceLocalFile()
|
||||||
voiceRecording = null
|
voiceRecording = null
|
||||||
voiceLocalFile = null
|
voiceLocalFile = null
|
||||||
|
|||||||
@@ -1228,4 +1228,10 @@
|
|||||||
<string name="follow_set_delete">Smazat seznam</string>
|
<string name="follow_set_delete">Smazat seznam</string>
|
||||||
<string name="follow_pack_delete">Smazat sadu doporučení</string>
|
<string name="follow_pack_delete">Smazat sadu doporučení</string>
|
||||||
<string name="kinds">Typy</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>
|
</resources>
|
||||||
|
|||||||
@@ -1233,4 +1233,10 @@ anz der Bedingungen ist erforderlich</string>
|
|||||||
<string name="follow_set_delete">Liste löschen</string>
|
<string name="follow_set_delete">Liste löschen</string>
|
||||||
<string name="follow_pack_delete">Empfehlungspaket löschen</string>
|
<string name="follow_pack_delete">Empfehlungspaket löschen</string>
|
||||||
<string name="kinds">Typen</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>
|
</resources>
|
||||||
|
|||||||
@@ -1228,4 +1228,10 @@
|
|||||||
<string name="follow_set_delete">Excluir lista</string>
|
<string name="follow_set_delete">Excluir lista</string>
|
||||||
<string name="follow_pack_delete">Excluir pacote de recomendações</string>
|
<string name="follow_pack_delete">Excluir pacote de recomendações</string>
|
||||||
<string name="kinds">Tipos</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>
|
</resources>
|
||||||
|
|||||||
@@ -1227,4 +1227,10 @@
|
|||||||
<string name="follow_set_delete">Ta bort lista</string>
|
<string name="follow_set_delete">Ta bort lista</string>
|
||||||
<string name="follow_pack_delete">Ta bort rekommendationspaket</string>
|
<string name="follow_pack_delete">Ta bort rekommendationspaket</string>
|
||||||
<string name="kinds">Typer</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>
|
</resources>
|
||||||
|
|||||||
@@ -184,6 +184,12 @@
|
|||||||
<string name="upload_error_voice_message_unexpected_state">Unexpected upload state</string>
|
<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_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="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="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="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>
|
<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"
|
secp256k1KmpJniAndroid = "0.22.0"
|
||||||
securityCryptoKtx = "1.1.0"
|
securityCryptoKtx = "1.1.0"
|
||||||
spotless = "8.1.0"
|
spotless = "8.1.0"
|
||||||
|
tarsosdsp = "2.5"
|
||||||
torAndroid = "0.4.8.21.1"
|
torAndroid = "0.4.8.21.1"
|
||||||
translate = "17.0.3"
|
translate = "17.0.3"
|
||||||
unifiedpush = "3.0.10"
|
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-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-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" }
|
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" }
|
tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" }
|
||||||
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
||||||
url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" }
|
url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" }
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ dependencyResolutionManagement {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven { url = "https://jitpack.io" }
|
maven { url = "https://jitpack.io" }
|
||||||
maven { url = "https://raw.githubusercontent.com/guardianproject/gpmaven/master" }
|
maven { url = "https://raw.githubusercontent.com/guardianproject/gpmaven/master" }
|
||||||
|
maven { url = "https://mvn.0110.be/releases" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user