Skip to content

ResonatorBank.kt

src/jvmMain/kotlin/afield/audio/engine/ResonatorBank.kt · 854 lines

@file:Suppress("MemberVisibilityCanBePrivate", "unused")

package afield.audio.engine

Vectorized resonator banks for spectral analysis without an FFT: ResonatorBank is a fixed-frequency EMA resonator bank, and TrackingResonatorBank retunes each resonator toward the frequency it hears.

Both store resonator state as split-complex arrays and use the JVM incubator Vector API for the linear passes. resonate and trackingResonate run a bank over a whole signal, and the resonatorAlphaHeuristic and trackingResonatorGammaHeuristic functions choose smoothing coefficients from frequency and sample rate.

import jdk.incubator.vector.FloatVector
import jdk.incubator.vector.VectorOperators
import jdk.incubator.vector.VectorSpecies
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt

private const val PI = 3.14159265358979323846f
private const val TWO_PI = 2.0f * PI
private const val MIN_TRACKING_MAX_POWER = 0.001f

fun resonatorAlphaHeuristic

Resonator EMA coefficient heuristic from Oscillators/noFFT.

fun resonatorAlphaHeuristic(
    frequency: Float,
    sampleRate: Float,
    k: Float = 1.0f,
    n: Float = 1.0f
): Float {
    require(frequency >= 0.0f) { "frequency must be >= 0" }
    require(sampleRate > 0.0f) { "sampleRate must be > 0" }
    require(k > 0.0f) { "k must be > 0" }
    val denominator = sampleRate * k * log10((1.0f + frequency).toDouble()).pow(n.toDouble()).toFloat()
    return if (denominator > 0.0f) {
        (1.0 - exp((-frequency / denominator).toDouble())).toFloat()
    } else {
        1.0f
    }
}

fun resonatorAlphaHeuristics

fun resonatorAlphaHeuristics(
    frequencies: FloatArray,
    sampleRate: Float,
    k: Float = 1.0f,
    n: Float = 1.0f
): FloatArray = FloatArray(frequencies.size) { resonatorAlphaHeuristic(frequencies[it], sampleRate, k, n) }

fun trackingResonatorGammaHeuristic

fun trackingResonatorGammaHeuristic(
    frequency: Float,
    sampleRate: Float,
    k: Float = 1.0f,
    n: Float = 1.0f
): Float = resonatorAlphaHeuristic(frequency, sampleRate, k, n) * 0.5f

fun trackingResonatorGammaHeuristics

fun trackingResonatorGammaHeuristics(
    frequencies: FloatArray,
    sampleRate: Float,
    k: Float = 1.0f,
    n: Float = 1.0f
): FloatArray = FloatArray(frequencies.size) { trackingResonatorGammaHeuristic(frequencies[it], sampleRate, k, n) }

class ResonatorBank

Kotlin port of the "resonate" EMA resonator bank from noFFT.

Split-complex layout: - Real parts: [0, numResonators) - Imag parts: [numResonators, 2 * numResonators)

