Skip to content

Effects.kt

src/jvmMain/kotlin/afield/audio/synth/Effects.kt · 1012 lines

@file:afield.runtime.completion.CompletionCategory("Synthesis")
package afield.audio.synth

import kotlin.math.*
import kotlin.random.Random

The mono effects: delays, reverbs, distortions and dynamics, each one a SignalProcessor that / pipes a signal into.

saw / Delay(0.25.p) is the whole calling convention. Every control is a Param rendered once per sample, so a delay time or a compressor threshold can itself be a signal, and a sweep of one lands sample-accurate rather than once a block. All of them take and return mono; stereo comes after them, from Pan or StereoSignal.

The delay-line readers are not uniform, and it is audible. Delay interpolates between samples with a cubic fit; Chorus and Flanger read the nearest whole sample, so a slow sweep through either of those has a faint stepping to it that Delay does not. Compressor and Limiter also keep a running trace of their own gain reduction, which is what the meters in Visualizers.kt draw.

Delay – fractional delay line with interpolation

class Delay

A feedback delay line, read at fractional sample positions.

delayTime is in seconds, clamped to the buffer, which is allocated once from maxDelaySec. The read uses a four-point cubic fit rather than the nearest sample, so sweeping the delay time slides instead of stepping. With feedback at zero you get one echo; above that the line re-circulates, and negative values invert each pass. mix crossfades the dry input against the delayed sample.

class Delay(
    /** Delay time in seconds. */
    val delayTime: Param = Param(0.3f),
    /** Feedback amount (-1…+1). */
    val feedback: Param = Param(0.5f),
    /** Dry/wet mix (0 = all dry, 1 = all wet). */
    val mix: Param = Param(0.5f),
    val maxDelaySec: Float = 5f
) : SignalProcessor() {
    private var buffer = FloatArray(0)
    private var bufSize = 0
    private var writePos = 0
    private var inBuf = FloatArray(0)
    private var dBuf = FloatArray(0); private var fBuf = 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) {
        val needed = (ctx.sampleRate * maxDelaySec).toInt() + 4
        if (bufSize < needed) { buffer = FloatArray(needed); bufSize = needed; writePos = 0 }
        if (inBuf.size < numFrames) {
            inBuf = FloatArray(numFrames); dBuf = FloatArray(numFrames)
            fBuf = FloatArray(numFrames); mBuf = FloatArray(numFrames)
        }
        input.render(inBuf, numFrames, ctx)
        delayTime.render(dBuf, numFrames, ctx)
        feedback.render(fBuf, numFrames, ctx)
        mix.render(mBuf, numFrames, ctx)

        for (i in 0 until numFrames) {
            val delaySamps = (dBuf[i] * ctx.sampleRate).toDouble().coerceIn(0.0, (bufSize - 2).toDouble())
            val readPos = writePos.toDouble() - delaySamps
            val delayed = readCubic(readPos)
            val fb = fBuf[i].coerceIn(-0.999f, 0.999f)
            val wet = inBuf[i] + delayed * fb
            buffer[writePos] = wet
            writePos = (writePos + 1) % bufSize
            val m = mBuf[i].coerceIn(0f, 1f)
            output[i] = inBuf[i] * (1f - m) + delayed * m
        }
    }

    private fun readCubic(pos: Double): Float {
        val idx = pos.toInt()
        val frac = (pos - idx).toFloat()
        fun s(offset: Int): Float {
            val p = ((idx + offset) % bufSize + bufSize) % bufSize
            return buffer[p]
        }
        val s0 = s(-1); val s1 = s(0); val s2 = s(1); val s3 = s(2)
        val c0 = s1
        val c1 = 0.5f * (s2 - s0)
        val c2 = s0 - 2.5f * s1 + 2f * s2 - 0.5f * s3
        val c3 = 0.5f * (s3 - s0) + 1.5f * (s1 - s2)
        return ((c3 * frac + c2) * frac + c1) * frac + c0
    }
}

FreeVerb – Schroeder reverb (Freeverb algorithm)

class FreeVerb

Schroeder reverb, following the Freeverb topology: eight comb filters in parallel, each with a one-pole damper in its feedback path, summed and then passed through four allpasses in series.

The comb and allpass lengths are Freeverb's own constants, quoted at 44.1 kHz and rescaled to the engine's sample rate on the first block. roomSize maps onto comb feedback from 0.7 to 0.98, so the shortest setting is a small room rather than no reverb; damping rolls the top off each repeat. The input is attenuated hard on the way in, Freeverb's own fixed gain, which is what keeps the summed output of eight comb filters at that much feedback in range. The output is one channel: the stereo spread of the original algorithm is not here, and a wide reverb wants two of these fed from Pan.

