Filters.kt
src/jvmMain/kotlin/afield/audio/synth/Filters.kt · 528 lines
@file:afield.runtime.completion.CompletionCategory("Synthesis")
package afield.audio.synth
import kotlin.math.*
The filters, and the one saturator that keeps them company. Each is a SignalProcessor, so
saw / SVF(cutoff = 800.p) is how one gets used, and every coefficient is recomputed per sample
from its Params, which is what lets a cutoff be swept at audio rate.
Which one to reach for. SVF is the default for anything modulated: it is the state-variable form under a topology-preserving transform, and it stays well behaved while the cutoff is moving. Biquad is the Audio EQ Cookbook set, second order in eight responses including the two shelves and a peaking EQ, and is what to use for a fixed curve. OnePole is 6 dB per octave and costs almost nothing. MoogLadder and DiodeLadder are four-pole resonant lowpasses that keep the character of the circuits they are named after.
The rest are more particular. CombFilter and AllpassFilter are the classic reverb building
blocks offered raw, in feedforward or feedback form; the reverbs in Effects.kt build their own
rather than compose these. FormantFilter parks three bandpasses on vowel formants and morphs
between A, E, I, O and U on a single control. DCBlocker is the one-line high-pass that removes
the offset rectification and asymmetric shaping leave behind. Saturator is a bare tanh with a
drive in front of it, which is often what a ladder filter's output wants next.
OnePole – simple one-pole filter
class OnePole
First-order (6 dB/oct) one-pole filter.
mode: LP for lowpass, HP for highpass.
class OnePole(
val cutoff: Param = Param(1000f),
val mode: Mode = Mode.LP
) : SignalProcessor() {
enum class Mode { LP, HP }
private var y1 = 0f
private var inBuf = FloatArray(0)
private var cBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (inBuf.size < numFrames) { inBuf = FloatArray(numFrames); cBuf = FloatArray(numFrames) }
input.render(inBuf, numFrames, ctx)
cutoff.render(cBuf, numFrames, ctx)
val invSr = ctx.inverseSampleRate.toDouble()
for (i in 0 until numFrames) {
val freq = cBuf[i].toDouble().coerceIn(1.0, ctx.nyquist.toDouble())
val b1 = exp(-2.0 * PI * freq * invSr).toFloat()
val a0 = 1f - b1
y1 = a0 * inBuf[i] + b1 * y1
output[i] = when (mode) {
Mode.LP -> y1
Mode.HP -> inBuf[i] - y1
}
}
}
}
Biquad – second-order IIR filter
enum class BiquadType
enum class BiquadType {
LPF, HPF, BPF, NOTCH, APF, PEAKING, LOW_SHELF, HIGH_SHELF
}
fun Signal.lowpass(cutoffHz: Float, q: Float = 0.707f): Signal
This signal through a 12 dB/octave Biquad lowpass at cutoffHz: bass.lowpass(150f).
Box code reaches for this rather than Biquad(BiquadType.LPF, …) because afield.math.statistics has a
BiquadType of its own, so the bare name is ambiguous in a box. Chain two for 24 dB/octave.
fun Signal.lowpass(cutoffHz: Float, q: Float = 0.707f): Signal = through(Biquad(BiquadType.LPF, Param(cutoffHz), Param(q)))
fun Signal.highpass(cutoffHz: Float, q: Float = 0.707f): Signal
This signal through a 12 dB/octave Biquad highpass at cutoffHz. See lowpass.
fun Signal.highpass(cutoffHz: Float, q: Float = 0.707f): Signal = through(Biquad(BiquadType.HPF, Param(cutoffHz), Param(q)))
class Biquad
class Biquad(
val type: BiquadType = BiquadType.LPF,
val cutoff: Param = Param(1000f),
val q: Param = Param(0.707f),
/** Gain in dB for PEAKING / LOW_SHELF / HIGH_SHELF. */
val gainDb: Param = Param(0f)
) : SignalProcessor() {
biquad state (transposed direct-form II)
private var s1 = 0f; private var s2 = 0f
private var inBuf = FloatArray(0)
private var cBuf = FloatArray(0)
private var qBuf = FloatArray(0)
private var gBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (inBuf.size < numFrames) {
inBuf = FloatArray(numFrames); cBuf = FloatArray(numFrames)
qBuf = FloatArray(numFrames); gBuf = FloatArray(numFrames)
}
input.render(inBuf, numFrames, ctx)
cutoff.render(cBuf, numFrames, ctx)
q.render(qBuf, numFrames, ctx)
gainDb.render(gBuf, numFrames, ctx)
val sr = ctx.sampleRate.toDouble()
for (i in 0 until numFrames) {
val freq = cBuf[i].toDouble().coerceIn(20.0, sr * 0.499)
val Q = qBuf[i].toDouble().coerceAtLeast(0.1)
val dBGain = gBuf[i].toDouble()
cook(freq, Q, dBGain, sr)
// transposed direct form II
val x = inBuf[i]
val y = b0 * x + s1
s1 = (b1 * x - a1 * y + s2).toFloat()
s2 = (b2 * x - a2 * y).toFloat()
output[i] = y.toFloat()
}
}
coefficient temporaries (recalculated per sample)
source — 59 lines, from line 124
private var b0 = 0.0; private var b1 = 0.0; private var b2 = 0.0
private var a1 = 0.0; private var a2 = 0.0
private fun cook(freq: Double, Q: Double, dBGain: Double, sr: Double) {
val w0 = 2.0 * PI * freq / sr
val cosW0 = cos(w0)
val sinW0 = sin(w0)
val alpha = sinW0 / (2.0 * Q)
val A = 10.0.pow(dBGain / 40.0)
var a0t: Double
when (type) {
BiquadType.LPF -> {
b0 = (1.0 - cosW0) / 2.0; b1 = 1.0 - cosW0; b2 = b0
a0t = 1.0 + alpha; a1 = -2.0 * cosW0; a2 = 1.0 - alpha
}
BiquadType.HPF -> {
b0 = (1.0 + cosW0) / 2.0; b1 = -(1.0 + cosW0); b2 = b0
a0t = 1.0 + alpha; a1 = -2.0 * cosW0; a2 = 1.0 - alpha
}
BiquadType.BPF -> {
b0 = alpha; b1 = 0.0; b2 = -alpha
a0t = 1.0 + alpha; a1 = -2.0 * cosW0; a2 = 1.0 - alpha
}
BiquadType.NOTCH -> {
b0 = 1.0; b1 = -2.0 * cosW0; b2 = 1.0
a0t = 1.0 + alpha; a1 = -2.0 * cosW0; a2 = 1.0 - alpha
}
BiquadType.APF -> {
b0 = 1.0 - alpha; b1 = -2.0 * cosW0; b2 = 1.0 + alpha
a0t = 1.0 + alpha; a1 = -2.0 * cosW0; a2 = 1.0 - alpha
}
BiquadType.PEAKING -> {
b0 = 1.0 + alpha * A; b1 = -2.0 * cosW0; b2 = 1.0 - alpha * A
a0t = 1.0 + alpha / A; a1 = -2.0 * cosW0; a2 = 1.0 - alpha / A
}
BiquadType.LOW_SHELF -> {
val sq = 2.0 * sqrt(A) * alpha
b0 = A * ((A + 1.0) - (A - 1.0) * cosW0 + sq)
b1 = 2.0 * A * ((A - 1.0) - (A + 1.0) * cosW0)
b2 = A * ((A + 1.0) - (A - 1.0) * cosW0 - sq)
a0t = (A + 1.0) + (A - 1.0) * cosW0 + sq
a1 = -2.0 * ((A - 1.0) + (A + 1.0) * cosW0)
a2 = (A + 1.0) + (A - 1.0) * cosW0 - sq
}
BiquadType.HIGH_SHELF -> {
val sq = 2.0 * sqrt(A) * alpha
b0 = A * ((A + 1.0) + (A - 1.0) * cosW0 + sq)
b1 = -2.0 * A * ((A - 1.0) + (A + 1.0) * cosW0)
b2 = A * ((A + 1.0) + (A - 1.0) * cosW0 - sq)
a0t = (A + 1.0) - (A - 1.0) * cosW0 + sq
a1 = 2.0 * ((A - 1.0) - (A + 1.0) * cosW0)
a2 = (A + 1.0) - (A - 1.0) * cosW0 - sq
}
}
// normalize
b0 /= a0t; b1 /= a0t; b2 /= a0t; a1 /= a0t; a2 /= a0t
}
}
SVF – State Variable Filter (TPT / Cytomic)
enum class SVFMode { LP, HP, BP, NOTCH }
class SVF
class SVF(
val mode: SVFMode = SVFMode.LP,
val cutoff: Param = Param(1000f),
val resonance: Param = Param(0.5f)
) : SignalProcessor() {
private var ic1eq = 0.0; private var ic2eq = 0.0
private var inBuf = FloatArray(0); private var cBuf = FloatArray(0); private var rBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (inBuf.size < numFrames) {
inBuf = FloatArray(numFrames); cBuf = FloatArray(numFrames); rBuf = FloatArray(numFrames)
}
input.render(inBuf, numFrames, ctx)
cutoff.render(cBuf, numFrames, ctx)
resonance.render(rBuf, numFrames, ctx)
val sr = ctx.sampleRate.toDouble()
for (i in 0 until numFrames) {
val freq = cBuf[i].toDouble().coerceIn(20.0, sr * 0.499)
val res = rBuf[i].toDouble().coerceIn(0.0, 1.0)
val g = tan(PI * freq / sr)
val k = 2.0 - 2.0 * res // damping: 0 = max resonance, 2 = no resonance
val a1 = 1.0 / (1.0 + g * (g + k))
val a2 = g * a1
val a3 = g * a2
val v3 = inBuf[i] - ic2eq
val v1 = a1 * ic1eq + a2 * v3
val v2 = ic2eq + a2 * ic1eq + a3 * v3
ic1eq = 2.0 * v1 - ic1eq
ic2eq = 2.0 * v2 - ic2eq
output[i] = when (mode) {
SVFMode.LP -> v2
SVFMode.BP -> v1
SVFMode.HP -> inBuf[i] - k * v1 - v2
SVFMode.NOTCH -> inBuf[i] - k * v1
}.toFloat()
}
}
}
MoogLadder – 24 dB/oct resonant lowpass
class MoogLadder
class MoogLadder(
val cutoff: Param = Param(1000f),
val resonance: Param = Param(0.5f)
) : SignalProcessor() {
Four cascaded one-pole stages + their delayed output
private var stage = DoubleArray(4)
private var inBuf = FloatArray(0); private var cBuf = FloatArray(0); private var rBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (inBuf.size < numFrames) {
inBuf = FloatArray(numFrames); cBuf = FloatArray(numFrames); rBuf = FloatArray(numFrames)
}
input.render(inBuf, numFrames, ctx)
cutoff.render(cBuf, numFrames, ctx)
resonance.render(rBuf, numFrames, ctx)
val sr = ctx.sampleRate.toDouble()
for (i in 0 until numFrames) {
val freq = cBuf[i].toDouble().coerceIn(20.0, sr * 0.499)
val res = rBuf[i].toDouble().coerceIn(0.0, 1.0) * 4.0 // 0..4 feedback
// Frequency coefficient (proper bilinear-ish warping)
val f = 2.0 * sin(PI * freq / sr)
// Feedback with resonance compensation
val fb = res * (1.0 - 0.15 * f * f)
val inp = inBuf[i].toDouble() - fb * stage[3]
// Four cascaded one-pole sections with tanh nonlinearity
stage[0] += f * (tanh(inp) - tanh(stage[0]))
stage[1] += f * (tanh(stage[0]) - tanh(stage[1]))
stage[2] += f * (tanh(stage[1]) - tanh(stage[2]))
stage[3] += f * (tanh(stage[2]) - tanh(stage[3]))
output[i] = stage[3].toFloat()
}
}
}
CombFilter – feedforward & feedback comb filter
class CombFilter
class CombFilter(
/** Delay time in seconds. */
val delayTime: Param = Param(0.01f),
/** Feedback/feedforward gain (-1…+1 for stability). */
val feedback: Param = Param(0.7f),
val mode: CombMode = CombMode.FEEDBACK,
maxDelaySec: Float = 1f
) : SignalProcessor() {
enum class CombMode { FEEDFORWARD, FEEDBACK }
private var buffer: FloatArray = FloatArray(0)
private var writePos = 0
private var bufSize = 0
private var inBuf = FloatArray(0); private var dBuf = FloatArray(0); private var fBuf = FloatArray(0)
private fun ensureBuffer(sampleRate: Float, maxDelay: Float) {
val needed = (sampleRate * maxDelay).toInt() + 4
if (bufSize < needed) {
buffer = FloatArray(needed)
bufSize = needed
writePos = 0
}
}
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
ensureBuffer(ctx.sampleRate, 1f)
if (inBuf.size < numFrames) {
inBuf = FloatArray(numFrames); dBuf = FloatArray(numFrames); fBuf = FloatArray(numFrames)
}
input.render(inBuf, numFrames, ctx)
delayTime.render(dBuf, numFrames, ctx)
feedback.render(fBuf, numFrames, ctx)
for (i in 0 until numFrames) {
val delaySamples = (dBuf[i] * ctx.sampleRate).toDouble().coerceIn(1.0, (bufSize - 1).toDouble())
val readPos = writePos - delaySamples.toInt()
val idx = if (readPos >= 0) readPos else readPos + bufSize
val delayed = buffer[idx % bufSize]
val fb = fBuf[i].coerceIn(-0.999f, 0.999f)
val out = when (mode) {
CombMode.FEEDFORWARD -> inBuf[i] + fb * delayed
CombMode.FEEDBACK -> inBuf[i] + fb * delayed
}
output[i] = out
buffer[writePos] = when (mode) {
CombMode.FEEDFORWARD -> inBuf[i]
CombMode.FEEDBACK -> out
}
writePos = (writePos + 1) % bufSize
}
}
}
AllpassFilter – allpass filter (for reverb, phaser, etc.)
class AllpassFilter
class AllpassFilter(
val delayTime: Param = Param(0.005f),
val coefficient: Param = Param(0.5f),
maxDelaySec: Float = 0.5f
) : SignalProcessor() {
private var buffer = FloatArray(0)
private var writePos = 0
private var bufSize = 0
private var inBuf = FloatArray(0); private var dBuf = FloatArray(0); private var cBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
val needed = (ctx.sampleRate * 0.5f).toInt() + 4
if (bufSize < needed) { buffer = FloatArray(needed); bufSize = needed; writePos = 0 }
if (inBuf.size < numFrames) {
inBuf = FloatArray(numFrames); dBuf = FloatArray(numFrames); cBuf = FloatArray(numFrames)
}
input.render(inBuf, numFrames, ctx)
delayTime.render(dBuf, numFrames, ctx)
coefficient.render(cBuf, numFrames, ctx)
for (i in 0 until numFrames) {
val delaySamples = (dBuf[i] * ctx.sampleRate).toInt().coerceIn(1, bufSize - 1)
val readIdx = ((writePos - delaySamples + bufSize) % bufSize)
val delayed = buffer[readIdx]
val g = cBuf[i]
val out = -g * inBuf[i] + delayed + g * delayed
buffer[writePos] = inBuf[i] + g * delayed
output[i] = out
writePos = (writePos + 1) % bufSize
}
}
}
DCBlocker – removes DC offset
class DCBlocker : SignalProcessor()
class DCBlocker : SignalProcessor() {
private var x1 = 0f; private var y1 = 0f
private var inBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (inBuf.size < numFrames) inBuf = FloatArray(numFrames)
input.render(inBuf, numFrames, ctx)
// y[n] = x[n] - x[n-1] + R * y[n-1], R ~ 0.995
val r = 0.995f
for (i in 0 until numFrames) {
val x = inBuf[i]
y1 = x - x1 + r * y1
x1 = x
output[i] = y1
}
}
}
FormantFilter – vowel formant synthesis
class FormantFilter
class FormantFilter(
/** Vowel morph: 0=A, 1=E, 2=I, 3=O, 4=U (interpolated). */
val morph: Param = Param(0f)
) : SignalProcessor() {
companion object
Formant table: vowel = (freq, bandwidth, amplitude)
companion object {
private val VOWELS = arrayOf(
// A
floatArrayOf(800f, 80f, 1f, 1150f, 90f, 0.5f, 2900f, 120f, 0.25f),
// E
floatArrayOf(400f, 60f, 1f, 1600f, 80f, 0.5f, 2700f, 120f, 0.25f),
// I
floatArrayOf(350f, 50f, 1f, 2000f, 100f, 0.4f, 2800f, 120f, 0.2f),
// O
floatArrayOf(450f, 70f, 1f, 800f, 80f, 0.5f, 2830f, 100f, 0.2f),
// U
floatArrayOf(325f, 50f, 1f, 700f, 60f, 0.4f, 2530f, 100f, 0.2f)
)
}
3 biquad BPF states (one per formant)
private val s1 = FloatArray(3); private val s2 = FloatArray(3)
private var inBuf = FloatArray(0); private var mBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (inBuf.size < numFrames) { inBuf = FloatArray(numFrames); mBuf = FloatArray(numFrames) }
input.render(inBuf, numFrames, ctx)
morph.render(mBuf, numFrames, ctx)
val sr = ctx.sampleRate.toDouble()
Buf.clear(output, 0, numFrames)
for (i in 0 until numFrames) {
val m = mBuf[i].toDouble().coerceIn(0.0, 4.0)
val idx = m.toInt().coerceAtMost(3)
val frac = (m - idx).toFloat()
val v0 = VOWELS[idx]; val v1 = VOWELS[idx + if (idx < 4) 1 else 0]
var sum = 0f
for (f in 0 until 3) {
val freq = (v0[f * 3] + (v1[f * 3] - v0[f * 3]) * frac).toDouble().coerceIn(20.0, sr * 0.499)
val bw = v0[f * 3 + 1] + (v1[f * 3 + 1] - v0[f * 3 + 1]) * frac
val amp = v0[f * 3 + 2] + (v1[f * 3 + 2] - v0[f * 3 + 2]) * frac
val w0 = 2.0 * PI * freq / sr
val alpha = sin(w0) * sinh(ln(2.0) / 2.0 * bw / freq * w0 / sin(w0))
val a0 = 1.0 + alpha
val b0r = (alpha / a0).toFloat()
val a1r = (-2.0 * cos(w0) / a0).toFloat()
val a2r = ((1.0 - alpha) / a0).toFloat()
// transposed direct form II
val x = inBuf[i]
val y = b0r * x + s1[f]
s1[f] = (-a1r * y + s2[f])
s2[f] = (-b0r * x - a2r * y)
sum += y * amp
}
output[i] = sum
}
}
}
LadderVCF – diode ladder filter (Roland-style)
class DiodeLadder
class DiodeLadder(
val cutoff: Param = Param(1000f),
val resonance: Param = Param(0.5f)
) : SignalProcessor() {
private var s = DoubleArray(4)
private var inBuf = FloatArray(0); private var cBuf = FloatArray(0); private var rBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (inBuf.size < numFrames) {
inBuf = FloatArray(numFrames); cBuf = FloatArray(numFrames); rBuf = FloatArray(numFrames)
}
input.render(inBuf, numFrames, ctx)
cutoff.render(cBuf, numFrames, ctx)
resonance.render(rBuf, numFrames, ctx)
val sr = ctx.sampleRate.toDouble()
for (i in 0 until numFrames) {
val freq = cBuf[i].toDouble().coerceIn(20.0, sr * 0.499)
val res = rBuf[i].toDouble().coerceIn(0.0, 1.0) * 17.0 // feedback gain
val g = tan(PI * freq / sr)
val G = g / (1.0 + g)
// feedback path
val sigma = s[0] * G * G * G + s[1] * G * G + s[2] * G + s[3]
val u = (inBuf[i].toDouble() - res * sigma) / (1.0 + res * G * G * G * G)
// four cascaded TPT integrators
var v = u
for (j in 0 until 4) {
val vn = (v - s[j]) * G
val out = vn + s[j]
s[j] = out + vn
v = out
}
output[i] = v.toFloat()
}
}
}
Saturator – soft clipping / tanh saturation
class Saturator
class Saturator(
val drive: Param = Param(1f)
) : SignalProcessor() {
private var inBuf = FloatArray(0); private var dBuf = FloatArray(0)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
if (inBuf.size < numFrames) { inBuf = FloatArray(numFrames); dBuf = FloatArray(numFrames) }
input.render(inBuf, numFrames, ctx)
drive.render(dBuf, numFrames, ctx)
for (i in 0 until numFrames) {
output[i] = tanh(inBuf[i].toDouble() * dBuf[i].toDouble()).toFloat()
}
}
}