class ResonatorBank(
    private val numResonators: Int,
    frequencies: FloatArray,
    alphas: FloatArray,
    betas: FloatArray,
    private val sampleRate: Float
) {
    private val twoNumResonators = 2 * numResonators

    private val frequencies = FloatArray(numResonators)
    private val alphas = FloatArray(twoNumResonators)
    private val omAlphas = FloatArray(twoNumResonators)
    private val betas = FloatArray(twoNumResonators)
    private val omBetas = FloatArray(twoNumResonators)

    private val rPtr = FloatArray(twoNumResonators)
    private val rrPtr = FloatArray(twoNumResonators)
    private val zPtr = FloatArray(twoNumResonators)
    private val wPtr = FloatArray(twoNumResonators)

    private val alphasSample = FloatArray(twoNumResonators)
    private val smPtr = FloatArray(numResonators)
    private val rsqrtPtr = FloatArray(numResonators)

init

    init {
        require(frequencies.size == numResonators) { "frequencies size must be numResonators" }
        require(alphas.size == numResonators) { "alphas size must be numResonators" }
        require(betas.size == numResonators) { "betas size must be numResonators" }

        // initialize from passed frequencies
        System.arraycopy(frequencies, 0, this.frequencies, 0, numResonators)

        // duplicate alphas and betas for split complex
        System.arraycopy(alphas, 0, this.alphas, 0, numResonators)
        System.arraycopy(alphas, 0, this.alphas, numResonators, numResonators)
        System.arraycopy(betas, 0, this.betas, 0, numResonators)
        System.arraycopy(betas, 0, this.betas, numResonators, numResonators)

        // omAlphas = 1 - alphas, omBetas = 1 - betas
        for (i in 0 until twoNumResonators) {
            omAlphas[i] = 1.0f - this.alphas[i]
            omBetas[i] = 1.0f - this.betas[i]
        }

        // z starts at 1 + 0i for each resonator
        for (i in 0 until numResonators) {
            zPtr[i] = 1.0f
            zPtr[i + numResonators] = 0.0f
        }

        // w = exp(-i * 2pi * f / sr) -> cos/sin of -2pi f / sr
        val twoPiOverSampleRate = -TWO_PI / sampleRate
        for (i in 0 until numResonators) {
            val phase = twoPiOverSampleRate * this.frequencies[i]
            wPtr[i] = cos(phase)
            wPtr[i + numResonators] = sin(phase)
        }
    }

fun frequencyValue(index: Int): Float

    fun frequencyValue(index: Int): Float {
        require(index in 0 until numResonators) { "Bad index passed to frequencyValue()" }
        return frequencies[index]
    }

fun alphaValue(index: Int): Float

    fun alphaValue(index: Int): Float {
        require(index in 0 until numResonators) { "Bad index passed to alphaValue()" }
        return alphas[index]
    }

fun timeConstantValue(index: Int): Float

    fun timeConstantValue(index: Int): Float {
        require(index in 0 until numResonators) { "Bad index passed to timeConstantValue()" }
        val alpha = alphas[index]
        return if (alpha > 0.0f) 1.0f / (sampleRate * alpha) else 0.0f
    }

fun getComplex(dest: FloatArray, offset: Int = 0)

    fun getComplex(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= twoNumResonators) {
            "Buffer passed to getComplex() is not large enough"
        }
        System.arraycopy(rrPtr, 0, dest, offset, twoNumResonators)
    }

fun getPowers(dest: FloatArray, offset: Int = 0)

    fun getPowers(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= numResonators) {
            "Buffer passed to getPowers() is not large enough"
        }
        for (i in 0 until numResonators) {
            val re = rrPtr[i]
            val im = rrPtr[i + numResonators]
            dest[offset + i] = re * re + im * im
        }
    }

fun getAmplitudes(dest: FloatArray, offset: Int = 0)

    fun getAmplitudes(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= numResonators) {
            "Buffer passed to getAmplitudes() is not large enough"
        }
        for (i in 0 until numResonators) {
            val re = rrPtr[i]
            val im = rrPtr[i + numResonators]
            dest[offset + i] = sqrt(re * re + im * im)
        }
    }

fun update(sample: Float)

    fun update(sample: Float) {
        // alphasSample = alphas * sample
        for (i in 0 until twoNumResonators) {
            alphasSample[i] = alphas[i] * sample
        }

        // r = r * (1 - alpha) + z * (alpha * sample)
        for (i in 0 until twoNumResonators) {
            rPtr[i] = rPtr[i] * omAlphas[i] + zPtr[i] * alphasSample[i]
        }

        // rr = rr * (1 - beta) + r * beta
        for (i in 0 until twoNumResonators) {
            rrPtr[i] = rrPtr[i] * omBetas[i] + rPtr[i] * betas[i]
        }

        // phasor update: z = z * w (complex multiply)
        for (i in 0 until numResonators) {
            val zRe = zPtr[i]
            val zIm = zPtr[i + numResonators]
            val wRe = wPtr[i]
            val wIm = wPtr[i + numResonators]
            zPtr[i] = zRe * wRe - zIm * wIm
            zPtr[i + numResonators] = zRe * wIm + zIm * wRe
        }
    }

fun update(samples: FloatArray)

    fun update(samples: FloatArray) {
        for (sample in samples) {
            update(sample)
        }
        stabilize()
    }

