Coverage Summary for Class: MultiChannelResampler (com.vsevolodganin.clicktrack.utils.resampler)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| MultiChannelResampler |
0%
(0/8)
|
0%
(0/35)
|
0%
(0/63)
|
1.2%
(4/331)
|
| MultiChannelResampler$Companion |
|
| MultiChannelResampler$Quality |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/36)
|
| MultiChannelResampler$WhenMappings |
|
| Total |
0%
(0/9)
|
0%
(0/35)
|
0%
(0/64)
|
1.1%
(4/367)
|
package com.vsevolodganin.clicktrack.utils.resampler
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cosh
import kotlin.math.floor
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Oboe's [MultiChannelResampler](https://github.com/google/oboe/blob/8a0b08994c54bec3d1bbbe3c82be6d661fa26ea1/src/flowgraph/resampler/MultiChannelResampler.h#L41)
* rewritten in Kotlin with AI.
*/
class MultiChannelResampler(
val channelCount: Int,
val inputRate: Int,
val outputRate: Int,
val numTaps: Int,
val normalizedCutoff: Float = DEFAULT_NORMALIZED_CUTOFF,
) {
constructor(
channelCount: Int,
inputRate: Int,
outputRate: Int,
quality: Quality,
normalizedCutoff: Float = DEFAULT_NORMALIZED_CUTOFF,
) : this(
channelCount = channelCount,
inputRate = inputRate,
outputRate = outputRate,
numTaps = when (quality) {
Quality.Fastest -> 2
Quality.Low -> 4
Quality.Medium -> 8
Quality.High -> 16
Quality.Best -> 32
},
normalizedCutoff = normalizedCutoff,
)
enum class Quality { Fastest, Low, Medium, High, Best }
/**
* Resample interleaved float PCM.
* @param input Interleaved float samples with [channelCount] channels.
* @return New interleaved buffer at [outputRate].
*/
fun resample(input: FloatArray): FloatArray {
if (input.isEmpty()) return FloatArray(0)
val framesIn = input.size / channelCount
if (framesIn == 0) return FloatArray(0)
// Estimate output frames and allocate.
val framesOut = ((framesIn.toLong() * outputRate) / inputRate).toInt()
val output = FloatArray(framesOut * channelCount)
// Downsampling requires low-pass filtering; upsampling can set cutoff=1.0.
val cutoffScaler = if (outputRate < inputRate) normalizedCutoff * (outputRate.toFloat() / inputRate.toFloat()) else 1.0f
val halfTaps = numTaps / 2
val tapsRadius = halfTaps // symmetric window radius
// For each output frame, compute source time in input sample domain.
// Use double precision for accumulator to reduce drift.
val rateRatio = inputRate.toDouble() / outputRate.toDouble()
var outIndex = 0
for (n in 0 until framesOut) {
val t = n * rateRatio // fractional input frame position
val tFloor = floor(t).toInt()
// For each channel, accumulate windowed sinc
for (ch in 0 until channelCount) {
var num = 0.0
var den = 0.0
// Window from -tapsRadius+1 .. +tapsRadius inclusive to keep numTaps terms
val start = tFloor - tapsRadius + 1
val end = tFloor + tapsRadius
for (i in start..end) {
val x = t - i // fractional distance
val w = sinc(piTimes(x) * cutoffScaler) * hyperbolicCosineWindow(normalizeWindowArg(x, tapsRadius), HC_ALPHA)
den += w
val idxFrame = i
if (idxFrame in 0 until framesIn) {
val sample = input[idxFrame * channelCount + ch]
num += w * sample
}
}
val value = if (abs(den) > 1e-12) (num / den).toFloat() else 0f
output[outIndex + ch] = value
}
outIndex += channelCount
}
return output
}
private fun sinc(x: Double): Double {
val ax = abs(x)
return if (ax < 1.0e-9) 1.0 else sin(x) / x
}
private fun piTimes(x: Double): Double = PI * x
// Hyperbolic-cosine window approximation; x in [-1, 1].
private fun hyperbolicCosineWindow(x: Double, alpha: Double): Double {
val x2 = x * x
if (x2 >= 1.0) return 0.0
val w = alpha * sqrt(1.0 - x2)
val invCoshAlpha = 1.0 / cosh(alpha)
return cosh(w) * invCoshAlpha
}
// Map distance to [-1, 1] across the taps radius, clamped.
private fun normalizeWindowArg(distance: Double, radius: Int): Double {
val x = distance / radius
return when {
x < -1.0 -> -1.0
x > 1.0 -> 1.0
else -> x
}
}
companion object {
const val DEFAULT_NORMALIZED_CUTOFF: Float = 0.70f
// Empirical alpha producing ~60 dB attenuation used by the native approximation by default
// via setStopBandAttenuation(60).
private const val DEFAULT_ATTENUATION_DB: Double = 60.0
private val HC_ALPHA: Double = run {
// Port of setStopBandAttenuation(): alpha = (-325.1e-6*A + 0.1677)*A - 3.149
@Suppress("ktlint:standard:property-naming")
val A = DEFAULT_ATTENUATION_DB
((-325.1e-6 * A + 0.1677) * A) - 3.149
}
}
}