class FreeVerb(
    val roomSize: Param = Param(0.8f),
    val damping: Param = Param(0.5f),
    val mix: Param = Param(0.3f)
) : SignalProcessor() {

companion object

Comb filter delay lengths (in samples at 44100 Hz, scaled at init)

    companion object {
        private val COMB_LENGTHS = intArrayOf(1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617)
        private val AP_LENGTHS = intArrayOf(556, 441, 341, 225)
        private const val SCALE_DAMPING = 0.4f
        private const val SCALE_ROOM = 0.28f
        private const val OFFSET_ROOM = 0.7f
    }

    private inner class CombState(length: Int) {
        val buffer = FloatArray(length)
        var pos = 0
        var filterStore = 0f
    }

    private inner class APState(length: Int) {
        val buffer = FloatArray(length)
        var pos = 0
    }

    private var combs: Array<CombState>? = null
    private var allpasses: Array<APState>? = null
    private var initialized = false

    private var inBuf = FloatArray(0)
    private var rBuf = FloatArray(0); private var dmpBuf = FloatArray(0); private var mBuf = FloatArray(0)

    private fun init(sampleRate: Float) {
        val scale = sampleRate / 44100f
        combs = Array(COMB_LENGTHS.size) { CombState((COMB_LENGTHS[it] * scale).toInt().coerceAtLeast(1)) }
        allpasses = Array(AP_LENGTHS.size) { APState((AP_LENGTHS[it] * scale).toInt().coerceAtLeast(1)) }
        initialized = true
    }

override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)

body — 43 lines, from line 148
    override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (!initialized) init(ctx.sampleRate)
        if (inBuf.size < numFrames) {
            inBuf = FloatArray(numFrames); rBuf = FloatArray(numFrames)
            dmpBuf = FloatArray(numFrames); mBuf = FloatArray(numFrames)
        }
        input.render(inBuf, numFrames, ctx)
        roomSize.render(rBuf, numFrames, ctx)
        damping.render(dmpBuf, numFrames, ctx)
        mix.render(mBuf, numFrames, ctx)

        val cms = combs!!; val aps = allpasses!!

        for (i in 0 until numFrames) {
            val room = rBuf[i].coerceIn(0f, 1f) * SCALE_ROOM + OFFSET_ROOM
            val damp = dmpBuf[i].coerceIn(0f, 1f) * SCALE_DAMPING
            val damp2 = 1f - damp
            val inp = inBuf[i] * 0.015f  // input scaling

            // Parallel comb filters
            var wet = 0f
            for (c in cms) {
                val delayed = c.buffer[c.pos]
                c.filterStore = delayed * damp2 + c.filterStore * damp
                c.buffer[c.pos] = inp + c.filterStore * room
                c.pos = (c.pos + 1) % c.buffer.size
                wet += delayed
            }

            // Series allpass filters
            for (a in aps) {
                val delayed = a.buffer[a.pos]
                val out = -wet + delayed
                a.buffer[a.pos] = wet + delayed * 0.5f
                a.pos = (a.pos + 1) % a.buffer.size
                wet = out
            }

            val m = mBuf[i].coerceIn(0f, 1f)
            output[i] = inBuf[i] * (1f - m) + wet * m
        }
    }
}

SpringReverb – bright, dispersive spring-like reverb

class SpringReverb

A spring tank, built as six short delay lines summed with alternating polarity and then dispersed through four allpasses in series.

The characteristic sound is in the dispersion rather than in the delays: tension raises the allpass coefficient, which is what smears a transient into the metallic chirp a real spring makes, and it drives the input a little harder as it goes. decay sets the feedback in the lines, tone opens both their damping and a one-pole on the way out. The lines are all under 25 ms, so the tank is bright and small; a hall is FreeVerb's job. Lengths are quoted at 44.1 kHz and rebuilt whenever the engine's sample rate changes.

class SpringReverb(
    /** Overall spring tension / metallic dispersion (0…1). */
    val tension: Param = Param(0.55f),
    /** Feedback tail length (0…1). */
    val decay: Param = Param(0.72f),
    /** Brightness of the spring return (0 = dark, 1 = bright). */
    val tone: Param = Param(0.65f),
    /** Dry/wet mix (0 = dry, 1 = wet). */
    val mix: Param = Param(0.28f)
) : SignalProcessor() {

companion object

declaration — 54 lines, from line 219
    companion object {
        private val LINE_LENGTHS = intArrayOf(211, 281, 337, 421, 631, 947)
        private val DISPERSION_LENGTHS = intArrayOf(53, 71, 97, 131)
    }

    private inner class SpringLine(length: Int) {
        val buffer = FloatArray(length)
        var pos = 0
        var lowpass = 0f

        fun process(input: Float, feedback: Float, damping: Float): Float {
            val delayed = buffer[pos]
            lowpass = delayed * (1f - damping) + lowpass * damping
            buffer[pos] = input + lowpass * feedback
            pos = (pos + 1) % buffer.size
            return delayed
        }
    }

    private inner class AllpassState(length: Int) {
        val buffer = FloatArray(length)
        var pos = 0

        fun process(input: Float, coefficient: Float): Float {
            val delayed = buffer[pos]
            val out = -input + delayed
            buffer[pos] = input + delayed * coefficient
            pos = (pos + 1) % buffer.size
            return out
        }
    }

    private var lines: Array<SpringLine>? = null
    private var dispersers: Array<AllpassState>? = null
    private var initializedSampleRate = 0f
    private var outputFilter = 0f

    private var inBuf = FloatArray(0)
    private var tensionBuf = FloatArray(0)
    private var decayBuf = FloatArray(0)
    private var toneBuf = FloatArray(0)
    private var mixBuf = FloatArray(0)

    private fun init(sampleRate: Float) {
        val scale = sampleRate / 44100f
        lines = Array(LINE_LENGTHS.size) {
            SpringLine((LINE_LENGTHS[it] * scale).toInt().coerceAtLeast(4))
        }
        dispersers = Array(DISPERSION_LENGTHS.size) {
            AllpassState((DISPERSION_LENGTHS[it] * scale).toInt().coerceAtLeast(4))
        }
        initializedSampleRate = sampleRate
        outputFilter = 0f
    }

override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)