fun update(frameData: FloatArray, frameLength: Int, sampleStride: Int)

    fun update(frameData: FloatArray, frameLength: Int, sampleStride: Int) {
        update(frameData, 0, frameLength, sampleStride)
    }

fun update(frameData: FloatArray, frameOffset: Int, frameLength: Int, sampleStride: Int)

    fun update(frameData: FloatArray, frameOffset: Int, frameLength: Int, sampleStride: Int) {
        var i = 0
        var idx = frameOffset
        while (i < frameLength) {
            update(frameData[idx])
            i += sampleStride
            idx += sampleStride
        }
        stabilize()
    }

fun update

    fun update(
        frameData: FloatArray,
        frameLength: Int,
        sampleStride: Int,
        powers: FloatArray?,
        amplitudes: FloatArray?
    ) {
        update(frameData, 0, frameLength, sampleStride, powers, amplitudes)
    }

fun update

    fun update(
        frameData: FloatArray,
        frameOffset: Int,
        frameLength: Int,
        sampleStride: Int,
        powers: FloatArray?,
        amplitudes: FloatArray?
    ) {
        var i = 0
        var idx = frameOffset
        while (i < frameLength) {
            update(frameData[idx])
            i += sampleStride
            idx += sampleStride
        }
        stabilize()
        // Mirrors C++ behavior: powers/amplitudes not filled here.
        if (powers != null || amplitudes != null) {
            // Intentionally left blank to preserve algorithm parity.
        }
    }

fun stabilize()

    fun stabilize() {
        for (i in 0 until numResonators) {
            val re = zPtr[i]
            val im = zPtr[i + numResonators]
            smPtr[i] = re * re + im * im
        }
        for (i in 0 until numResonators) {
            val inv = if (smPtr[i] > 0.0f) 1.0f / sqrt(smPtr[i]) else 0.0f
            rsqrtPtr[i] = inv
            zPtr[i] *= inv
            zPtr[i + numResonators] *= inv
        }
    }
}

class TrackingResonatorBank

A phase-tracking resonator bank.

Each resonator starts at a natural frequency, estimates the phase drift of the smoothed resonator output, and retunes its phasor toward the instantaneous frequency when its local power is above a dynamic threshold. Below threshold it returns to its natural frequency.

The storage is the same split-complex SoA layout as ResonatorBank. The linear EMA, power, reset, and stabilization passes are vectorized with the JVM incubator Vector API; per-resonator atan2/cos/sin stays scalar.

