TrackedPhase.kt
src/jvmMain/kotlin/afield/audio/synth/TrackedPhase.kt · 236 lines
@file:afield.runtime.completion.CompletionCategory("Synthesis")
package afield.audio.synth
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.sin
import kotlin.math.sqrt
Pitch-tracked phase for voice banks: a tracker reduces one periodic sound to phase, phase advance, pitch, level and confidence, and a bank gives every voice its own phase accumulator.
Each voice advances by
theta += ratio * (omega + lock * (phaseAdvance - omega))
This supports undertones, harmonics and irrational ratios without dividing wrapped phase.
lock = 0 follows stable pitch; lock = 1 also follows the tracker's phase corrections. It does
not force a voice onto an absolute input phase, so changing a ratio preserves continuity.
QuadPhaseTracker is the analysis half of SubharmonicQuadPLL, sharing its HilbertPair and algorithm; Subharmonic keeps its own oscillator and level behaviour. HarmonicPhaseTracker supplies the same state from several partials, including a missing fundamental. Neither tracker separates polyphonic sources.
Trackers, states and voices belong to the audio thread. TrackedPhaseAnalysis caches each sample's state for graph fan-out; reading a live tracker from the Runloop must never advance it. Per-sample paths reuse their storage. Custom renderers must handle their own bandwidth; the bank's Nyquist fade protects the fundamental of each sine voice only.
private const val TWO_PI = 2.0 * PI
class TrackedPhaseState
Mutable, reused per-sample state published by a PhaseTracker.
phase is the tracker's wrapped phase in [0, 2π). phaseAdvance is the actual phase increment used by the tracker on this sample. omega is the slower/stabler angular-frequency estimate in radians per sample. level is the tracked analytic-signal amplitude. confidence is 0..1 and describes phase agreement while the gate is open.
A tracker owns this object and mutates it in place. Consumers must not retain snapshots by reference; copy fields explicitly if they need history.
class TrackedPhaseState(
val sampleRate: Double,
) {
var phase: Double
var phase: Double = 0.0
internal set
var phaseAdvance: Double
var phaseAdvance: Double = 0.0
internal set
var omega: Double
var omega: Double = 0.0
internal set
var level: Double
var level: Double = 0.0
internal set
var confidence: Double
var confidence: Double = 0.0
internal set
var gateOpen: Boolean
var gateOpen: Boolean = false
internal set
var locked: Boolean
var locked: Boolean = false
internal set
var harmonicity: Double
Harmonic power coverage, or phase agreement for the quadrature tracker.
var harmonicity: Double = 0.0
internal set
var activePartials: Int
Number of usable analysis partials on this sample.
var activePartials: Int = 0
internal set
init { require(sampleRate.isFinite() && sampleRate > 0) }
internal fun clear()
internal fun clear() {
phase = 0.0; phaseAdvance = 0.0; omega = 0.0; level = 0.0
confidence = 0.0; harmonicity = 0.0; activePartials = 0
gateOpen = false; locked = false
}
internal fun copyFrom(other: TrackedPhaseState)
internal fun copyFrom(other: TrackedPhaseState) {
phase = other.phase; phaseAdvance = other.phaseAdvance; omega = other.omega
level = other.level; confidence = other.confidence; harmonicity = other.harmonicity
activePartials = other.activePartials; gateOpen = other.gateOpen; locked = other.locked
}
val hz: Double
val hz: Double
get() = omega * sampleRate / TWO_PI
val phaseAdvanceHz: Double
val phaseAdvanceHz: Double
get() = phaseAdvance * sampleRate / TWO_PI
override fun toString(): String
override fun toString(): String =
"TrackedPhaseState(hz=${"%.3f".format(hz)}, level=${"%.4f".format(level)}, " +
"confidence=${"%.3f".format(confidence)}, locked=$locked)"
}
interface PhaseTracker
Analysis half of the system. Implementations update one reused state for every sample.
val sampleRate: Double
val state: TrackedPhaseState
fun process(input: Float): TrackedPhaseState
fun reset()
class QuadPhaseTracker
Quadrature analysis shared with SubharmonicQuadPLL; use a band-limited monophonic input.
class QuadPhaseTracker(
override val sampleRate: Double = 48_000.0,
val minHz: Double = 35.0,
val maxHz: Double = 350.0,
val bandwidthHz: Double = 8.0,
val damping: Double = 0.707,
val acquisitionTimeSeconds: Double = 0.02,
val gateLevel: Double = 0.01,
attackTimeSeconds: Double = 0.005,
releaseTimeSeconds: Double = 0.06,
val initialHz: Double = 100.0,
val lockThreshold: Double = 0.9,
) : PhaseTracker {
override val state = TrackedPhaseState(sampleRate)
private val twoPi = TWO_PI
private var omega = 0.0
private var envelope = 0.0
private val minOmega = twoPi * minHz / sampleRate
private val maxOmega = twoPi * maxHz / sampleRate
private val wn = twoPi * bandwidthHz / sampleRate
private val kp = 2.0 * damping * wn
private val ki = wn * wn
private val kf = if (acquisitionTimeSeconds > 0.0) 1.0 - smoothing(acquisitionTimeSeconds) else 0.0
private val attack = smoothing(attackTimeSeconds)
private val release = smoothing(releaseTimeSeconds)
private val hilbert = HilbertPair()
private val lockCoeff = smoothing(0.01)
private var phase = 0.0
private var lastI = 0.0
private var lastQ = 0.0
cos of the phase error, smoothed over 10 ms: 1 when the loop sits on the input's phase.
private var lock = 0.0
var locked = false; private set
True while the input is above gateLevel and the loop is in phase with it.
init
init {
require(minHz.isFinite() && maxHz.isFinite() && minHz > 0 && maxHz > minHz && maxHz < sampleRate / 2)
require(initialHz.isFinite() && initialHz > 0)
require(bandwidthHz.isFinite() && bandwidthHz > 0 && damping.isFinite() && damping > 0)
require(acquisitionTimeSeconds.isFinite() && acquisitionTimeSeconds >= 0)
require(gateLevel.isFinite() && gateLevel >= 0)
reset()
}
override fun process(input: Float): TrackedPhaseState
override fun process(input: Float): TrackedPhaseState {
val x = if (input.isFinite()) input.toDouble() else 0.0
hilbert.process(x)
val i = hilbert.i; val q = hilbert.q
val mag = sqrt(i * i + q * q)
val target = if (mag > gateLevel) mag else 0.0
val c = if (target > envelope) attack else release
envelope = c * envelope + (1.0 - c) * target
val gateOpen = mag > gateLevel
var advance = omega
if (gateOpen) {
val c = cos(phase); val s = sin(phase)
val error = (i * c - q * s) / mag // sin(θ − φ̂)
lock = lockCoeff * lock + (1.0 - lockCoeff) * ((q * c + i * s) / mag) // cos(θ − φ̂), smoothed
if (kf > 0.0 && lock < lockThreshold && lastI * lastI + lastQ * lastQ > gateLevel * gateLevel) {
// phase of z_n · conj(z_{n-1}), with z = q + j·i
val advance = atan2(i * lastQ - q * lastI, q * lastQ + i * lastI)
if (advance > 0.0) omega += kf * (advance - omega)
}
omega = (omega + ki * error).coerceIn(minOmega, maxOmega)
advance = omega + kp * error
phase = wrapTrackedPhase(phase + advance)
} else {
phase = wrapTrackedPhase(phase + omega)
}
locked = gateOpen && lock >= lockThreshold
lastI = i; lastQ = q
state.phase = phase
state.phaseAdvance = advance
state.omega = omega
state.level = envelope
state.gateOpen = gateOpen
state.locked = locked
state.confidence = if (gateOpen) lock.coerceIn(0.0, 1.0) else 0.0
state.harmonicity = state.confidence
state.activePartials = if (gateOpen) 1 else 0
return state
}
override fun reset()
override fun reset() {
envelope = 0.0
state.clear()
hilbert.reset()
phase = 0.0; lastI = 0.0; lastQ = 0.0; locked = false; lock = 0.0
omega = (twoPi * initialHz / sampleRate).coerceIn(minOmega, maxOmega)
state.omega = omega
state.phaseAdvance = omega
}
private fun smoothing(seconds: Double): Double {
require(seconds.isFinite() && seconds >= 0)
return if (seconds == 0.0) 0.0 else exp(-1.0 / (seconds * sampleRate))
}
}
internal fun wrapTrackedPhase(phase: Double): Double
Constant-time phase wrapping, including negative ratios.
internal fun wrapTrackedPhase(phase: Double): Double {
if (!phase.isFinite()) return 0.0
val p = phase % (2.0 * PI)
return if (p < 0.0) p + 2.0 * PI else p
}