body — 48 lines, from line 274
    override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (lines == null || initializedSampleRate != ctx.sampleRate) init(ctx.sampleRate)
        if (inBuf.size < numFrames) {
            inBuf = FloatArray(numFrames)
            tensionBuf = FloatArray(numFrames)
            decayBuf = FloatArray(numFrames)
            toneBuf = FloatArray(numFrames)
            mixBuf = FloatArray(numFrames)
        }

        input.render(inBuf, numFrames, ctx)
        tension.render(tensionBuf, numFrames, ctx)
        decay.render(decayBuf, numFrames, ctx)
        tone.render(toneBuf, numFrames, ctx)
        mix.render(mixBuf, numFrames, ctx)

        val springLines = lines!!
        val allpasses = dispersers!!

        for (i in 0 until numFrames) {
            val tens = tensionBuf[i].coerceIn(0f, 1f)
            val tail = decayBuf[i].coerceIn(0f, 1f)
            val bright = toneBuf[i].coerceIn(0f, 1f)
            val feedback = 0.54f + tail * 0.42f
            val damping = (1f - bright).coerceIn(0f, 1f) * 0.72f
            val drive = inBuf[i] * (0.018f + tens * 0.016f)

            var wet = 0f
            for (lineIndex in springLines.indices) {
                val polarity = if ((lineIndex and 1) == 0) 1f else -1f
                val lineFeedback = feedback * (0.94f + lineIndex * 0.012f).coerceAtMost(0.995f)
                wet += springLines[lineIndex].process(drive * polarity, lineFeedback, damping) * polarity
            }
            wet /= springLines.size

            var dispersed = wet
            val coefficient = 0.38f + tens * 0.52f
            for (a in allpasses) {
                dispersed = a.process(dispersed, coefficient)
            }

            val toneCoeff = 0.08f + bright * 0.82f
            outputFilter += (dispersed - outputFilter) * toneCoeff
            val m = mixBuf[i].coerceIn(0f, 1f)
            output[i] = inBuf[i] * (1f - m) + outputFilter * m
        }
    }
}

ConvolutionReverb – short direct convolution reverb

class ConvolutionReverb

Convolves the input with an impulse response directly in the time domain, one dot product per output sample.

There is no FFT here, so the cost is the impulse length times the block size, every block, and maxIrSamples is the ceiling that keeps that affordable: the default 8192 taps is about 0.17 s at 48 kHz. That is enough for a room, a cabinet or a short plate, and not enough for a cathedral.

The impulse is resampled from its own rate to the engine's on the first block and rebuilt if the engine's rate changes. With normalize left on it is also scaled to unit energy, so swapping one impulse for another changes the character without changing the level; gain then trims the wet path and mix crossfades it against the dry. syntheticImpulse makes a plausible decaying noise burst for when there is no file to load.

class ConvolutionReverb(
    val impulse: AudioSample,
    /** Dry/wet mix (0 = dry, 1 = convolved). */
    val mix: Param = Param(0.35f),
    /** Wet signal trim after convolution. */
    val gain: Param = Param(1f),
    val normalize: Boolean = true,
    val maxIrSamples: Int = 8192
) : SignalProcessor() {
    private var ir = FloatArray(0)
    private var buffer = FloatArray(0)
    private var writePos = 0
    private var initializedSampleRate = 0f

    private var inBuf = FloatArray(0)
    private var mixBuf = FloatArray(0)
    private var gainBuf = FloatArray(0)

companion object

fun syntheticImpulse

body — 45 lines, from line 361
        fun syntheticImpulse(
            lengthSeconds: Float = 0.45f,
            decay: Float = 0.65f,
            diffusion: Float = 0.8f,
            sampleRate: Float = 44100f,
            seed: Int = 0
        ): AudioSample {
            val safeLength = lengthSeconds.coerceAtLeast(0.01f)
            val n = (safeLength * sampleRate).toInt().coerceAtLeast(8)
            val rng = Random(seed)
            val data = FloatArray(n)
            data[0] = 1f
            for (i in 1 until n) {
                val t = i.toFloat() / n.toFloat()
                val env = exp(-t * (2.0 + decay.coerceIn(0f, 1f) * 8.0)).toFloat()
                val sparse = if (rng.nextFloat() < diffusion.coerceIn(0.05f, 1f)) 1f else 0f
                data[i] = (rng.nextFloat() * 2f - 1f) * env * sparse
            }
            return AudioSample(data, sampleRate)
        }
    }

    private fun init(sampleRate: Float) {
        val capped = maxIrSamples.coerceAtLeast(1)
        val targetLength = min(capped, (impulse.lengthSeconds * sampleRate).toInt().coerceAtLeast(1))
        val prepared = FloatArray(targetLength)
        val step = impulse.sampleRate / sampleRate
        var sourcePos = 0.0
        var energy = 0.0
        for (i in prepared.indices) {
            val v = impulse.readInterp(sourcePos)
            prepared[i] = v
            energy += (v * v).toDouble()
            sourcePos += step
        }
        if (normalize && energy > 1e-12) {
            val scale = (1.0 / sqrt(energy)).toFloat()
            for (i in prepared.indices) prepared[i] *= scale
        }

        ir = prepared
        buffer = FloatArray(prepared.size)
        writePos = 0
        initializedSampleRate = sampleRate
    }