class TrackingResonatorBank(
    private val numResonators: Int,
    naturalFrequencies: FloatArray,
    alphas: FloatArray,
    betas: FloatArray = alphas,
    private val sampleRate: Float,
    gammas: FloatArray = trackingResonatorGammaHeuristics(naturalFrequencies, sampleRate)
) {
    private val twoNumResonators = 2 * numResonators

    private val naturalFrequencies = FloatArray(numResonators)
    private val naturalOmegas = FloatArray(numResonators)
    private val omegas = FloatArray(numResonators)

    private val alphas = FloatArray(twoNumResonators)
    private val omAlphas = FloatArray(twoNumResonators)
    private val betas = FloatArray(twoNumResonators)
    private val omBetas = FloatArray(twoNumResonators)
    private val gammas = FloatArray(numResonators)

    private val rPtr = FloatArray(twoNumResonators)
    private val rrPtr = FloatArray(twoNumResonators)
    private val rrPreviousPtr = FloatArray(twoNumResonators)
    private val deltaPtr = FloatArray(twoNumResonators)
    private val zPtr = FloatArray(twoNumResonators)
    private val wPtr = FloatArray(twoNumResonators)

    private val powers = FloatArray(numResonators)
    private val smPtr = FloatArray(numResonators)
    private val rsqrtPtr = FloatArray(numResonators)

    private var sigma = 1.0f
    private var omSigma = 0.0f
    private var accPower = 0.0001f
    private var trackingPowerThreshold = MIN_TRACKING_MAX_POWER / 1000.0f

init

    init {
        require(numResonators >= 0) { "numResonators must be >= 0" }
        require(sampleRate > 0.0f) { "sampleRate must be > 0" }
        require(naturalFrequencies.size == numResonators) { "naturalFrequencies size must be numResonators" }
        require(alphas.size == numResonators) { "alphas size must be numResonators" }
        require(betas.size == numResonators) { "betas size must be numResonators" }
        require(gammas.size == numResonators) { "gammas size must be numResonators" }

        System.arraycopy(naturalFrequencies, 0, this.naturalFrequencies, 0, numResonators)

        System.arraycopy(alphas, 0, this.alphas, 0, numResonators)
        System.arraycopy(alphas, 0, this.alphas, numResonators, numResonators)
        System.arraycopy(betas, 0, this.betas, 0, numResonators)
        System.arraycopy(betas, 0, this.betas, numResonators, numResonators)
        System.arraycopy(gammas, 0, this.gammas, 0, numResonators)

        for (i in 0 until twoNumResonators) {
            require(this.alphas[i] in 0.0f..1.0f) { "alphas must be in [0, 1]" }
            require(this.betas[i] in 0.0f..1.0f) { "betas must be in [0, 1]" }
            omAlphas[i] = 1.0f - this.alphas[i]
            omBetas[i] = 1.0f - this.betas[i]
        }
        for (i in 0 until numResonators) {
            require(this.gammas[i] in 0.0f..1.0f) { "gammas must be in [0, 1]" }
        }

        reset()
    }

fun naturalFrequencyValue(index: Int): Float

    fun naturalFrequencyValue(index: Int): Float {
        require(index in 0 until numResonators) { "Bad index passed to naturalFrequencyValue()" }
        return naturalFrequencies[index]
    }

fun resonantFrequencyValue(index: Int): Float

    fun resonantFrequencyValue(index: Int): Float {
        require(index in 0 until numResonators) { "Bad index passed to resonantFrequencyValue()" }
        return -omegas[index] * sampleRate / TWO_PI
    }

fun instantaneousFrequencyValue(index: Int): Float

    fun instantaneousFrequencyValue(index: Int): Float {
        require(index in 0 until numResonators) { "Bad index passed to instantaneousFrequencyValue()" }
        return resonantFrequencyValue(index) + atan2(deltaPtr[index + numResonators], deltaPtr[index]) * sampleRate / TWO_PI
    }

fun alphaValue(index: Int): Float

    fun alphaValue(index: Int): Float {
        require(index in 0 until numResonators) { "Bad index passed to alphaValue()" }
        return alphas[index]
    }

fun betaValue(index: Int): Float

    fun betaValue(index: Int): Float {
        require(index in 0 until numResonators) { "Bad index passed to betaValue()" }
        return betas[index]
    }

fun gammaValue(index: Int): Float

    fun gammaValue(index: Int): Float {
        require(index in 0 until numResonators) { "Bad index passed to gammaValue()" }
        return gammas[index]
    }

fun timeConstantValue(index: Int): Float

    fun timeConstantValue(index: Int): Float {
        require(index in 0 until numResonators) { "Bad index passed to timeConstantValue()" }
        val alpha = alphas[index]
        return if (alpha > 0.0f) 1.0f / (sampleRate * alpha) else 0.0f
    }

fun trackingPowerThresholdValue(): Float = trackingPowerThreshold

fun accumulatedPowerValue(): Float = accPower

fun getNaturalFrequencies(dest: FloatArray, offset: Int = 0)

    fun getNaturalFrequencies(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= numResonators) {
            "Buffer passed to getNaturalFrequencies() is not large enough"
        }
        System.arraycopy(naturalFrequencies, 0, dest, offset, numResonators)
    }

fun getResonantFrequencies(dest: FloatArray, offset: Int = 0)

    fun getResonantFrequencies(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= numResonators) {
            "Buffer passed to getResonantFrequencies() is not large enough"
        }
        for (i in 0 until numResonators) {
            dest[offset + i] = -omegas[i] * sampleRate / TWO_PI
        }
    }

fun getInstantaneousFrequencies(dest: FloatArray, offset: Int = 0)

    fun getInstantaneousFrequencies(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= numResonators) {
            "Buffer passed to getInstantaneousFrequencies() is not large enough"
        }
        for (i in 0 until numResonators) {
            dest[offset + i] = -omegas[i] * sampleRate / TWO_PI +
                atan2(deltaPtr[i + numResonators], deltaPtr[i]) * sampleRate / TWO_PI
        }
    }

