Physical.kt
src/jvmMain/kotlin/afield/audio/synth/Physical.kt · 247 lines
@file:afield.runtime.completion.CompletionCategory("Synthesis")
package afield.audio.synth
import kotlin.math.floor
Two processors over one delay line read at fractional sample positions: FractionalDelay, a plain echo, and KarplusStrong, the same line closed into a loop and tuned to a pitch.
DelayInterpolation picks how the line reads between whole samples. LINEAR mixes the two
neighbours, CUBIC, the default, fits four of them. The choice matters most inside a feedback
loop, where the read happens again on every pass.
FractionalDelay is the echo: delayTime in seconds against a line sized from maxDelaySec and
the sample rate it first sees, feedback clamped just inside ±1 so each pass is quieter than the
last, and mix crossfading dry against delayed. Delay in Effects.kt does the same job with a
fixed cubic read; this one lets the interpolation be chosen.
KarplusStrong closes that line into a loop one period of freq long. Two things happen inside
it. brightness crossfades the delayed sample against the one before it, an average of two taps
that acts as a lowpass, so the high harmonics fade a pass at a time while the low ones hold, and
the loop length has that average's own delay subtracted from it, which keeps the pitch where it
was as brightness moves. decay scales the whole of what goes back round on each pass.
The excitation reaches the loop through a second line, delayed by pickPosition of a period and
subtracted from itself before excitationMix scales it in. That is a comb with notches every
1 / pickPosition harmonics, which is the standard stand-in for where along a string it was
plucked, so moving the control changes the colour of the attack rather than its level. It is a
SignalProcessor, so burst / KarplusStrong(freq = 110.p) is how it gets used, and with
clearOnTrigger a rising edge on trigger empties both lines so a new note starts clean.
enum class DelayInterpolation
How a delay line reads between whole samples: LINEAR mixes two neighbours, CUBIC fits four.
enum class DelayInterpolation {
LINEAR,
CUBIC
}
internal class FractionalDelayLine
internal class FractionalDelayLine(
maxDelaySamples: Int,
private val interpolation: DelayInterpolation = DelayInterpolation.CUBIC
) {
private val buffer = FloatArray(maxDelaySamples.coerceAtLeast(4) + 4)
private val size = buffer.size
private var writePos = 0
fun clear()
fun clear() {
buffer.fill(0f)
writePos = 0
}
fun write(sample: Float)
fun write(sample: Float) {
buffer[writePos] = sample
writePos++
if (writePos >= size) writePos = 0
}
fun read(delaySamples: Double): Float
fun read(delaySamples: Double): Float {
val clamped = delaySamples.coerceIn(0.0, (size - 3).toDouble())
val pos = writePos.toDouble() - clamped
return when (interpolation) {
DelayInterpolation.LINEAR -> readLinear(pos)
DelayInterpolation.CUBIC -> readCubic(pos)
}
}
private fun readLinear(pos: Double): Float {
val base = floor(pos).toInt()
val frac = (pos - base).toFloat()
val s0 = sampleAt(base)
val s1 = sampleAt(base + 1)
return s0 + (s1 - s0) * frac
}
private fun readCubic(pos: Double): Float {
val base = floor(pos).toInt()
val frac = (pos - base).toFloat()
val s0 = sampleAt(base - 1)
val s1 = sampleAt(base)
val s2 = sampleAt(base + 1)
val s3 = sampleAt(base + 2)
return hermite(frac, s0, s1, s2, s3)
}
private fun sampleAt(index: Int): Float {
var wrapped = index % size
if (wrapped < 0) wrapped += size
return buffer[wrapped]
}
}
class FractionalDelay
class FractionalDelay(
val delayTime: Param = Param(0.2f),
val feedback: Param = Param(0.35f),
val mix: Param = Param(0.5f),
val maxDelaySec: Float = 5f,
val interpolation: DelayInterpolation = DelayInterpolation.CUBIC
) : SignalProcessor() {
private var line: FractionalDelayLine? = null
private var lineCapacity = 0
private var inBuf = FloatArray(0)
private var delayBuf = FloatArray(0)
private var feedbackBuf = FloatArray(0)
private var mixBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
ensureLine(ctx.sampleRate)
if (inBuf.size < numFrames) {
inBuf = FloatArray(numFrames)
delayBuf = FloatArray(numFrames)
feedbackBuf = FloatArray(numFrames)
mixBuf = FloatArray(numFrames)
}
input.render(inBuf, numFrames, ctx)
delayTime.render(delayBuf, numFrames, ctx)
feedback.render(feedbackBuf, numFrames, ctx)
mix.render(mixBuf, numFrames, ctx)
val delayLine = line!!
val maxDelaySamples = lineCapacity - 3.0
for (i in 0 until numFrames) {
val delaySamples = (delayBuf[i].coerceAtLeast(0f) * ctx.sampleRate).toDouble().coerceIn(0.0, maxDelaySamples)
val delayed = delayLine.read(delaySamples)
val fb = feedbackBuf[i].coerceIn(-0.9999f, 0.9999f)
val wet = delayed
delayLine.write(inBuf[i] + wet * fb)
val m = mixBuf[i].coerceIn(0f, 1f)
output[i] = inBuf[i] * (1f - m) + wet * m
}
}
private fun ensureLine(sampleRate: Float) {
val required = (sampleRate * maxDelaySec).toInt().coerceAtLeast(8)
if (required != lineCapacity || line == null) {
lineCapacity = required
line = FractionalDelayLine(required, interpolation)
}
}
}
class KarplusStrong
Karplus-Strong resonator driven by its input signal.
Feed it a short excitation burst for plucks or hammer hits:
audio { (white * AR(gate = gate.also { it.open() }, release = 0.01.p)) / KarplusStrong(freq = 110.p) }
class KarplusStrong(
val freq: Param = Param(110f),
val decay: Param = Param(0.995f),
val brightness: Param = Param(0.5f),
val excitationMix: Param = Param(1f),
val pickPosition: Param = Param(0.15f),
val trigger: Signal = Const(0f),
val clearOnTrigger: Boolean = true,
val maxDelaySec: Float = 2f,
val interpolation: DelayInterpolation = DelayInterpolation.CUBIC
) : SignalProcessor() {
private var stringLine: FractionalDelayLine? = null
private var excitationLine: FractionalDelayLine? = null
private var lineCapacity = 0
private var prevTrigger = 0f
private var previousLoopSample = 0f
private var inBuf = FloatArray(0)
private var freqBuf = FloatArray(0)
private var decayBuf = FloatArray(0)
private var brightnessBuf = FloatArray(0)
private var excitationMixBuf = FloatArray(0)
private var pickPositionBuf = FloatArray(0)
private var triggerBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
body — 69 lines, from line 178
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
ensureLines(ctx.sampleRate)
if (inBuf.size < numFrames) {
inBuf = FloatArray(numFrames)
freqBuf = FloatArray(numFrames)
decayBuf = FloatArray(numFrames)
brightnessBuf = FloatArray(numFrames)
excitationMixBuf = FloatArray(numFrames)
pickPositionBuf = FloatArray(numFrames)
triggerBuf = FloatArray(numFrames)
}
input.render(inBuf, numFrames, ctx)
freq.render(freqBuf, numFrames, ctx)
decay.render(decayBuf, numFrames, ctx)
brightness.render(brightnessBuf, numFrames, ctx)
excitationMix.render(excitationMixBuf, numFrames, ctx)
pickPosition.render(pickPositionBuf, numFrames, ctx)
trigger.render(triggerBuf, numFrames, ctx)
val stringDelay = stringLine!!
val excitationDelay = excitationLine!!
val maxDelaySamples = lineCapacity - 3.0
for (i in 0 until numFrames) {
val trig = triggerBuf[i]
if (clearOnTrigger && trig > 0f && prevTrigger <= 0f) {
stringDelay.clear()
excitationDelay.clear()
previousLoopSample = 0f
}
prevTrigger = trig
val hz = freqBuf[i].coerceIn(8f, ctx.sampleRate * 0.45f)
val periodSamples = ctx.sampleRate / hz
val bright = brightnessBuf[i].coerceIn(0f, 1f)
val filterDelay = 1.0 - bright.toDouble()
val tunedDelay = (periodSamples - filterDelay.toFloat()).toDouble().coerceIn(1.0, maxDelaySamples)
val delayed = stringDelay.read(tunedDelay)
val filtered = delayed * bright + previousLoopSample * (1f - bright)
previousLoopSample = delayed
val pick = pickPositionBuf[i].coerceIn(0f, 1f)
val pickDelaySamples = (periodSamples * pick).toDouble().coerceIn(0.0, maxDelaySamples)
val excitation = inBuf[i]
val combedExcitation = excitation - excitationDelay.read(pickDelaySamples)
excitationDelay.write(excitation)
val loop = filtered * decayBuf[i].coerceIn(-0.9999f, 0.9999f)
val next = combedExcitation * excitationMixBuf[i] + loop
stringDelay.write(next)
output[i] = delayed
}
}
private fun ensureLines(sampleRate: Float) {
val required = (sampleRate * maxDelaySec).toInt().coerceAtLeast(16)
if (required != lineCapacity || stringLine == null || excitationLine == null) {
lineCapacity = required
stringLine = FractionalDelayLine(required, interpolation)
excitationLine = FractionalDelayLine(required, interpolation)
previousLoopSample = 0f
prevTrigger = 0f
}
}
}