override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)

    override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (initializedSampleRate != ctx.sampleRate || ir.isEmpty()) init(ctx.sampleRate)
        if (inBuf.size < numFrames) {
            inBuf = FloatArray(numFrames)
            mixBuf = FloatArray(numFrames)
            gainBuf = FloatArray(numFrames)
        }

        input.render(inBuf, numFrames, ctx)
        mix.render(mixBuf, numFrames, ctx)
        gain.render(gainBuf, numFrames, ctx)

        val impulseResponse = ir
        val ring = buffer

        for (i in 0 until numFrames) {
            ring[writePos] = inBuf[i]
            var wet = 0f
            var readPos = writePos
            for (tap in impulseResponse.indices) {
                wet += impulseResponse[tap] * ring[readPos]
                readPos--
                if (readPos < 0) readPos = ring.size - 1
            }
            writePos++
            if (writePos >= ring.size) writePos = 0

            val m = mixBuf[i].coerceIn(0f, 1f)
            output[i] = inBuf[i] * (1f - m) + wet * gainBuf[i] * m
        }
    }
}

Chorus – LFO-modulated delay

class Chorus

Several taps of one modulated delay line, averaged: voices readers of a 50 ms buffer, each swept by its own LFO at rate and started a fraction of a cycle apart from its neighbours.

depth is the widest excursion in seconds, and each voice's delay swings between zero and that. The taps are read at whole samples rather than interpolated, so a slow, deep sweep has a faint granularity to it. There is no feedback path; for that, reach for Flanger.

class Chorus(
    /** LFO rate in Hz. */
    val rate: Param = Param(1.5f),
    /** Modulation depth in seconds. */
    val depth: Param = Param(0.003f),
    /** Dry/wet mix (0…1). */
    val mix: Param = Param(0.5f),
    /** Number of chorus voices. */
    val voices: Int = 3
) : SignalProcessor() {
    private val maxDelay = 0.05f  // 50ms max
    private var buffer = FloatArray(0)
    private var bufSize = 0
    private var writePos = 0
    private val phases = DoubleArray(voices) { it.toDouble() / voices }

    private var inBuf = FloatArray(0)
    private var rateBuf = FloatArray(0); private var depthBuf = 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) {
        val needed = (ctx.sampleRate * maxDelay).toInt() + 4
        if (bufSize < needed) { buffer = FloatArray(needed); bufSize = needed; writePos = 0 }
        if (inBuf.size < numFrames) {
            inBuf = FloatArray(numFrames); rateBuf = FloatArray(numFrames)
            depthBuf = FloatArray(numFrames); mixBuf = FloatArray(numFrames)
        }
        input.render(inBuf, numFrames, ctx)
        rate.render(rateBuf, numFrames, ctx)
        depth.render(depthBuf, numFrames, ctx)
        mix.render(mixBuf, numFrames, ctx)

        val invSr = ctx.inverseSampleRate.toDouble()

        for (i in 0 until numFrames) {
            buffer[writePos] = inBuf[i]
            var wet = 0f
            val freq = rateBuf[i].toDouble()
            val dp = depthBuf[i].toDouble().coerceIn(0.0, maxDelay.toDouble())

            for (v in 0 until voices) {
                val delay = dp * (1.0 + sin(2.0 * PI * phases[v])) * 0.5 * ctx.sampleRate
                val readPos = writePos.toDouble() - delay.coerceAtLeast(1.0)
                val idx = ((readPos.toInt() % bufSize) + bufSize) % bufSize
                wet += buffer[idx]
                phases[v] += freq * invSr
                if (phases[v] >= 1.0) phases[v] -= 1.0
            }
            wet /= voices

            val m = mixBuf[i].coerceIn(0f, 1f)
            output[i] = inBuf[i] * (1f - m) + wet * m
            writePos = (writePos + 1) % bufSize
        }
    }
}

Waveshaper – arbitrary waveshaping distortion