fun getComplex(dest: FloatArray, offset: Int = 0)

    fun getComplex(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= twoNumResonators) {
            "Buffer passed to getComplex() is not large enough"
        }
        System.arraycopy(rrPtr, 0, dest, offset, twoNumResonators)
    }

fun getPowers(dest: FloatArray, offset: Int = 0)

    fun getPowers(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= numResonators) {
            "Buffer passed to getPowers() is not large enough"
        }
        System.arraycopy(powers, 0, dest, offset, numResonators)
    }

fun getAmplitudes(dest: FloatArray, offset: Int = 0)

    fun getAmplitudes(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= numResonators) {
            "Buffer passed to getAmplitudes() is not large enough"
        }
        var i = 0
        val lanes = SP.length()
        while (i + lanes <= numResonators) {
            FloatVector.fromArray(SP, powers, i)
                .lanewise(VectorOperators.SQRT)
                .intoArray(dest, offset + i)
            i += lanes
        }
        while (i < numResonators) {
            dest[offset + i] = sqrt(powers[i])
            i++
        }
    }

fun getPhases(dest: FloatArray, offset: Int = 0)

    fun getPhases(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= numResonators) {
            "Buffer passed to getPhases() is not large enough"
        }
        for (i in 0 until numResonators) {
            dest[offset + i] = atan2(rrPtr[i + numResonators], rrPtr[i])
        }
    }

fun getDeltaPhases(dest: FloatArray, offset: Int = 0)

    fun getDeltaPhases(dest: FloatArray, offset: Int = 0) {
        require(dest.size - offset >= numResonators) {
            "Buffer passed to getDeltaPhases() is not large enough"
        }
        for (i in 0 until numResonators) {
            dest[offset + i] = atan2(deltaPtr[i + numResonators], deltaPtr[i])
        }
    }

fun update(sample: Float)

    fun update(sample: Float) {
        updateResonance(sample)
        updatePowers()
        updateTracking()
        updatePhasors()
    }

fun update(samples: FloatArray)

    fun update(samples: FloatArray) {
        for (sample in samples) {
            update(sample)
        }
        stabilize()
    }

fun update(frameData: FloatArray, frameLength: Int, sampleStride: Int)

    fun update(frameData: FloatArray, frameLength: Int, sampleStride: Int) {
        update(frameData, 0, frameLength, sampleStride)
    }

fun update(frameData: FloatArray, frameOffset: Int, frameLength: Int, sampleStride: Int)

    fun update(frameData: FloatArray, frameOffset: Int, frameLength: Int, sampleStride: Int) {
        require(sampleStride > 0) { "sampleStride must be > 0" }
        var i = 0
        var idx = frameOffset
        while (i < frameLength) {
            update(frameData[idx])
            i += sampleStride
            idx += sampleStride
        }
        stabilize()
    }

fun update

    fun update(
        frameData: FloatArray,
        frameLength: Int,
        sampleStride: Int,
        powers: FloatArray?,
        amplitudes: FloatArray?
    ) {
        update(frameData, 0, frameLength, sampleStride, powers, amplitudes)
    }

fun update

    fun update(
        frameData: FloatArray,
        frameOffset: Int,
        frameLength: Int,
        sampleStride: Int,
        powers: FloatArray?,
        amplitudes: FloatArray?
    ) {
        update(frameData, frameOffset, frameLength, sampleStride)
        powers?.let { getPowers(it) }
        amplitudes?.let { getAmplitudes(it) }
    }

fun setPowerTimeConstant(tau: Float = 1.0f)

    fun setPowerTimeConstant(tau: Float = 1.0f) {
        require(tau > 0.0f) { "tau must be > 0" }
        val sampleDuration = 1.0f / sampleRate
        sigma = (1.0 - exp((-sampleDuration / tau).toDouble())).toFloat()
        omSigma = 1.0f - sigma
    }

