mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Replace GPLv3 TarsosDSP with in-house pitch shifter
This commit is contained in:
@@ -474,9 +474,6 @@ dependencies {
|
|||||||
// EXIF metadata stripping
|
// EXIF metadata stripping
|
||||||
implementation(libs.androidx.exifinterface)
|
implementation(libs.androidx.exifinterface)
|
||||||
|
|
||||||
// Voice anonymization DSP
|
|
||||||
implementation(libs.tarsosdsp)
|
|
||||||
|
|
||||||
// WebRTC for voice/video calls
|
// WebRTC for voice/video calls
|
||||||
implementation(libs.stream.webrtc.android)
|
implementation(libs.stream.webrtc.android)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* 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 kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-Kotlin pitch shifter for mono float PCM.
|
||||||
|
*
|
||||||
|
* Shifts pitch by a frequency [ratio][shift] while preserving the original
|
||||||
|
* duration, by combining two textbook DSP steps:
|
||||||
|
*
|
||||||
|
* 1. **WSOLA time-stretch** (Waveform Similarity Overlap-Add) stretches the
|
||||||
|
* signal in time by the pitch ratio without changing its pitch. Unlike plain
|
||||||
|
* overlap-add, WSOLA searches a small window around each analysis frame for
|
||||||
|
* the offset that best correlates with the natural continuation of the
|
||||||
|
* previous frame, which avoids the phase-discontinuity "warble" of OLA.
|
||||||
|
* 2. **Linear resampling** back to the original length raises (or lowers) the
|
||||||
|
* pitch by the same ratio and restores the duration.
|
||||||
|
*
|
||||||
|
* This is a clean-room implementation of the standard WSOLA algorithm; it
|
||||||
|
* carries no third-party code and keeps the app free of GPL-licensed DSP
|
||||||
|
* dependencies. Quality is more than sufficient for voice anonymization, whose
|
||||||
|
* pitch factors are modest (~0.7x–1.5x).
|
||||||
|
*/
|
||||||
|
class PitchShifter(
|
||||||
|
private val sampleRate: Int,
|
||||||
|
/** Analysis/synthesis window length in samples. ~46 ms at 44.1 kHz. */
|
||||||
|
private val frameSize: Int = 2048,
|
||||||
|
/** Half-radius (in samples) of the WSOLA cross-correlation search. */
|
||||||
|
private val seekWindow: Int = 256,
|
||||||
|
) {
|
||||||
|
private val synthesisHop = frameSize / 2
|
||||||
|
private val overlap = frameSize - synthesisHop
|
||||||
|
private val window = hannWindow(frameSize)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns [input] pitch-shifted by [frequencyRatio] with the same length.
|
||||||
|
*
|
||||||
|
* @param frequencyRatio output-to-input frequency multiplier. `> 1` raises
|
||||||
|
* the pitch, `< 1` lowers it, `1.0` is a no-op copy.
|
||||||
|
*/
|
||||||
|
fun shift(
|
||||||
|
input: FloatArray,
|
||||||
|
frequencyRatio: Double,
|
||||||
|
): FloatArray {
|
||||||
|
if (input.isEmpty()) return FloatArray(0)
|
||||||
|
if (frequencyRatio == 1.0) return input.copyOf()
|
||||||
|
|
||||||
|
// Stretch in time by the ratio (pitch unchanged, length scaled), then
|
||||||
|
// resample back to the original length (pitch scaled by the ratio).
|
||||||
|
val stretched = timeStretch(input, frequencyRatio)
|
||||||
|
return resampleLinear(stretched, input.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WSOLA time-stretch. Output length is approximately `input.size * stretch`.
|
||||||
|
*/
|
||||||
|
private fun timeStretch(
|
||||||
|
input: FloatArray,
|
||||||
|
stretch: Double,
|
||||||
|
): FloatArray {
|
||||||
|
if (input.size < frameSize + seekWindow) {
|
||||||
|
// Too short for the overlap machinery; resampling alone still yields
|
||||||
|
// the requested length change without crashing on tiny clips.
|
||||||
|
return resampleLinear(input, (input.size * stretch).roundToInt().coerceAtLeast(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
val analysisHop = synthesisHop / stretch
|
||||||
|
val outLength = (input.size * stretch).roundToInt() + frameSize
|
||||||
|
val out = FloatArray(outLength)
|
||||||
|
val norm = FloatArray(outLength)
|
||||||
|
|
||||||
|
// Integer start of the current analysis frame inside the input.
|
||||||
|
var analysisStart = 0
|
||||||
|
// Fractional nominal position; the next frame is sought around it.
|
||||||
|
var nominal = 0.0
|
||||||
|
var synthesisPos = 0
|
||||||
|
|
||||||
|
while (analysisStart + frameSize < input.size && synthesisPos + frameSize < outLength) {
|
||||||
|
// Overlap-add the windowed analysis frame at the synthesis position.
|
||||||
|
for (i in 0 until frameSize) {
|
||||||
|
val w = window[i]
|
||||||
|
out[synthesisPos + i] += input[analysisStart + i] * w
|
||||||
|
norm[synthesisPos + i] += w
|
||||||
|
}
|
||||||
|
|
||||||
|
synthesisPos += synthesisHop
|
||||||
|
nominal += analysisHop
|
||||||
|
|
||||||
|
// The samples that should naturally follow what we just wrote: the
|
||||||
|
// tail of the current frame advanced by one synthesis hop.
|
||||||
|
val naturalStart = analysisStart + synthesisHop
|
||||||
|
if (naturalStart + overlap >= input.size) break
|
||||||
|
|
||||||
|
// Search around the nominal next analysis position for the offset
|
||||||
|
// whose head best correlates with that natural continuation.
|
||||||
|
val center = nominal.roundToInt()
|
||||||
|
analysisStart = bestMatchOffset(input, naturalStart, center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize where windows overlapped to keep unity gain at the edges.
|
||||||
|
for (i in out.indices) {
|
||||||
|
if (norm[i] > 1e-6f) out[i] /= norm[i]
|
||||||
|
}
|
||||||
|
// Trim the padding tail to the expected stretched length.
|
||||||
|
val expected = (input.size * stretch).roundToInt()
|
||||||
|
return if (expected < out.size) out.copyOf(expected) else out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds, within ±[seekWindow] of [center], the input offset whose
|
||||||
|
* `overlap`-length head best cross-correlates with the `overlap`-length
|
||||||
|
* segment starting at [naturalStart]. Returns a bounds-safe offset.
|
||||||
|
*/
|
||||||
|
private fun bestMatchOffset(
|
||||||
|
input: FloatArray,
|
||||||
|
naturalStart: Int,
|
||||||
|
center: Int,
|
||||||
|
): Int {
|
||||||
|
val low = (center - seekWindow).coerceAtLeast(0)
|
||||||
|
val high = (center + seekWindow).coerceAtMost(input.size - frameSize - 1)
|
||||||
|
if (high <= low) return low.coerceIn(0, input.size - frameSize - 1)
|
||||||
|
|
||||||
|
var bestOffset = low
|
||||||
|
var bestCorr = Double.NEGATIVE_INFINITY
|
||||||
|
for (offset in low..high) {
|
||||||
|
var corr = 0.0
|
||||||
|
for (i in 0 until overlap) {
|
||||||
|
corr += input[naturalStart + i].toDouble() * input[offset + i]
|
||||||
|
}
|
||||||
|
if (corr > bestCorr) {
|
||||||
|
bestCorr = corr
|
||||||
|
bestOffset = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linear-interpolation resample of [input] to exactly [targetLength] samples. */
|
||||||
|
private fun resampleLinear(
|
||||||
|
input: FloatArray,
|
||||||
|
targetLength: Int,
|
||||||
|
): FloatArray {
|
||||||
|
if (targetLength <= 0 || input.isEmpty()) return FloatArray(0)
|
||||||
|
if (input.size == 1) return FloatArray(targetLength) { input[0] }
|
||||||
|
|
||||||
|
val out = FloatArray(targetLength)
|
||||||
|
val step = (input.size - 1).toDouble() / (targetLength - 1).coerceAtLeast(1)
|
||||||
|
for (i in 0 until targetLength) {
|
||||||
|
val pos = i * step
|
||||||
|
val idx = pos.toInt()
|
||||||
|
val frac = pos - idx
|
||||||
|
val a = input[idx]
|
||||||
|
val b = if (idx + 1 < input.size) input[idx + 1] else a
|
||||||
|
out[i] = (a + (b - a) * frac).toFloat()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hannWindow(size: Int): FloatArray =
|
||||||
|
FloatArray(size) { i ->
|
||||||
|
(0.5 * (1.0 - cos(2.0 * PI * i / (size - 1)))).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-122
@@ -25,13 +25,6 @@ import android.media.MediaCodecInfo
|
|||||||
import android.media.MediaExtractor
|
import android.media.MediaExtractor
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import android.media.MediaMuxer
|
import android.media.MediaMuxer
|
||||||
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 com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
@@ -57,9 +50,10 @@ data class AnonymizedResult(
|
|||||||
/**
|
/**
|
||||||
* Processes audio files to alter voice characteristics for privacy.
|
* Processes audio files to alter voice characteristics for privacy.
|
||||||
*
|
*
|
||||||
* Uses TarsosDSP's WSOLA (Waveform Similarity Overlap-Add) algorithm combined with
|
* Uses the in-house [PitchShifter] (WSOLA time-stretch + resampling) to shift pitch
|
||||||
* rate transposition to shift pitch while preserving duration. Note that in TarsosDSP,
|
* while preserving duration. Preset pitch factors work inversely: factor < 1 raises
|
||||||
* pitch factors work inversely: factor < 1 raises pitch, factor > 1 lowers pitch.
|
* pitch, factor > 1 lowers pitch — so the shifter receives the reciprocal as its
|
||||||
|
* output-to-input frequency ratio.
|
||||||
*/
|
*/
|
||||||
class VoiceAnonymizer {
|
class VoiceAnonymizer {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -73,7 +67,7 @@ class VoiceAnonymizer {
|
|||||||
*
|
*
|
||||||
* The process involves three stages:
|
* The process involves three stages:
|
||||||
* 1. Decode input audio to PCM (0-30% progress)
|
* 1. Decode input audio to PCM (0-30% progress)
|
||||||
* 2. Apply pitch shifting with TarsosDSP (30-70% progress)
|
* 2. Apply pitch shifting with [PitchShifter] (30-70% progress)
|
||||||
* 3. Encode processed audio to AAC (70-100% progress)
|
* 3. Encode processed audio to AAC (70-100% progress)
|
||||||
*
|
*
|
||||||
* @param inputFile Source audio file (supports formats decodable by MediaCodec)
|
* @param inputFile Source audio file (supports formats decodable by MediaCodec)
|
||||||
@@ -102,7 +96,7 @@ class VoiceAnonymizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val processedPcm =
|
val processedPcm =
|
||||||
processPcmWithTarsos(pcmData, preset, sampleRate) { progress ->
|
applyPitchShift(pcmData, preset, sampleRate) { progress ->
|
||||||
onProgress(0.3f + progress * 0.4f)
|
onProgress(0.3f + progress * 0.4f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +286,7 @@ class VoiceAnonymizer {
|
|||||||
release()
|
release()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processPcmWithTarsos(
|
private fun applyPitchShift(
|
||||||
pcmData: FloatArray,
|
pcmData: FloatArray,
|
||||||
preset: VoicePreset,
|
preset: VoicePreset,
|
||||||
sampleRate: Int,
|
sampleRate: Int,
|
||||||
@@ -311,75 +305,13 @@ class VoiceAnonymizer {
|
|||||||
baseFactor
|
baseFactor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val totalSamples = pcmData.size
|
|
||||||
val processedSamples = ArrayList<Float>(totalSamples)
|
|
||||||
|
|
||||||
val wsola =
|
// Presets express factor inversely (factor > 1 lowers pitch), so the
|
||||||
WaveformSimilarityBasedOverlapAdd(
|
// output-to-input frequency ratio is the reciprocal.
|
||||||
WaveformSimilarityBasedOverlapAdd.Parameters.musicDefaults(
|
val frequencyRatio = 1.0 / factor
|
||||||
factor,
|
val shifted = PitchShifter(sampleRate).shift(pcmData, frequencyRatio)
|
||||||
sampleRate.toDouble(),
|
onProgress(1f)
|
||||||
),
|
return shifted
|
||||||
)
|
|
||||||
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() {
|
|
||||||
// No-op: no cleanup needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
// No-op: no cleanup needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dispatcher.addAudioProcessor(progressProcessor)
|
|
||||||
|
|
||||||
dispatcher.run()
|
|
||||||
|
|
||||||
return processedSamples.toFloatArray()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractWaveform(
|
private fun extractWaveform(
|
||||||
@@ -559,44 +491,3 @@ class VoiceAnonymizer {
|
|||||||
release()
|
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() {
|
|
||||||
// No-op: no cleanup needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* 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 org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
class PitchShifterTest {
|
||||||
|
private val sampleRate = 44100
|
||||||
|
|
||||||
|
/** One second of a clean mono sine at [freq] Hz, amplitude 0.8. */
|
||||||
|
private fun sine(
|
||||||
|
freq: Double,
|
||||||
|
seconds: Double = 1.0,
|
||||||
|
): FloatArray {
|
||||||
|
val n = (sampleRate * seconds).toInt()
|
||||||
|
return FloatArray(n) { i -> (0.8 * sin(2.0 * PI * freq * i / sampleRate)).toFloat() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimates the fundamental frequency of a (near-)periodic signal via
|
||||||
|
* autocorrelation over a stable middle window. Dependency-free and robust
|
||||||
|
* for sine inputs.
|
||||||
|
*/
|
||||||
|
private fun estimateFrequency(signal: FloatArray): Double {
|
||||||
|
// Use the central 60% to avoid edge ramp-up/ramp-down artifacts.
|
||||||
|
val start = (signal.size * 0.2).toInt()
|
||||||
|
val end = (signal.size * 0.8).toInt()
|
||||||
|
val chunk = signal.copyOfRange(start, end)
|
||||||
|
|
||||||
|
val minFreq = 50.0
|
||||||
|
val maxFreq = 2000.0
|
||||||
|
val minLag = (sampleRate / maxFreq).toInt()
|
||||||
|
val maxLag = (sampleRate / minFreq).toInt().coerceAtMost(chunk.size - 1)
|
||||||
|
|
||||||
|
var bestLag = minLag
|
||||||
|
var bestCorr = Double.NEGATIVE_INFINITY
|
||||||
|
for (lag in minLag..maxLag) {
|
||||||
|
var corr = 0.0
|
||||||
|
for (i in 0 until chunk.size - lag) {
|
||||||
|
corr += chunk[i] * chunk[i + lag]
|
||||||
|
}
|
||||||
|
if (corr > bestCorr) {
|
||||||
|
bestCorr = corr
|
||||||
|
bestLag = lag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sampleRate.toDouble() / bestLag
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ratio of one preserves length`() {
|
||||||
|
val input = sine(440.0)
|
||||||
|
val output = PitchShifter(sampleRate).shift(input, 1.0)
|
||||||
|
// Duration must be preserved (within a frame of tolerance).
|
||||||
|
assertEquals(input.size.toDouble(), output.size.toDouble(), 4096.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shift up one octave roughly doubles the fundamental`() {
|
||||||
|
val output = PitchShifter(sampleRate).shift(sine(440.0), 2.0)
|
||||||
|
val measured = estimateFrequency(output)
|
||||||
|
assertEquals(880.0, measured, 880.0 * 0.06)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shift down one octave roughly halves the fundamental`() {
|
||||||
|
val output = PitchShifter(sampleRate).shift(sine(440.0), 0.5)
|
||||||
|
val measured = estimateFrequency(output)
|
||||||
|
assertEquals(220.0, measured, 220.0 * 0.06)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `preserves duration for the deep preset ratio`() {
|
||||||
|
// DEEP preset lowers pitch: frequencyRatio = 1 / 1.4.
|
||||||
|
val input = sine(300.0)
|
||||||
|
val output = PitchShifter(sampleRate).shift(input, 1.0 / 1.4)
|
||||||
|
assertEquals(input.size.toDouble(), output.size.toDouble(), 4096.0)
|
||||||
|
val measured = estimateFrequency(output)
|
||||||
|
assertEquals(300.0 / 1.4, measured, (300.0 / 1.4) * 0.06)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `preserves duration for the high preset ratio`() {
|
||||||
|
// HIGH preset raises pitch: frequencyRatio = 1 / 0.75.
|
||||||
|
val input = sine(300.0)
|
||||||
|
val output = PitchShifter(sampleRate).shift(input, 1.0 / 0.75)
|
||||||
|
assertEquals(input.size.toDouble(), output.size.toDouble(), 4096.0)
|
||||||
|
val measured = estimateFrequency(output)
|
||||||
|
assertEquals(300.0 / 0.75, measured, (300.0 / 0.75) * 0.06)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty input returns empty output`() {
|
||||||
|
val output = PitchShifter(sampleRate).shift(FloatArray(0), 1.5)
|
||||||
|
assertTrue(output.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,6 @@ securityCryptoKtx = "1.1.0"
|
|||||||
slf4j = "2.0.18"
|
slf4j = "2.0.18"
|
||||||
spotless = "8.6.0"
|
spotless = "8.6.0"
|
||||||
streamWebrtcAndroid = "1.3.10"
|
streamWebrtcAndroid = "1.3.10"
|
||||||
tarsosdsp = "2.5"
|
|
||||||
translate = "17.0.3"
|
translate = "17.0.3"
|
||||||
jetbrainsCompose = "1.11.0"
|
jetbrainsCompose = "1.11.0"
|
||||||
unifiedpush = "3.0.10"
|
unifiedpush = "3.0.10"
|
||||||
@@ -198,7 +197,6 @@ secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-km
|
|||||||
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" }
|
||||||
schnorr256k1-kmp = { group = "com.vitorpamplona.schnorr256k1", name = "schnorr256k1-kmp", version.ref = "schnorr256k1Kmp" }
|
schnorr256k1-kmp = { group = "com.vitorpamplona.schnorr256k1", name = "schnorr256k1-kmp", version.ref = "schnorr256k1Kmp" }
|
||||||
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
|
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
|
||||||
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
|
|
||||||
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
||||||
play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" }
|
play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" }
|
||||||
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
|
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ dependencyResolutionManagement {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven { url = uri("https://jitpack.io") }
|
maven { url = uri("https://jitpack.io") }
|
||||||
maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") }
|
maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") }
|
||||||
maven { url = uri("https://mvn.0110.be/releases") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user