class Waveshaper

Memoryless distortion: multiply by drive, then push each sample through transferFn on its own.

The companion object holds the usual four, so Waveshaper.tanh() and Waveshaper.chebyshev(3) are there without writing a lambda, and an arbitrary (Float) -> Float is just as welcome. Nothing is oversampled, so a transfer function with a corner in it (hard clipping, high Chebyshev orders) will fold aliases back down when the input is bright. The smooth shapers are gentler about it, and a lowpass before the shaper is the usual answer.

class Waveshaper(
    /** Transfer function: input sample → output sample. */
    val transferFn: (Float) -> Float,
    /** Pre-gain (drive). */
    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] = transferFn(inBuf[i] * dBuf[i])
        }
    }

companion object

    companion object {

fun tanh(drive: Param = Param(2f)): Waveshaper

Tanh soft clipping.

        fun tanh(drive: Param = Param(2f)): Waveshaper =
            Waveshaper({ tanh(it.toDouble()).toFloat() }, drive)

fun hardClip(drive: Param = Param(2f), threshold: Float = 1f): Waveshaper

Hard clipping.

        fun hardClip(drive: Param = Param(2f), threshold: Float = 1f): Waveshaper =
            Waveshaper({ it.coerceIn(-threshold, threshold) }, drive)

fun sineFold(drive: Param = Param(1f)): Waveshaper

Sine-fold waveshaping (for rich harmonics).

        fun sineFold(drive: Param = Param(1f)): Waveshaper =
            Waveshaper({ sin(it.toDouble() * PI * 0.5).toFloat() }, drive)

fun chebyshev(order: Int, drive: Param = Param(1f)): Waveshaper

Chebyshev polynomial waveshaper (order N).

        fun chebyshev(order: Int, drive: Param = Param(1f)): Waveshaper =
            Waveshaper({ chebyshevT(order, it.toDouble()).toFloat() }, drive)

        private fun chebyshevT(n: Int, x: Double): Double = when {
            n == 0 -> 1.0
            n == 1 -> x
            else -> 2.0 * x * chebyshevT(n - 1, x) - chebyshevT(n - 2, x)
        }
    }
}

Rectifier – half/full-wave rectification with blend control

enum class RectifierMode

Which part of the waveform a Rectifier keeps.

enum class RectifierMode {
    HALF_WAVE,
    FULL_WAVE,
    NEGATIVE_HALF
}

class Rectifier

Folds a waveform against zero: drive and bias first, then the mode's rule, then mix against the dry input.

HALF_WAVE keeps what is above zero and NEGATIVE_HALF what is below, each of which halves the energy and adds a DC component. FULL_WAVE takes the absolute value, which doubles the perceived pitch of anything periodic. bias shifts the waveform across zero before the fold, so it is the control that decides how much of the wave survives, and sweeping it is more interesting than sweeping drive. A DCBlocker afterwards is usually wanted.

class Rectifier(
    val mode: RectifierMode = RectifierMode.FULL_WAVE,
    val drive: Param = Param(1f),
    val bias: Param = Param(0f),
    val mix: Param = Param(1f)
) : SignalProcessor() {
    private var inBuf = FloatArray(0)
    private var driveBuf = FloatArray(0)
    private var biasBuf = 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) {
        if (inBuf.size < numFrames) {
            inBuf = FloatArray(numFrames)
            driveBuf = FloatArray(numFrames)
            biasBuf = FloatArray(numFrames)
            mixBuf = FloatArray(numFrames)
        }

        input.render(inBuf, numFrames, ctx)
        drive.render(driveBuf, numFrames, ctx)
        bias.render(biasBuf, numFrames, ctx)
        mix.render(mixBuf, numFrames, ctx)

        for (i in 0 until numFrames) {
            val dry = inBuf[i]
            val shaped = when (mode) {
                RectifierMode.HALF_WAVE -> (dry * driveBuf[i] + biasBuf[i]).coerceAtLeast(0f)
                RectifierMode.FULL_WAVE -> abs(dry * driveBuf[i] + biasBuf[i])
                RectifierMode.NEGATIVE_HALF -> (dry * driveBuf[i] + biasBuf[i]).coerceAtMost(0f)
            }
            val wet = mixBuf[i].coerceIn(0f, 1f)
            output[i] = dry * (1f - wet) + shaped * wet
        }
    }
}

StagedWaveFolder – multi-stage analog-style folding

class StagedWaveFolder

A wavefolder: anything that leaves the window between two thresholds is reflected back inside, and that happens stages times over with gain in between.

Reflection is what makes this different from clipping. A sample well past the threshold does not flatten, it comes back down the other way, so raising drive adds folds and therefore harmonics instead of adding loudness. The reflection repeats until the value is inside the window, so the output is bounded by threshold and symmetry no matter what arrives.

symmetry opens one side of the window and tightens the other, which is where the even harmonics come from; bias moves the signal within the window before the first fold; stageGain multiplies between stages so the later folds reach further than the first; makeup trims what comes out.

