Skip to content

OscillatorModels.kt

src/jvmMain/kotlin/afield/audio/engine/OscillatorModels.kt · 262 lines

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

package afield.audio.engine

Scalar, one-object-per-oscillator reference models: Phasor (a complex phasor advanced by multiplication), Oscillator, Resonator and TrackingResonator.

These are the per-sample building blocks that the array banks in OscillatorBankArrays.kt hold, and a readable counterpart to the vectorized banks in ResonatorBank.kt.

import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.sin
import kotlin.math.sqrt

private const val OSC_TWO_PI = (2.0 * PI).toFloat()

class Phasor

Scalar complex phasor reference implementation.

This mirrors the Oscillators README building block: z = z * w, where the real and imaginary parts provide cosine and sine waveforms.

class Phasor(
    frequency: Float,
    private val sampleRate: Float,
    initialPhaseRadians: Float = 0.0f,
    private val direction: Float = 1.0f
) {
    private var omega = 0.0f
    private var wRe = 1.0f
    private var wIm = 0.0f

var frequency: Float

    var frequency: Float = frequency
        private set

var real: Float

    var real: Float = cos(initialPhaseRadians)
        private set

var imag: Float

    var imag: Float = sin(initialPhaseRadians)
        private set

val cosine: Float

    val cosine: Float
        get() = real

val sine: Float

    val sine: Float
        get() = imag

init

    init {
        require(sampleRate > 0.0f) { "sampleRate must be > 0" }
        require(direction == 1.0f || direction == -1.0f) { "direction must be +1 or -1" }
        setFrequency(frequency)
    }

fun setFrequency(frequency: Float)

    fun setFrequency(frequency: Float) {
        require(frequency >= 0.0f) { "frequency must be >= 0" }
        this.frequency = frequency
        omega = direction * OSC_TWO_PI * frequency / sampleRate
        wRe = cos(omega)
        wIm = sin(omega)
    }

fun update()

    fun update() {
        val nextRe = real * wRe - imag * wIm
        val nextIm = real * wIm + imag * wRe
        real = nextRe
        imag = nextIm
    }

fun stabilize()

    fun stabilize() {
        val mag = sqrt(real * real + imag * imag)
        if (mag > 0.0f) {
            real /= mag
            imag /= mag
        } else {
            real = 1.0f
            imag = 0.0f
        }
    }
}

class Oscillator

class Oscillator(
    frequency: Float,
    var amplitude: Float = 1.0f,
    sampleRate: Float,
    initialPhaseRadians: Float = 0.0f
) {

val phasor = Phasor(frequency, sampleRate, initialPhaseRadians)

var frequency: Float

    var frequency: Float
        get() = phasor.frequency
        set(value) = phasor.setFrequency(value)

fun updateCosine(): Float

    fun updateCosine(): Float {
        val out = amplitude * phasor.cosine
        phasor.update()
        return out
    }

fun updateSine(): Float

    fun updateSine(): Float {
        val out = amplitude * phasor.sine
        phasor.update()
        return out
    }

fun renderCosine(dest: FloatArray, offset: Int = 0, length: Int = dest.size - offset)

    fun renderCosine(dest: FloatArray, offset: Int = 0, length: Int = dest.size - offset) {
        require(offset >= 0 && length >= 0 && offset + length <= dest.size) { "bad render range" }
        for (i in 0 until length) {
            dest[offset + i] = updateCosine()
        }
    }
}

open class Resonator

Scalar resonator reference implementation.

Uses the same two-stage EWMA and split complex state as ResonatorBank, but as one object for readability and API parity with the upstream README.