fun reset()

    fun reset() {
        java.util.Arrays.fill(rPtr, 0.0f)
        java.util.Arrays.fill(rrPtr, 0.0f)
        java.util.Arrays.fill(rrPreviousPtr, 0.0f)
        java.util.Arrays.fill(deltaPtr, 0.0f)
        java.util.Arrays.fill(powers, 0.0f)
        java.util.Arrays.fill(zPtr, 0.0f)

        val minusTwoPiOverSampleRate = -TWO_PI / sampleRate
        for (i in 0 until numResonators) {
            zPtr[i] = 1.0f
            zPtr[i + numResonators] = 0.0f
            val omega = minusTwoPiOverSampleRate * naturalFrequencies[i]
            naturalOmegas[i] = omega
            omegas[i] = omega
            wPtr[i] = cos(omega)
            wPtr[i + numResonators] = sin(omega)
        }
        accPower = 0.0001f
        trackingPowerThreshold = MIN_TRACKING_MAX_POWER / 1000.0f
    }

fun stabilize()

body — 122 lines, from line 549
    fun stabilize() {
        var i = 0
        val lanes = SP.length()
        while (i + lanes <= numResonators) {
            val zRe = FloatVector.fromArray(SP, zPtr, i)
            val zIm = FloatVector.fromArray(SP, zPtr, i + numResonators)
            val sm = zRe.mul(zRe).add(zIm.mul(zIm))
            sm.intoArray(smPtr, i)
            val inv = FloatVector.broadcast(SP, 1.0f).div(sm.lanewise(VectorOperators.SQRT))
            inv.intoArray(rsqrtPtr, i)
            zRe.mul(inv).intoArray(zPtr, i)
            zIm.mul(inv).intoArray(zPtr, i + numResonators)
            i += lanes
        }
        while (i < numResonators) {
            val re = zPtr[i]
            val im = zPtr[i + numResonators]
            smPtr[i] = re * re + im * im
            val inv = if (smPtr[i] > 0.0f) 1.0f / sqrt(smPtr[i]) else 0.0f
            rsqrtPtr[i] = inv
            zPtr[i] *= inv
            zPtr[i + numResonators] *= inv
            i++
        }
    }

    private fun updateResonance(sample: Float) {
        val lanes = SP.length()
        val vSample = FloatVector.broadcast(SP, sample)
        var i = 0
        while (i + lanes <= twoNumResonators) {
            val r = FloatVector.fromArray(SP, rPtr, i)
            val rr = FloatVector.fromArray(SP, rrPtr, i)
            val z = FloatVector.fromArray(SP, zPtr, i)
            val alpha = FloatVector.fromArray(SP, alphas, i)
            val omAlpha = FloatVector.fromArray(SP, omAlphas, i)
            val beta = FloatVector.fromArray(SP, betas, i)
            val omBeta = FloatVector.fromArray(SP, omBetas, i)

            val nextR = r.mul(omAlpha).add(z.mul(alpha).mul(vSample))
            val nextRr = rr.mul(omBeta).add(nextR.mul(beta))
            nextR.intoArray(rPtr, i)
            nextRr.intoArray(rrPtr, i)
            i += lanes
        }
        while (i < twoNumResonators) {
            rPtr[i] = rPtr[i] * omAlphas[i] + zPtr[i] * alphas[i] * sample
            rrPtr[i] = rrPtr[i] * omBetas[i] + rPtr[i] * betas[i]
            i++
        }
    }

    private fun updatePowers() {
        val lanes = SP.length()
        var i = 0
        while (i + lanes <= numResonators) {
            val re = FloatVector.fromArray(SP, rrPtr, i)
            val im = FloatVector.fromArray(SP, rrPtr, i + numResonators)
            re.mul(re).add(im.mul(im)).intoArray(powers, i)
            i += lanes
        }
        while (i < numResonators) {
            val re = rrPtr[i]
            val im = rrPtr[i + numResonators]
            powers[i] = re * re + im * im
            i++
        }

        var maxPower = 0.0f
        for (k in 0 until numResonators) {
            if (powers[k] > maxPower) maxPower = powers[k]
        }
        accPower = omSigma * accPower + sigma * maxPower
        trackingPowerThreshold = kotlin.math.max(MIN_TRACKING_MAX_POWER, accPower) / 1000.0f
    }

    private fun updateTracking() {
        for (i in 0 until numResonators) {
            val re = rrPtr[i]
            val im = rrPtr[i + numResonators]
            val previousRe = rrPreviousPtr[i]
            val previousIm = rrPreviousPtr[i + numResonators]

            val deltaRe = re * previousRe + im * previousIm
            val deltaIm = im * previousRe - re * previousIm
            deltaPtr[i] = deltaRe
            deltaPtr[i + numResonators] = deltaIm

            val trackedOmega = omegas[i] - gammas[i] * atan2(deltaIm, deltaRe)
            omegas[i] = if (powers[i] >= trackingPowerThreshold) trackedOmega else naturalOmegas[i]
        }
        System.arraycopy(rrPtr, 0, rrPreviousPtr, 0, twoNumResonators)
    }

    private fun updatePhasors() {
        for (i in 0 until numResonators) {
            val omega = omegas[i]
            wPtr[i] = cos(omega)
            wPtr[i + numResonators] = sin(omega)
        }

        val lanes = SP.length()
        var i = 0
        while (i + lanes <= numResonators) {
            val zRe = FloatVector.fromArray(SP, zPtr, i)
            val zIm = FloatVector.fromArray(SP, zPtr, i + numResonators)
            val wRe = FloatVector.fromArray(SP, wPtr, i)
            val wIm = FloatVector.fromArray(SP, wPtr, i + numResonators)
            zRe.mul(wRe).sub(zIm.mul(wIm)).intoArray(zPtr, i)
            zRe.mul(wIm).add(zIm.mul(wRe)).intoArray(zPtr, i + numResonators)
            i += lanes
        }
        while (i < numResonators) {
            val zRe = zPtr[i]
            val zIm = zPtr[i + numResonators]
            val wRe = wPtr[i]
            val wIm = wPtr[i + numResonators]
            zPtr[i] = zRe * wRe - zIm * wIm
            zPtr[i + numResonators] = zRe * wIm + zIm * wRe
            i++
        }
    }