class StagedWaveFolder(
    val drive: Param = Param(2f),
    val threshold: Param = Param(0.7f),
    /** Additional gain applied between folding stages. */
    val stageGain: Param = Param(1.35f),
    /** Positive values open the positive side and tighten the negative side. */
    val symmetry: Param = Param(0f),
    /** Pre-fold DC offset / bias. */
    val bias: Param = Param(0f),
    /** Wet/dry mix. */
    val mix: Param = Param(1f),
    /** Output trim after the final stage. */
    val makeup: Param = Param(0.8f),
    val stages: Int = 3
) : SignalProcessor() {
    private val safeStages = stages.coerceAtLeast(1)
    private var inBuf = FloatArray(0)
    private var driveBuf = FloatArray(0)
    private var thresholdBuf = FloatArray(0)
    private var stageGainBuf = FloatArray(0)
    private var symmetryBuf = FloatArray(0)
    private var biasBuf = FloatArray(0)
    private var mixBuf = FloatArray(0)
    private var makeupBuf = FloatArray(0)

override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)

body — 56 lines, from line 665
    override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (inBuf.size < numFrames) {
            inBuf = FloatArray(numFrames)
            driveBuf = FloatArray(numFrames)
            thresholdBuf = FloatArray(numFrames)
            stageGainBuf = FloatArray(numFrames)
            symmetryBuf = FloatArray(numFrames)
            biasBuf = FloatArray(numFrames)
            mixBuf = FloatArray(numFrames)
            makeupBuf = FloatArray(numFrames)
        }

        input.render(inBuf, numFrames, ctx)
        drive.render(driveBuf, numFrames, ctx)
        threshold.render(thresholdBuf, numFrames, ctx)
        stageGain.render(stageGainBuf, numFrames, ctx)
        symmetry.render(symmetryBuf, numFrames, ctx)
        bias.render(biasBuf, numFrames, ctx)
        mix.render(mixBuf, numFrames, ctx)
        makeup.render(makeupBuf, numFrames, ctx)

        for (i in 0 until numFrames) {
            val dry = inBuf[i]
            val baseThreshold = thresholdBuf[i].coerceAtLeast(1e-4f)
            val asym = symmetryBuf[i].coerceIn(-0.95f, 0.95f)
            val positiveThreshold = baseThreshold * (1f + asym)
            val negativeThreshold = -baseThreshold * (1f - asym)
            var folded = dry * driveBuf[i] + biasBuf[i]
            val stageDrive = stageGainBuf[i].coerceAtLeast(0f)

            repeat(safeStages) { stage ->
                if (stage > 0 && stageDrive != 1f) folded *= stageDrive
                folded = foldReflect(folded, positiveThreshold, negativeThreshold)
            }

            folded *= makeupBuf[i]
            val wet = mixBuf[i].coerceIn(0f, 1f)
            output[i] = dry * (1f - wet) + folded * wet
        }
    }

    private fun foldReflect(value: Float, positiveThreshold: Float, negativeThreshold: Float): Float {
        val range = positiveThreshold - negativeThreshold
        if (range <= 1e-6f) return value.coerceIn(negativeThreshold, positiveThreshold)

        var x = value
        while (x > positiveThreshold || x < negativeThreshold) {
            x = when {
                x > positiveThreshold -> positiveThreshold - (x - positiveThreshold)
                x < negativeThreshold -> negativeThreshold + (negativeThreshold - x)
                else -> x
            }
        }
        return x
    }
}

Compressor – dynamic range compressor

class Compressor

A peak compressor with a soft knee: above threshold the level is reduced towards ratio, and attack and release decide how quickly the gain follows.

The detector reads the instantaneous absolute value, not an RMS window, so short transients are seen in full and attack is the only thing standing between them and the gain reduction. Smoothing happens in decibels on the target level rather than on the linear gain. kneeWidth is the dB span, centred on the threshold, over which the ratio comes in gradually; set it to zero for a hard knee. makeupGain is applied after, also in dB.

For a guaranteed ceiling, put a Limiter after this one. Even at a high ratio with a fast attack the dB envelope has to catch up with a transient, so some of it gets through.

class Compressor(
    /** Threshold in dB. */
    val threshold: Param = Param(-12f),
    /** Compression ratio (e.g. 4 = 4:1). */
    val ratio: Param = Param(4f),
    /** Attack time in seconds. */
    val attack: Param = Param(0.005f),
    /** Release time in seconds. */
    val release: Param = Param(0.05f),
    /** Makeup gain in dB. */
    val makeupGain: Param = Param(0f),
    /** Knee width in dB (0 = hard knee). */
    val kneeWidth: Float = 6f
) : SignalProcessor() {
    private var envDb = -96f

internal val vizOutputDbTrace = ScalarTrace(capacity = 4096, stride = 8)

internal val vizReductionDbTrace = ScalarTrace(capacity = 4096, stride = 8)

@Volatile internal var vizCurrentGainReductionDb: Float = 0f

@Volatile internal var vizCurrentThresholdDb: Float = threshold.base

@Volatile internal var vizCurrentRatio: Float = ratio.base

    private var inBuf = FloatArray(0)
    private var thBuf = FloatArray(0); private var raBuf = FloatArray(0)
    private var aBuf = FloatArray(0); private var rBuf = FloatArray(0); private var mBuf = FloatArray(0)

override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)