open class Resonator(
    frequency: Float,
    val alpha: Float,
    val beta: Float = alpha,
    protected val sampleRate: Float
) {

protected val phasor = Phasor(frequency, sampleRate, direction = -1.0f)

protected var rRe = 0.0f

protected var rIm = 0.0f

protected var rrRe = 0.0f

protected var rrIm = 0.0f

val frequency: Float

    val frequency: Float
        get() = phasor.frequency

val real: Float

    val real: Float
        get() = rrRe

val imag: Float

    val imag: Float
        get() = rrIm

val power: Float

    val power: Float
        get() = rrRe * rrRe + rrIm * rrIm

val amplitude: Float

    val amplitude: Float
        get() = sqrt(power)

val phase: Float

    val phase: Float
        get() = atan2(rrIm, rrRe)

val timeConstant: Float

    val timeConstant: Float
        get() = if (alpha > 0.0f) 1.0f / (sampleRate * alpha) else 0.0f

init

    init {
        require(sampleRate > 0.0f) { "sampleRate must be > 0" }
        require(alpha in 0.0f..1.0f) { "alpha must be in [0, 1]" }
        require(beta in 0.0f..1.0f) { "beta must be in [0, 1]" }
    }

open fun update(sample: Float)

    open fun update(sample: Float) {
        rRe = rRe * (1.0f - alpha) + phasor.real * alpha * sample
        rIm = rIm * (1.0f - alpha) + phasor.imag * alpha * sample
        rrRe = rrRe * (1.0f - beta) + rRe * beta
        rrIm = rrIm * (1.0f - beta) + rIm * beta
        phasor.update()
    }

fun update(samples: FloatArray, offset: Int = 0, length: Int = samples.size - offset, stride: Int = 1)

    fun update(samples: FloatArray, offset: Int = 0, length: Int = samples.size - offset, stride: Int = 1) {
        require(offset >= 0 && length >= 0 && stride > 0) { "bad update range" }
        var i = 0
        var idx = offset
        while (i < length) {
            require(idx in samples.indices) { "sample index out of range" }
            update(samples[idx])
            i += stride
            idx += stride
        }
        stabilize()
    }

open fun reset()

    open fun reset() {
        rRe = 0.0f
        rIm = 0.0f
        rrRe = 0.0f
        rrIm = 0.0f
    }

fun stabilize()

    fun stabilize() {
        phasor.stabilize()
    }
}

class TrackingResonator

class TrackingResonator(
    naturalFrequency: Float,
    alpha: Float,
    beta: Float = alpha,
    sampleRate: Float,
    val gamma: Float = trackingResonatorGammaHeuristic(naturalFrequency, sampleRate)
) : Resonator(naturalFrequency, alpha, beta, sampleRate) {
    private val naturalFrequency = naturalFrequency
    private var previousRe = 0.0f
    private var previousIm = 0.0f
    private var resonantOmega = -OSC_TWO_PI * naturalFrequency / sampleRate
    private var naturalOmega = resonantOmega
    private var sigma = 1.0f
    private var omSigma = 0.0f
    private var accumulatedPower = 0.0001f

var trackingPowerThreshold: Float

    var trackingPowerThreshold: Float = 0.001f / 1000.0f
        private set

val resonantFrequency: Float

    val resonantFrequency: Float
        get() = -resonantOmega * sampleRate / OSC_TWO_PI

val instantaneousFrequency: Float

    val instantaneousFrequency: Float
        get() = resonantFrequency + deltaPhase * sampleRate / OSC_TWO_PI

var deltaPhase: Float

    var deltaPhase: Float = 0.0f
        private set

init

    init {
        require(gamma in 0.0f..1.0f) { "gamma must be in [0, 1]" }
    }

override fun update(sample: Float)

    override fun update(sample: Float) {
        super.update(sample)

        val deltaRe = rrRe * previousRe + rrIm * previousIm
        val deltaIm = rrIm * previousRe - rrRe * previousIm
        deltaPhase = atan2(deltaIm, deltaRe)

        val trackedOmega = resonantOmega - gamma * deltaPhase
        resonantOmega = if (power >= trackingPowerThreshold) trackedOmega else naturalOmega
        phasor.setFrequency(-resonantOmega * sampleRate / OSC_TWO_PI)

        accumulatedPower = omSigma * accumulatedPower + sigma * power
        trackingPowerThreshold = kotlin.math.max(0.001f, accumulatedPower) / 1000.0f
        previousRe = rrRe
        previousIm = rrIm
    }

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
    }

override fun reset()

    override fun reset() {
        super.reset()
        previousRe = 0.0f
        previousIm = 0.0f
        deltaPhase = 0.0f
        naturalOmega = -OSC_TWO_PI * naturalFrequency / sampleRate
        resonantOmega = naturalOmega
        phasor.setFrequency(naturalFrequency)
        accumulatedPower = 0.0001f
        trackingPowerThreshold = 0.001f / 1000.0f
    }
}