companion object

    companion object {
        private val SP: VectorSpecies<Float> = FloatVector.SPECIES_PREFERRED

fun alphasHeuristic

        fun alphasHeuristic(
            frequencies: FloatArray,
            sampleRate: Float,
            k: Float = 1.0f,
            n: Float = 1.0f
        ): FloatArray = resonatorAlphaHeuristics(frequencies, sampleRate, k, n)

fun gammasHeuristic

        fun gammasHeuristic(
            frequencies: FloatArray,
            sampleRate: Float,
            k: Float = 1.0f,
            n: Float = 1.0f
        ): FloatArray = trackingResonatorGammaHeuristics(frequencies, sampleRate, k, n)
    }
}

fun trackingResonate

body — 50 lines, from line 691
fun trackingResonate(
    input: FloatArray,
    samplingRate: Float,
    naturalFrequencies: FloatArray,
    alphas: FloatArray,
    betas: FloatArray = alphas,
    gammas: FloatArray = trackingResonatorGammaHeuristics(naturalFrequencies, samplingRate),
    hopLength: Int,
    powerTimeConstant: Float? = null,
    progress: ((completed: Int, total: Int) -> Unit)? = null
): TrackingResonatorAnalysis {
    require(naturalFrequencies.size == alphas.size) { "Frequencies and alphas must have same size" }
    require(alphas.size == betas.size) { "Alphas and betas must have same size" }
    require(betas.size == gammas.size) { "Betas and gammas must have same size" }
    require(hopLength > 0) { "hopLength must be > 0" }

    val numSamples = input.size
    val numResonators = naturalFrequencies.size
    val twoNumResonators = 2 * numResonators
    val numSlices = numSamples / hopLength

    val complex = FloatArray(numSlices * twoNumResonators)
    val resonantFrequencies = FloatArray(numSlices * numResonators)
    val powers = FloatArray(numSlices * numResonators)
    val bank = TrackingResonatorBank(numResonators, naturalFrequencies, alphas, betas, samplingRate, gammas)
    powerTimeConstant?.let { bank.setPowerTimeConstant(it) }

    progress?.invoke(0, numSlices)
    for (idx in 0 until numSlices) {
        val inputOffset = idx * hopLength
        bank.update(input, inputOffset, hopLength, 1)
        bank.getComplex(complex, idx * twoNumResonators)
        bank.getResonantFrequencies(resonantFrequencies, idx * numResonators)
        bank.getPowers(powers, idx * numResonators)
        progress?.invoke(idx + 1, numSlices)
    }

    return TrackingResonatorAnalysis(
        sampleRate = samplingRate,
        hopLength = hopLength,
        sourceSampleCount = numSamples,
        naturalFrequencies = naturalFrequencies,
        complex = complex,
        resonantFrequencies = resonantFrequencies,
        powers = powers,
        alphas = alphas,
        betas = betas,
        gammas = gammas
    )
}