body — 49 lines, from line 764
    override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (inBuf.size < numFrames) {
            inBuf = FloatArray(numFrames); thBuf = FloatArray(numFrames)
            raBuf = FloatArray(numFrames); aBuf = FloatArray(numFrames)
            rBuf = FloatArray(numFrames); mBuf = FloatArray(numFrames)
        }
        input.render(inBuf, numFrames, ctx)
        threshold.render(thBuf, numFrames, ctx)
        ratio.render(raBuf, numFrames, ctx)
        attack.render(aBuf, numFrames, ctx)
        release.render(rBuf, numFrames, ctx)
        makeupGain.render(mBuf, numFrames, ctx)
        val invSr = ctx.inverseSampleRate.toDouble()

        for (i in 0 until numFrames) {
            val x = inBuf[i]
            val xDb = if (abs(x) > 1e-6f) (20.0 * log10(abs(x).toDouble())).toFloat() else -96f
            val th = thBuf[i]; val r = raBuf[i].coerceAtLeast(1f)
            val at = aBuf[i].coerceAtLeast(0.0001f).toDouble()
            val rt = rBuf[i].coerceAtLeast(0.0001f).toDouble()

            // Gain computer with soft knee
            val halfKnee = kneeWidth / 2f
            val gainReduction = when {
                xDb < th - halfKnee -> 0f  // below knee
                xDb > th + halfKnee -> (th + (xDb - th) / r) - xDb  // above knee
                else -> {  // within knee
                    val x2 = xDb - th + halfKnee
                    (x2 * x2 / (4f * kneeWidth) * (1f / r - 1f))
                }
            }

            // Smooth envelope
            val targetDb = xDb + gainReduction
            val coeff = if (targetDb < envDb)
                exp(-invSr / at).toFloat() else exp(-invSr / rt).toFloat()
            envDb = coeff * envDb + (1f - coeff) * targetDb

            val gainDb = envDb - xDb + mBuf[i]
            output[i] = x * 10f.pow(gainDb / 20f)
            val reductionDb = (envDb - xDb).coerceAtMost(0f)
            vizCurrentGainReductionDb = reductionDb
            vizCurrentThresholdDb = th
            vizCurrentRatio = r
            vizReductionDbTrace.record(reductionDb)
            vizOutputDbTrace.record(envDb + mBuf[i])
        }
    }
}

Limiter – brick-wall peak limiter

class Limiter

A brick wall at ceiling dB: gain drops the instant a sample would exceed it and recovers over release.

The attack is not a parameter because there is none. When the required gain is lower than the current gain the limiter jumps straight to it in that sample, so nothing passes the ceiling and there is no lookahead to pay for; the audible cost is that a loud transient pulls the whole signal down with it for as long as release says. This is the last thing in a chain, after any Compressor.

class Limiter(
    /** Ceiling in dB (e.g. -0.3). */
    val ceiling: Param = Param(-0.3f),
    /** Release time in seconds. */
    val release: Param = Param(0.05f)
) : SignalProcessor() {
    private var gain = 1f

internal val vizOutputDbTrace = ScalarTrace(capacity = 4096, stride = 8)

internal val vizReductionDbTrace = ScalarTrace(capacity = 4096, stride = 8)

@Volatile internal var vizCurrentGainReductionDb: Float = 0f

@Volatile internal var vizCurrentCeilingDb: Float = ceiling.base

    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)
        ceiling.render(cBuf, numFrames, ctx)
        release.render(rBuf, numFrames, ctx)
        val invSr = ctx.inverseSampleRate.toDouble()

        for (i in 0 until numFrames) {
            val ceil = 10f.pow(cBuf[i] / 20f)
            val x = abs(inBuf[i])
            val targetGain = if (x > ceil) ceil / x else 1f

            val rt = rBuf[i].coerceAtLeast(0.0001f).toDouble()
            val coeff = if (targetGain < gain) 0f else exp(-invSr / rt).toFloat()
            gain = coeff * gain + (1f - coeff) * targetGain

            output[i] = inBuf[i] * gain
            val outputDb = if (kotlin.math.abs(output[i]) > 1e-6f) (20.0 * log10(kotlin.math.abs(output[i]).toDouble())).toFloat() else -96f
            val reductionDb = (20.0 * log10(gain.coerceAtLeast(1e-6f).toDouble())).toFloat().coerceAtMost(0f)
            vizCurrentGainReductionDb = reductionDb
            vizCurrentCeilingDb = cBuf[i]
            vizReductionDbTrace.record(reductionDb)
            vizOutputDbTrace.record(outputDb)
        }
    }
}

BitCrusher – sample rate & bit depth reduction

class BitCrusher

Quantises amplitude to bits and holds each sample for downsample samples: the two halves of digital degradation, separated.

