add VoiceAnonymizer for audio pitch/formant shifting
This commit is contained in:
+435
@@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||||
|
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.media.MediaMuxer
|
||||||
|
import android.util.Log
|
||||||
|
import be.tarsos.dsp.AudioDispatcher
|
||||||
|
import be.tarsos.dsp.AudioEvent
|
||||||
|
import be.tarsos.dsp.AudioProcessor
|
||||||
|
import be.tarsos.dsp.WaveformSimilarityBasedOverlapAdd
|
||||||
|
import be.tarsos.dsp.io.TarsosDSPAudioFloatConverter
|
||||||
|
import be.tarsos.dsp.io.TarsosDSPAudioFormat
|
||||||
|
import be.tarsos.dsp.resample.RateTransposer
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
data class AnonymizedResult(
|
||||||
|
val file: File,
|
||||||
|
val waveform: List<Float>,
|
||||||
|
val duration: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
class VoiceAnonymizer {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "VoiceAnonymizer"
|
||||||
|
private const val SAMPLE_RATE = 44100
|
||||||
|
private const val CHANNELS = 1
|
||||||
|
private const val BIT_RATE = 128000
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun anonymize(
|
||||||
|
inputFile: File,
|
||||||
|
preset: VoicePreset,
|
||||||
|
onProgress: (Float) -> Unit = {},
|
||||||
|
): Result<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()
|
||||||
|
extractor.setDataSource(inputFile.absolutePath)
|
||||||
|
|
||||||
|
var audioTrackIndex = -1
|
||||||
|
var format: MediaFormat? = null
|
||||||
|
for (i in 0 until extractor.trackCount) {
|
||||||
|
val trackFormat = extractor.getTrackFormat(i)
|
||||||
|
val mime = trackFormat.getString(MediaFormat.KEY_MIME)
|
||||||
|
if (mime?.startsWith("audio/") == true) {
|
||||||
|
audioTrackIndex = i
|
||||||
|
format = trackFormat
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioTrackIndex == -1 || format == null) {
|
||||||
|
extractor.release()
|
||||||
|
throw IllegalStateException("No audio track found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
extractor.selectTrack(audioTrackIndex)
|
||||||
|
val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm"
|
||||||
|
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||||
|
val durationUs = format.getLong(MediaFormat.KEY_DURATION)
|
||||||
|
val duration = (durationUs / 1_000_000).toInt()
|
||||||
|
|
||||||
|
val decoder = MediaCodec.createDecoderByType(mime)
|
||||||
|
decoder.configure(format, null, null, 0)
|
||||||
|
decoder.start()
|
||||||
|
|
||||||
|
val pcmSamples = mutableListOf<Float>()
|
||||||
|
val bufferInfo = MediaCodec.BufferInfo()
|
||||||
|
var inputDone = false
|
||||||
|
var outputDone = false
|
||||||
|
|
||||||
|
while (!outputDone && coroutineContext.isActive) {
|
||||||
|
if (!inputDone) {
|
||||||
|
val inputBufferIndex = decoder.dequeueInputBuffer(10000)
|
||||||
|
if (inputBufferIndex >= 0) {
|
||||||
|
val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
|
||||||
|
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
||||||
|
if (sampleSize < 0) {
|
||||||
|
decoder.queueInputBuffer(
|
||||||
|
inputBufferIndex,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||||
|
)
|
||||||
|
inputDone = true
|
||||||
|
} else {
|
||||||
|
val presentationTimeUs = extractor.sampleTime
|
||||||
|
decoder.queueInputBuffer(
|
||||||
|
inputBufferIndex,
|
||||||
|
0,
|
||||||
|
sampleSize,
|
||||||
|
presentationTimeUs,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
extractor.advance()
|
||||||
|
if (durationUs > 0) {
|
||||||
|
onProgress((presentationTimeUs.toFloat() / durationUs).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
|
||||||
|
if (outputBufferIndex >= 0) {
|
||||||
|
val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
|
||||||
|
val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer()
|
||||||
|
while (shortBuffer.hasRemaining()) {
|
||||||
|
pcmSamples.add(shortBuffer.get() / 32768f)
|
||||||
|
}
|
||||||
|
decoder.releaseOutputBuffer(outputBufferIndex, false)
|
||||||
|
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||||
|
outputDone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder.stop()
|
||||||
|
decoder.release()
|
||||||
|
extractor.release()
|
||||||
|
|
||||||
|
return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processPcmWithTarsos(
|
||||||
|
pcmData: FloatArray,
|
||||||
|
preset: VoicePreset,
|
||||||
|
sampleRate: Int,
|
||||||
|
onProgress: (Float) -> Unit,
|
||||||
|
): FloatArray {
|
||||||
|
val factor = preset.pitchFactor
|
||||||
|
val processedSamples = mutableListOf<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)
|
||||||
|
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
encoder.start()
|
||||||
|
|
||||||
|
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||||
|
var audioTrackIndex = -1
|
||||||
|
var muxerStarted = false
|
||||||
|
|
||||||
|
val bufferInfo = MediaCodec.BufferInfo()
|
||||||
|
var inputOffset = 0
|
||||||
|
var inputDone = false
|
||||||
|
var outputDone = false
|
||||||
|
val totalSamples = pcmData.size
|
||||||
|
|
||||||
|
while (!outputDone) {
|
||||||
|
if (!inputDone) {
|
||||||
|
val inputBufferIndex = encoder.dequeueInputBuffer(10000)
|
||||||
|
if (inputBufferIndex >= 0) {
|
||||||
|
val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
|
||||||
|
inputBuffer.clear()
|
||||||
|
|
||||||
|
val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset)
|
||||||
|
if (samplesToWrite <= 0) {
|
||||||
|
encoder.queueInputBuffer(
|
||||||
|
inputBufferIndex,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||||
|
)
|
||||||
|
inputDone = true
|
||||||
|
} else {
|
||||||
|
for (i in 0 until samplesToWrite) {
|
||||||
|
val sample =
|
||||||
|
(pcmData[inputOffset + i] * 32767)
|
||||||
|
.toInt()
|
||||||
|
.coerceIn(-32768, 32767)
|
||||||
|
.toShort()
|
||||||
|
inputBuffer.putShort(sample)
|
||||||
|
}
|
||||||
|
val presentationTimeUs = (inputOffset * 1_000_000L) / sampleRate
|
||||||
|
encoder.queueInputBuffer(
|
||||||
|
inputBufferIndex,
|
||||||
|
0,
|
||||||
|
inputBuffer.position(),
|
||||||
|
presentationTimeUs,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
inputOffset += samplesToWrite
|
||||||
|
onProgress(inputOffset.toFloat() / totalSamples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000)
|
||||||
|
when {
|
||||||
|
outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||||
|
audioTrackIndex = muxer.addTrack(encoder.outputFormat)
|
||||||
|
muxer.start()
|
||||||
|
muxerStarted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
outputBufferIndex >= 0 -> {
|
||||||
|
val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!!
|
||||||
|
if (muxerStarted && bufferInfo.size > 0) {
|
||||||
|
outputBuffer.position(bufferInfo.offset)
|
||||||
|
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
|
||||||
|
muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo)
|
||||||
|
}
|
||||||
|
encoder.releaseOutputBuffer(outputBufferIndex, false)
|
||||||
|
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||||
|
outputDone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder.stop()
|
||||||
|
encoder.release()
|
||||||
|
muxer.stop()
|
||||||
|
muxer.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FloatArrayAudioInputStream(
|
||||||
|
private val floatArray: FloatArray,
|
||||||
|
private val format: TarsosDSPAudioFormat,
|
||||||
|
private val frameLength: Long,
|
||||||
|
) : be.tarsos.dsp.io.TarsosDSPAudioInputStream {
|
||||||
|
private var position = 0
|
||||||
|
|
||||||
|
override fun getFormat(): TarsosDSPAudioFormat = format
|
||||||
|
|
||||||
|
override fun getFrameLength(): Long = frameLength
|
||||||
|
|
||||||
|
override fun read(
|
||||||
|
buffer: ByteArray,
|
||||||
|
offset: Int,
|
||||||
|
length: Int,
|
||||||
|
): Int {
|
||||||
|
val converter = TarsosDSPAudioFloatConverter.getConverter(format)
|
||||||
|
val floatBuffer = FloatArray(length / 2)
|
||||||
|
val samplesToRead = minOf(floatBuffer.size, floatArray.size - position)
|
||||||
|
|
||||||
|
if (samplesToRead <= 0) return -1
|
||||||
|
|
||||||
|
System.arraycopy(floatArray, position, floatBuffer, 0, samplesToRead)
|
||||||
|
position += samplesToRead
|
||||||
|
|
||||||
|
converter.toByteArray(floatBuffer, samplesToRead, buffer, offset)
|
||||||
|
return samplesToRead * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun skip(bytesToSkip: Long): Long {
|
||||||
|
val samplesToSkip = (bytesToSkip / 2).toInt()
|
||||||
|
val actualSkip = minOf(samplesToSkip, floatArray.size - position)
|
||||||
|
position += actualSkip
|
||||||
|
return actualSkip.toLong() * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user