fun resonate

Port of resonate::resonate for offline analysis.

Returns a flat array where each hop slice contains: - numResonators real parts, then - numResonators imag parts

fun resonate(
    input: FloatArray,
    samplingRate: Float,
    freqs: FloatArray,
    alphas: FloatArray,
    betas: FloatArray,
    hopLength: Int
): FloatArray {
    require(freqs.size == alphas.size) { "Frequencies and alphas must have same size" }
    require(alphas.size == betas.size) { "Alphas and betas must have same size" }
    require(hopLength > 0) { "hopLength must be > 0" }

    val numSamples = input.size
    val numResonators = freqs.size
    val twoNumResonators = 2 * numResonators
    val numSlices = numSamples / hopLength

    val result = FloatArray(numSlices * twoNumResonators)
    val bank = ResonatorBank(numResonators, freqs, alphas, betas, samplingRate)

    for (idx in 0 until numSlices) {
        val inputOffset = idx * hopLength
        bank.update(input, inputOffset, hopLength, 1)
        val resultOffset = idx * twoNumResonators
        bank.getComplex(result, resultOffset)
    }

    return result
}

fun main()

Small standalone smoketest for ResonatorBank/resonate.

Run however you normally run JVM mains in this repo (this isn't wired as the app entrypoint).

body — 69 lines, from line 785
fun main() {
    val sampleRate = 48_000f
    val seconds = 1.5f
    val n = (seconds * sampleRate).toInt()

    // Two sines (plus a tiny bit of noise to avoid pathological symmetry).
    val f1 = 440f
    val f2 = 880f
    val input = FloatArray(n) { i ->
        val t = i / sampleRate
        val x = 0.7f * sin((2.0 * PI * f1 * t).toFloat()) + 0.35f * sin((2.0 * PI * f2 * t).toFloat())
        val dither = (i % 97 - 48) * (1e-6f)
        x + dither
    }

    val freqs = floatArrayOf(110f, 220f, 330f, 440f, 550f, 660f, 770f, 880f, 990f)

    // EMA coefficients; tweak these if you want more/less temporal smoothing.
    val alphas = FloatArray(freqs.size) { 0.0025f }
    val betas = FloatArray(freqs.size) { 0.02f }

    val hopLength = 256

    val result = resonate(
        input = input,
        samplingRate = sampleRate,
        freqs = freqs,
        alphas = alphas,
        betas = betas,
        hopLength = hopLength
    )

    val numRes = freqs.size
    val twoNumRes = 2 * numRes
    val numSlices = result.size / twoNumRes

    fun amplitudeAt(slice: Int, k: Int): Float {
        val base = slice * twoNumRes
        val re = result[base + k]
        val im = result[base + numRes + k]
        return sqrt(re * re + im * im)
    }

    // Inspect the last slice.
    val last = numSlices - 1
    var bestK = 0
    var bestA = -1f
    for (k in 0 until numRes) {
        val a = amplitudeAt(last, k)
        if (a > bestA) {
            bestA = a
            bestK = k
        }
    }

    println("resonate smoketest")
    println("sampleRate=$sampleRate, seconds=$seconds, hopLength=$hopLength, slices=$numSlices")
    println("peak @ ${freqs[bestK]} Hz : amplitude=$bestA")

    fun findFreqIndex(target: Float): Int {
        for (i in freqs.indices) if (freqs[i] == target) return i
        return -1
    }

    freqs.forEach {
        val i = findFreqIndex(it)
        println("$it = ${amplitudeAt(last, i)}")
    }
}