bits rounds to 2^bits levels, which adds the harmonic grit; downsample is a plain sample-and-hold, which folds anything above the reduced Nyquist back down as aliasing. Neither is filtered, because the aliasing is the effect. Both are Params and both take fractional values, so either can be swept, and a sweep of downsample slides the aliases rather than stepping them.

class BitCrusher(
    /** Effective bit depth (1…32). */
    val bits: Param = Param(8f),
    /** Downsample factor (1 = none, 4 = quarter rate). */
    val downsample: Param = Param(1f)
) : SignalProcessor() {
    private var held = 0f
    private var counter = 0f
    private var inBuf = FloatArray(0); private var bBuf = 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); bBuf = FloatArray(numFrames); dBuf = FloatArray(numFrames)
        }
        input.render(inBuf, numFrames, ctx)
        bits.render(bBuf, numFrames, ctx)
        downsample.render(dBuf, numFrames, ctx)

        for (i in 0 until numFrames) {
            counter += 1f
            val ds = dBuf[i].coerceAtLeast(1f)
            if (counter >= ds) {
                counter -= ds
                val b = bBuf[i].coerceIn(1f, 32f)
                val levels = 2f.pow(b)
                held = kotlin.math.round(inBuf[i] * levels) / levels
            }
            output[i] = held
        }
    }
}

Flanger

class Flanger

One short delay line, swept by an internal LFO at rate and fed back on itself.

The ceiling on the delay is 20 ms, which is what separates this from Chorus: the delayed copy stays close enough to the dry signal to interfere with it, and the moving comb notches are the sound. depth is the widest excursion in seconds and the sweep runs from zero to it. feedback sharpens the notches into resonances and inverts them when negative. The line is read at whole samples.

class Flanger(
    /** LFO rate in Hz. */
    val rate: Param = Param(0.3f),
    /** Max delay time in seconds. */
    val depth: Param = Param(0.003f),
    /** Feedback (-1…+1). */
    val feedback: Param = Param(0.7f),
    val mix: Param = Param(0.5f)
) : SignalProcessor() {
    private val maxDelay = 0.02f
    private var buffer = FloatArray(0)
    private var bufSize = 0
    private var writePos = 0
    private var lfoPhase = 0.0

    private var inBuf = FloatArray(0)
    private var rateBuf = FloatArray(0); private var depthBuf = FloatArray(0)
    private var fbBuf = 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) {
        val needed = (ctx.sampleRate * maxDelay).toInt() + 4
        if (bufSize < needed) { buffer = FloatArray(needed); bufSize = needed; writePos = 0 }
        if (inBuf.size < numFrames) {
            inBuf = FloatArray(numFrames); rateBuf = FloatArray(numFrames)
            depthBuf = FloatArray(numFrames); fbBuf = FloatArray(numFrames)
            mixBuf = FloatArray(numFrames)
        }
        input.render(inBuf, numFrames, ctx)
        rate.render(rateBuf, numFrames, ctx)
        depth.render(depthBuf, numFrames, ctx)
        feedback.render(fbBuf, numFrames, ctx)
        mix.render(mixBuf, numFrames, ctx)
        val invSr = ctx.inverseSampleRate.toDouble()

        for (i in 0 until numFrames) {
            val dp = depthBuf[i].toDouble().coerceIn(0.0, maxDelay.toDouble())
            val delay = dp * (0.5 + 0.5 * sin(2.0 * PI * lfoPhase)) * ctx.sampleRate
            val readIdx = ((writePos - delay.toInt() + bufSize) % bufSize)
            val delayed = buffer[readIdx % bufSize]
            val fb = fbBuf[i].coerceIn(-0.99f, 0.99f)
            buffer[writePos] = inBuf[i] + delayed * fb
            writePos = (writePos + 1) % bufSize
            lfoPhase += rateBuf[i].toDouble() * invSr
            if (lfoPhase >= 1.0) lfoPhase -= 1.0
            val m = mixBuf[i].coerceIn(0f, 1f)
            output[i] = inBuf[i] * (1f - m) + delayed * m
        }
    }
}

RingModulator

class RingModulator

Multiplies the input by an internal sine at carrierFreq.

Every partial in the input comes out as a pair, one at the sum of the two frequencies and one at the difference, and the original is gone: that missing fundamental is why a ring-modulated voice reads as a machine rather than as a filtered voice. carrierFreq is a Param, so it can be swept, or driven from another oscillator for something closer to FM.

class RingModulator(
    val carrierFreq: Param = Param(300f)
) : SignalProcessor() {
    private var phase = 0.0
    private var inBuf = FloatArray(0); private var fBuf = 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); fBuf = FloatArray(numFrames) }
        input.render(inBuf, numFrames, ctx)
        carrierFreq.render(fBuf, numFrames, ctx)
        val invSr = ctx.inverseSampleRate.toDouble()
        for (i in 0 until numFrames) {
            output[i] = inBuf[i] * sin(2.0 * PI * phase).toFloat()
            phase += fBuf[i].toDouble() * invSr
            phase -= floor(phase)
        }
    }
}