Skip to content

DoughDsp.kt

src/jvmMain/kotlin/afield/audio/synth/DoughDsp.kt · 838 lines

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

import afield.data.query.graph.LiveGraphEdge
import afield.data.query.graph.LiveGraphInspectable
import kotlin.math.abs
import kotlin.math.floor

Native Kotlin ports of low-level DSP from dough, carrying dough's AGPL-3.0 terms.

Ported from dough.c (https://codeberg.org/uzu/dough), which is licensed AGPL-3.0. These are line-by-line ports, so this file is a derivative work and the licence travels with it.

Within dough.c, the Dattorro reverb (PlateReverb here) is itself noted as adapted from el-visio's MIT-licensed implementation of the algorithm in Jon Dattorro, "Effect Design Part 1: Reverberator and Other Filters" (1997): https://ccrma.stanford.edu/~dattorro/EffectDesignPart1.pdf

This file complements the WASM binding in Dough.kt rather than competing with it: that path runs the real dough engine and nothing here affects it. What follows is the subset of dough's primitives that the rest of afield.audio.synth does not cover.

  • DoughMath — fast float approximations (log2/exp2/pow/sin)
  • PlateReverb — Dattorro plate reverb, mono in, stereo out
  • Phaser — LFO-swept cascaded notches
  • ChamberlinLowpass — the "superdough" 2-pole lowpass and its resonance curve
  • CurvedADSR — time-based ADSR with click-free retrigger
  • SoftDistort — dough's expm1 k-curve soft clipper
  • ZawOsc and PulzeOsc — naive saw and pulse, aliasing included

Skipped as already covered by this package: polyBLEP saw/pulse/square/ tri (Oscillators.kt, in double precision), white/pink/brown noise (same coefficients), the Audio-EQ-Cookbook biquad (Filters.kt), delay (Effects.kt), flanger/chorus (Effects.kt), bit crush + sample-rate reduction (Effects.kt BitCrusher), and lag (Envelopes.kt Slew). The voice allocator, event/mini-notation parser, orbits and scheduler are out of scope — use the WASM engine for those.

The oscillators here live alongside the rest of the port rather than in Oscillators.kt so that the licence attribution above covers one file.

DoughMath – fast float approximations

object DoughMath

dough's hand-rolled float math. Cheaper and less accurate than kotlin.math; the inaccuracy is audible and is part of the character of ChamberlinLowpass and Phaser, which recompute coefficients with these on every sample.

Accuracy, as measured by DoughDspSmokeTest: - log2 8e-5 absolute over 0.01…20 kHz - exp2 4e-5 relative over -30…30 - sinFast 1e-6 absolute - parSin 5.6e-2 absolute — it is a parabola, not a series, and that peak error is inherent to the fit. It is accurate enough for an LFO but coarse for filter coefficients: it is why Phaser's notches bottom out around -18 dB instead of nulling. dough accepts that trade, and so does this port; reach for sinFast or kotlin.math.sin if you need a deeper null.

object DoughMath {

const val PI_F: Float = Math.PI.toFloat()

    private const val EXP_SHIFT = 23
    private const val MANTISSA_MASK = (1 shl EXP_SHIFT) - 1

fun log2(x: Float): Float

Approximate base-2 logarithm via the exponent bits.

Only valid for x > 0 — the caller is responsible for that (as it is in dough, where only pow calls this).

    fun log2(x: Float): Float {
        val bits = x.toRawBits()
        // mantissa renormalised into [0.5, 1) — "bends" the linear significand
        val mantissa = Float.fromBits((bits and MANTISSA_MASK) or ((127 - 1) shl EXP_SHIFT))
        val y = bits.toFloat() * (1f / (1 shl EXP_SHIFT))
        return y - 124.22544637f - 1.498030302f * mantissa - 1.72587999f / (0.3520887068f + mantissa)
    }

fun exp2(x: Float): Float

Approximate 2^x: exact power-of-two step from the exponent bits, times a 4th-order polynomial over the fractional part.

dough leaves the out-of-range exponent shift undefined; here it saturates instead, so a runaway parameter can never inject NaN into an audio buffer.

    fun exp2(x: Float): Float {
        if (x < -126f) return 0f
        if (x > 127f) return Float.MAX_VALUE
        val xf = floor(x)
        val ystep = Float.fromBits((127 + xf.toInt()) shl EXP_SHIFT)
        val x1 = x - xf
        val xt = x1 - 0.5f
        val yTaylor = 1.4142135623730951f +
            xt * (0.9802581434685472f +
                xt * (0.3397315841830749f +
                    xt * (0.07849466324122069f +
                        xt * 0.013602088628663625f)))
        val m0 = 0.9999443187818418f
        val m1 = 1.0000312541780023f
        return ystep * yTaylor * (m0 + (m1 - m0) * x1)
    }

fun pow(x: Float, y: Float): Float

Approximate x^y. Returns NaN for x < 0, matching dough.

    fun pow(x: Float, y: Float): Float {
        if (x < 0f) return Float.NaN
        if (x == 0f) return 0f
        if (y == 0f) return if (x >= 0f) 1f else -1f
        return exp2(y * log2(x))
    }

fun expm1(x: Float): Float = exp2(x * 1.4426950408889634f) - 1f

Approximate e^x - 1.

fun powHalf(x: Float): Float = exp2(-x)

Approximate 0.5^x.

fun pow10(x: Float): Float = exp2(x * 3.321928094887362f)

Approximate 10^x.

fun modPi(x: Float): Float

Wraps x into [-PI, PI].

    fun modPi(x: Float): Float {
        var v = x + PI_F
        v *= 0.5f / PI_F
        v -= floor(v)
        v *= 2f * PI_F
        return v - PI_F
    }

fun parSin(x: Float): Float

"Sine" made from a parabola — cheap, ~1e-3 absolute error.

    fun parSin(x: Float): Float {
        val v = modPi(x)
        return 0.40528473456935094f * v * (PI_F - abs(v))
    }

fun parCos(x: Float): Float = parSin(x + 0.5f * PI_F)

parSin shifted by a quarter turn.

fun sinFast(x: Float): Float

Sine via a Padé approximant — more accurate and costlier than parSin.

    fun sinFast(x: Float): Float {
        val t = (4f * abs(x * (0.5f / PI_F) - floor(x * (0.5f / PI_F) + 0.75f) + 0.25f) - 1f) * (PI_F / 2f)
        val tt = t * t
        val num = t * (1f + tt * (-(2363f / 18183f) + tt * (12671f / 4363920f)))
        val denom = 1f + tt * ((445f / 12122f) + tt * ((601f / 872784f) + tt * (121f / 16662240f)))
        return num / denom
    }

fun ftz(x: Float, limit: Float = 1e-15f): Float = if (x < limit && x > -limit) 0f else x

Flush values below limit to zero — keeps denormals out of feedback paths.

fun lerpCurve(x: Float, y0: Float, y1: Float, exponent: Float): Float

Interpolate y0 → y1 over x in [0, 1] with a curve exponent: 0 = linear, > 0 = ease-in, < 0 = ease-out.

    fun lerpCurve(x: Float, y0: Float, y1: Float, exponent: Float): Float {
        if (x <= 0f) return y0
        if (x >= 1f) return y1
        val curved = when {
            exponent == 0f -> x
            exponent > 0f -> pow(x, exponent)
            else -> 1f - pow(1f - x, -exponent)
        }
        return y0 + (y1 - y0) * curved
    }
}

PlateReverb – Dattorro plate reverb (mono in → stereo out)

class PlateReverb

Jon Dattorro's plate reverb: a pre-delay and input-diffusion chain feeding a figure-of-eight tank of two cross-coupled halves, each with its own all-pass diffusers and damping lowpass. The stereo image comes from summing seven taps per side at points scattered through the tank, so it is decorrelated rather than merely panned — this is what makes it sound different from FreeVerb and SpringReverb.

Mono in, stereo out, so it is a StereoNode rather than a SignalProcessor — chain the mono part first, then wrap:

val wet = PlateReverb(saw(110) / lpf(1200), decay = 0.85.p, mix = 0.4.p)
audio { wet }

Everything except mix is read once per block (dough drives these from control-rate event parameters); mix is read per sample.

The tap lengths are dough's, tuned for 44.1 kHz. By default they are rescaled to the running sample rate so the time constants hold; pass scaleToSampleRate = false for behaviour identical to dough.

class PlateReverb(
    private val source: Signal,
    /** Pre-delay, 0…1 of ~109 ms. */
    val preDelay: Param = Param(0.1f),
    /** Input lowpass coefficient, 0…1 (higher = brighter). */
    val preFilter: Param = Param(0.85f),
    /** Gain of the first two input all-pass diffusers. */
    val inputDiffusion1: Param = Param(0.75f),
    /** Gain of the second two input all-pass diffusers. */
    val inputDiffusion2: Param = Param(0.625f),
    /** Tank all-pass diffusion. */
    val decayDiffusion: Param = Param(0.70f),
    /** Tank feedback, clamped to 0.99. */
    val decay: Param = Param(0.75f),
    /** Tank lowpass coefficient, 0…1 (higher = brighter tail). */
    val damping: Param = Param(0.95f),
    /** Dry/wet mix (0 = dry, 1 = wet). */
    val mix: Param = Param(0.3f),
    val scaleToSampleRate: Boolean = true
) : StereoNode(), LiveGraphInspectable {

companion object

    companion object {
        private const val TAP_MAIN = 0
        private const val TAP_OUT1 = 1
        private const val TAP_OUT2 = 2
        private const val TAP_OUT3 = 3
        private const val NUM_TAPS = 4

Pre-delay line length in samples at the 44.1 kHz tuning rate.

        private const val MAX_PREDELAY = 4800
        private const val TUNING_SAMPLE_RATE = 44100f
    }

Power-of-two delay line addressed by a free-running counter.

Each tap stores readOffset = size - delay, so a read is buffer[(t + readOffset) and mask]. t wraps at 65536 and every size divides that, so the wrap is invisible to the masking.

source — 87 lines, from line 244
    private class VerbLine(delay: Int) {
        val buffer: FloatArray
        val mask: Int
        val readOffset = IntArray(NUM_TAPS)

        init {
            // smallest power of two strictly greater than delay
            var numBits = 0
            var x = delay
            while (x != 0) { numBits++; x = x shr 1 }
            val size = 1 shl numBits
            buffer = FloatArray(size)
            mask = size - 1
            setDelay(TAP_MAIN, delay)
        }

        fun setDelay(tap: Int, delay: Int) { readOffset[tap] = mask + 1 - delay }

        fun write(t: Int, value: Float) { buffer[t and mask] = value }

        fun read(tap: Int, t: Int): Float = buffer[(t + readOffset[tap]) and mask]

        fun process(t: Int, value: Float): Float {
            buffer[t and mask] = value
            return buffer[(t + readOffset[TAP_MAIN]) and mask]
        }

        fun allPass(t: Int, gain: Float, input: Float): Float {
            val delayed = read(TAP_MAIN, t)
            val v = input - delayed * gain
            write(t, v)
            return delayed + v * gain
        }
    }

    private lateinit var preDelayLine: VerbLine
    private lateinit var inDiffusion: Array<VerbLine>
    private lateinit var decayDiffusion1: Array<VerbLine>
    private lateinit var preDampingDelay: Array<VerbLine>
    private lateinit var decayDiffusion2: Array<VerbLine>
    private lateinit var postDampingDelay: Array<VerbLine>

    private var preFilterState = 0f
    private val dampingState = FloatArray(2)
    private var t = 0
    private var maxPreDelaySamples = MAX_PREDELAY
    private var initialized = false

    private var srcBuf = FloatArray(0)
    private var mixBuf = FloatArray(0)
    private var pdBuf = FloatArray(0); private var pfBuf = FloatArray(0)
    private var id1Buf = FloatArray(0); private var id2Buf = FloatArray(0)
    private var ddBuf = FloatArray(0); private var decBuf = FloatArray(0)
    private var dmpBuf = FloatArray(0)

    private fun init(sampleRate: Float) {
        val scale = if (scaleToSampleRate) sampleRate / TUNING_SAMPLE_RATE else 1f
        fun s(n: Int): Int = (n * scale).toInt().coerceAtLeast(1)

        maxPreDelaySamples = s(MAX_PREDELAY)
        preDelayLine = VerbLine(maxPreDelaySamples)

        inDiffusion = arrayOf(VerbLine(s(142)), VerbLine(s(107)), VerbLine(s(379)), VerbLine(s(277)))
        decayDiffusion1 = arrayOf(VerbLine(s(672)), VerbLine(s(908)))

        preDampingDelay = arrayOf(VerbLine(s(4453)), VerbLine(s(4217)))
        preDampingDelay[0].setDelay(TAP_OUT1, s(353))
        preDampingDelay[0].setDelay(TAP_OUT2, s(3627))
        preDampingDelay[0].setDelay(TAP_OUT3, s(1990))
        preDampingDelay[1].setDelay(TAP_OUT1, s(266))
        preDampingDelay[1].setDelay(TAP_OUT2, s(2974))
        preDampingDelay[1].setDelay(TAP_OUT3, s(2111))

        decayDiffusion2 = arrayOf(VerbLine(s(1800)), VerbLine(s(2656)))
        decayDiffusion2[0].setDelay(TAP_OUT1, s(187))
        decayDiffusion2[0].setDelay(TAP_OUT2, s(1228))
        decayDiffusion2[1].setDelay(TAP_OUT1, s(335))
        decayDiffusion2[1].setDelay(TAP_OUT2, s(1913))

        postDampingDelay = arrayOf(VerbLine(s(3720)), VerbLine(s(3163)))
        postDampingDelay[0].setDelay(TAP_OUT1, s(1066))
        postDampingDelay[0].setDelay(TAP_OUT2, s(2673))
        postDampingDelay[1].setDelay(TAP_OUT1, s(121))
        postDampingDelay[1].setDelay(TAP_OUT2, s(1996))

        initialized = true
    }

resolved parameters, refreshed once per block

    private var preFilterAmount = 0.85f
    private var inputDiffusion1Amount = 0.75f
    private var inputDiffusion2Amount = 0.625f
    private var decayDiffusion1Amount = 0.70f
    private var decayDiffusion2Amount = 0.5f
    private var decayAmount = 0.75f
    private var dampingAmount = 0.95f

override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext)

body — 97 lines, from line 341
    override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (!initialized) init(ctx.sampleRate)
        if (srcBuf.size < numFrames) {
            srcBuf = FloatArray(numFrames); mixBuf = FloatArray(numFrames)
            pdBuf = FloatArray(numFrames); pfBuf = FloatArray(numFrames)
            id1Buf = FloatArray(numFrames); id2Buf = FloatArray(numFrames)
            ddBuf = FloatArray(numFrames); decBuf = FloatArray(numFrames)
            dmpBuf = FloatArray(numFrames)
        }
        source.render(srcBuf, numFrames, ctx)
        mix.render(mixBuf, numFrames, ctx)
        preDelay.render(pdBuf, numFrames, ctx)
        preFilter.render(pfBuf, numFrames, ctx)
        inputDiffusion1.render(id1Buf, numFrames, ctx)
        inputDiffusion2.render(id2Buf, numFrames, ctx)
        decayDiffusion.render(ddBuf, numFrames, ctx)
        decay.render(decBuf, numFrames, ctx)
        damping.render(dmpBuf, numFrames, ctx)

        // control-rate parameters: sampled once per block
        preDelayLine.setDelay(
            TAP_MAIN,
            (pdBuf[0].coerceIn(0f, 1f) * maxPreDelaySamples).toInt().coerceIn(1, maxPreDelaySamples)
        )
        preFilterAmount = pfBuf[0].coerceIn(0f, 1f)
        inputDiffusion1Amount = id1Buf[0].coerceIn(0f, 0.99f)
        inputDiffusion2Amount = id2Buf[0].coerceIn(0f, 0.99f)
        decayDiffusion1Amount = ddBuf[0].coerceIn(0f, 0.99f)
        decayAmount = decBuf[0].coerceIn(0f, 0.99f)
        decayDiffusion2Amount = (decayAmount + 0.15f).coerceIn(0.25f, 0.50f)
        dampingAmount = dmpBuf[0].coerceIn(0f, 1f)

        for (i in 0 until numFrames) {
            process(srcBuf[i])
            val m = mixBuf[i].coerceIn(0f, 1f)
            val dry = srcBuf[i] * (1f - m)
            outL[i] = dry + left() * m
            outR[i] = dry + right() * m
        }
    }

    private fun process(input: Float) {
        // Slowly modulate the tank all-pass delays to break up ringing.
        // t wraps at 65536, so this walks down then back up.
        if ((t and 0x07ff) == 0) {
            if (t < (1 shl 15)) {
                decayDiffusion1[0].readOffset[TAP_MAIN]--
                decayDiffusion1[1].readOffset[TAP_MAIN]--
            } else {
                decayDiffusion1[0].readOffset[TAP_MAIN]++
                decayDiffusion1[1].readOffset[TAP_MAIN]++
            }
        }

        var x = preDelayLine.process(t, input)

        preFilterState += (x - preFilterState) * preFilterAmount
        x = preFilterState

        x = inDiffusion[0].allPass(t, inputDiffusion1Amount, x)
        x = inDiffusion[1].allPass(t, inputDiffusion1Amount, x)
        x = inDiffusion[2].allPass(t, inputDiffusion2Amount, x)
        x = inDiffusion[3].allPass(t, inputDiffusion2Amount, x)

        for (i in 0 until 2) {
            // cross-feed from the opposite half of the tank
            var x1 = x + postDampingDelay[1 - i].read(TAP_MAIN, t) * decayAmount
            x1 = decayDiffusion1[i].allPass(t, -decayDiffusion1Amount, x1)
            x1 = preDampingDelay[i].process(t, x1)
            dampingState[i] += (x1 - dampingState[i]) * dampingAmount
            x1 = dampingState[i] * decayAmount
            x1 = decayDiffusion2[i].allPass(t, decayDiffusion2Amount, x1)
            // ftz is ours, not dough's: the tank runs forever, so without it a
            // decaying tail leaves denormals circulating in the feedback path.
            postDampingDelay[i].write(t, DoughMath.ftz(x1))
        }

        t = (t + 1) and 0xFFFF
    }

    private fun left(): Float =
        preDampingDelay[1].read(TAP_OUT1, t) +
            preDampingDelay[1].read(TAP_OUT2, t) -
            decayDiffusion2[1].read(TAP_OUT2, t) +
            postDampingDelay[1].read(TAP_OUT2, t) -
            preDampingDelay[0].read(TAP_OUT3, t) -
            decayDiffusion2[0].read(TAP_OUT1, t) +
            postDampingDelay[0].read(TAP_OUT1, t)

    private fun right(): Float =
        preDampingDelay[0].read(TAP_OUT1, t) +
            preDampingDelay[0].read(TAP_OUT2, t) -
            decayDiffusion2[0].read(TAP_OUT2, t) +
            postDampingDelay[0].read(TAP_OUT2, t) -
            preDampingDelay[1].read(TAP_OUT3, t) -
            decayDiffusion2[1].read(TAP_OUT1, t) +
            postDampingDelay[1].read(TAP_OUT1, t)

override fun liveGraphChildren(): Iterable<LiveGraphEdge>

    override fun liveGraphChildren(): Iterable<LiveGraphEdge> = listOf(
        LiveGraphEdge("source") { source },
        LiveGraphEdge("decay") { decay },
        LiveGraphEdge("damping") { damping },
        LiveGraphEdge("mix") { mix }
    )
}

Phaser – LFO-swept cascaded notches

class Phaser

Two cascaded notch filters swept by a sine LFO, the second offset 282 Hz above the first. Detuning is exponential (in cents), so the sweep sounds even across the range, and depth widens the notches by lowering their Q rather than by moving them further.

val swirl = supersaw / phaser(rate = 0.4, depth = 0.7, center = 900)

Coefficients are recomputed every sample with DoughMath.parSin / DoughMath.parCos, as in dough.

class Phaser(
    /** LFO rate in Hz. */
    val rate: Param = Param(0.5f),
    /** 0…1; widens the notches by dropping Q from 2.0 to 0.1. */
    val depth: Param = Param(0.5f),
    /** Centre frequency of the first notch, in Hz. */
    val center: Param = Param(1000f),
    /** LFO sweep range in cents. */
    val sweep: Param = Param(600f),
    /** Dry/wet mix (0 = dry, 1 = wet). */
    val mix: Param = Param(1f)
) : SignalProcessor() {

Direct-form-I notch, recooked per sample.

    private class NotchStage {
        private var x1 = 0f; private var x2 = 0f
        private var y1 = 0f; private var y2 = 0f

        fun process(input: Float, freq: Float, q: Float, sampleRate: Float): Float {
            val omega = 2f * DoughMath.PI_F * freq / sampleRate
            val sinOmega = DoughMath.parSin(omega)
            val cosOmega = DoughMath.parCos(omega)
            val alpha = sinOmega / (2f * q)

            val a0 = 1f + alpha
            val b0 = 1f / a0
            val b1 = -2f * cosOmega / a0
            val b2 = b0
            val a1 = -2f * cosOmega / a0
            val a2 = (1f - alpha) / a0

            val output = b0 * input + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
            x2 = x1; x1 = input
            y2 = y1; y1 = DoughMath.ftz(output)
            return output
        }
    }

    private val stage1 = NotchStage()
    private val stage2 = NotchStage()
    private var lfoPhase = 0.0

    private var inBuf = FloatArray(0)
    private var rateBuf = FloatArray(0); private var depthBuf = FloatArray(0)
    private var centerBuf = FloatArray(0); private var sweepBuf = 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); rateBuf = FloatArray(numFrames)
            depthBuf = FloatArray(numFrames); centerBuf = FloatArray(numFrames)
            sweepBuf = FloatArray(numFrames); mixBuf = FloatArray(numFrames)
        }
        input.render(inBuf, numFrames, ctx)
        rate.render(rateBuf, numFrames, ctx)
        depth.render(depthBuf, numFrames, ctx)
        center.render(centerBuf, numFrames, ctx)
        sweep.render(sweepBuf, numFrames, ctx)
        mix.render(mixBuf, numFrames, ctx)

        val sr = ctx.sampleRate
        val maxFreq = sr * 0.45f
        val invSr = ctx.inverseSampleRate.toDouble()

        for (i in 0 until numFrames) {
            val lfo = DoughMath.sinFast((lfoPhase * 2.0 * Math.PI).toFloat())
            lfoPhase += rateBuf[i].toDouble() * invSr
            lfoPhase -= floor(lfoPhase)

            val q = 2f - (depthBuf[i] * 2f).coerceIn(0f, 1.9f)
            val detune = DoughMath.exp2(lfo * sweepBuf[i] / 1200f)
            val c = centerBuf[i]
            val freq1 = (c * detune).coerceIn(20f, maxFreq)
            val freq2 = ((c + 282f) * detune).coerceIn(20f, maxFreq)

            var wet = stage1.process(inBuf[i], freq1, q, sr)
            wet = stage2.process(wet, freq2, q, sr)

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

ChamberlinLowpass – the "superdough" 2-pole lowpass

class ChamberlinLowpass

Two-pole Chamberlin-style state-variable lowpass, in the exact form strudel/superdough uses. Distinct from SVF (trapezoidal, zero-delay feedback) in both topology and, more audibly, in the resonance curve: damping is 0.5^((resonance + 0.125) / 0.125), so the useful range is squeezed into the top of the resonance knob and it snarls rather than whistles as it approaches self-oscillation.

Lowpass only — dough reaches for a biquad (Biquad) for highpass and bandpass, and the highpass output is not available from this topology.

The c coefficient is clamped to 1.14, which is what keeps the filter stable at high cutoffs; consequently the cutoff flattens out somewhat above roughly a fifth of the sample rate.

class ChamberlinLowpass(
    /** Cutoff in Hz. */
    val cutoff: Param = Param(1000f),
    /** 0…1; higher is more resonant. */
    val resonance: Param = Param(0f)
) : SignalProcessor() {

    private var s0 = 0f
    private var s1 = 0f

    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 invSr = ctx.inverseSampleRate

        for (i in 0 until numFrames) {
            val c = (2f * DoughMath.parSin(cBuf[i] * invSr * DoughMath.PI_F)).coerceIn(0f, 1.14f)
            val r = DoughMath.powHalf((rBuf[i] + 0.125f) / 0.125f)
            val mrc = 1f - r * c

            var v0 = s0
            var v1 = s1
            v0 = mrc * v0 - c * v1 + c * inBuf[i]
            v1 = mrc * v1 + c * v0

            s0 = DoughMath.ftz(v0)
            s1 = DoughMath.ftz(v1)
            output[i] = v1
        }
    }
}

CurvedADSR – time-based ADSR with click-free retrigger

class CurvedADSR

ADSR with separately shaped attack and decay curves.

Two behaviours distinguish it from ADSR: - Release from anywhere. Dropping the gate during the decay segment releases from the level reached at that instant, rather than waiting for decay to finish. - Click-free retrigger. Raising the gate during release restarts the attack from the current level instead of from zero, so rapid retriggering does not produce a discontinuity.

attackCurve and decayCurve are exponents: attack eases in as t^attackCurve, decay and release ease out as 1 - (1-t)^decayCurve. dough's defaults are 2.0 for both.

class CurvedADSR(
    val gate: Signal = Gate(),
    val attack: Param = Param(0.01f),
    val decay: Param = Param(0.1f),
    val sustain: Param = Param(0.7f),
    val release: Param = Param(0.3f),
    /** Attack shaping exponent; > 1 eases in. */
    val attackCurve: Float = 2f,
    /** Decay/release shaping exponent; > 1 eases out. */
    val decayCurve: Float = 2f
) : Signal() {

    private enum class Stage { OFF, ATTACK, DECAY, SUSTAIN, RELEASE }

    private var stage = Stage.OFF
    private var startTime = 0f
    private var startVal = 0f
    private var elapsed = 0f

    private var gateBuf = FloatArray(0)
    private var aBuf = FloatArray(0); private var dBuf = FloatArray(0)
    private var sBuf = FloatArray(0); private var rBuf = FloatArray(0)

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

body — 89 lines, from line 650
    override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (gateBuf.size < numFrames) {
            gateBuf = FloatArray(numFrames)
            aBuf = FloatArray(numFrames); dBuf = FloatArray(numFrames)
            sBuf = FloatArray(numFrames); rBuf = FloatArray(numFrames)
        }
        gate.render(gateBuf, numFrames, ctx)
        attack.render(aBuf, numFrames, ctx)
        decay.render(dBuf, numFrames, ctx)
        sustain.render(sBuf, numFrames, ctx)
        release.render(rBuf, numFrames, ctx)

        val dt = ctx.inverseSampleRate

        for (i in 0 until numFrames) {
            // guarded against zero so time/segment can never be NaN
            val a = aBuf[i].coerceAtLeast(1e-6f)
            val d = dBuf[i].coerceAtLeast(1e-6f)
            val s = sBuf[i].coerceIn(0f, 1f)
            val r = rBuf[i].coerceAtLeast(1e-6f)
            output[i] = step(gateBuf[i], a, d, s, r)
            elapsed += dt
        }
    }

    private fun step(gate: Float, attack: Float, decay: Float, sustain: Float, release: Float): Float {
        when (stage) {
            Stage.OFF -> {
                if (gate > 0f) {
                    stage = Stage.ATTACK
                    startTime = elapsed
                    startVal = 0f
                }
                return 0f
            }

            Stage.ATTACK -> {
                val time = elapsed - startTime
                if (time > attack) {
                    stage = Stage.DECAY
                    startTime = elapsed
                    return 1f
                }
                return DoughMath.lerpCurve(time / attack, startVal, 1f, attackCurve)
            }

            Stage.DECAY -> {
                val time = elapsed - startTime
                val current = DoughMath.lerpCurve(time / decay, 1f, sustain, -decayCurve)
                if (gate <= 0f) {
                    stage = Stage.RELEASE
                    startTime = elapsed
                    startVal = current
                    return current
                }
                if (time > decay) {
                    stage = Stage.SUSTAIN
                    startTime = elapsed
                    return sustain
                }
                return current
            }

            Stage.SUSTAIN -> {
                if (gate <= 0f) {
                    stage = Stage.RELEASE
                    startTime = elapsed
                    startVal = sustain
                }
                return sustain
            }

            Stage.RELEASE -> {
                val time = elapsed - startTime
                if (time > release) {
                    stage = Stage.OFF
                    return 0f
                }
                val current = DoughMath.lerpCurve(time / release, startVal, 0f, -decayCurve)
                if (gate > 0f) {
                    stage = Stage.ATTACK
                    startTime = elapsed
                    startVal = current
                }
                return current
            }
        }
    }
}

SoftDistort – expm1 k-curve soft clipper

class SoftDistort

The soft clipper from superdough: y = (1+k)x / (1 + k|x|) with k = e^amount - 1, so amount maps to drive exponentially and the output stays bounded by (1+k)/k however hard it is pushed.

At amount = 0 it is a straight wire. Unlike Waveshaper.tanh the knee stays soft at high drive, which is why it stacks well under PlateReverb.

class SoftDistort(
    /** Drive; 0 = clean. */
    val amount: Param = Param(1f),
    /** Output gain applied after shaping. */
    val postGain: Param = Param(1f)
) : SignalProcessor() {

    private var inBuf = FloatArray(0)
    private var aBuf = 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); aBuf = FloatArray(numFrames); gBuf = FloatArray(numFrames)
        }
        input.render(inBuf, numFrames, ctx)
        amount.render(aBuf, numFrames, ctx)
        postGain.render(gBuf, numFrames, ctx)

        for (i in 0 until numFrames) {
            val x = inBuf[i]
            val k = DoughMath.expm1(aBuf[i].coerceAtLeast(0f))
            output[i] = (((1f + k) * x) / (1f + k * abs(x))) * gBuf[i]
        }
    }
}

ZawOsc / PulzeOsc – naive, deliberately aliasing oscillators

class ZawOsc

Naive sawtooth — the raw phase ramp, with no band-limiting.

dough calls this zaw to distinguish it from its band-limited saw. The aliasing is the point: at high frequencies the foldover adds a gritty inharmonic shimmer that SawOsc (PolyBLEP) removes. Use SawOsc for anything that needs to stay clean.

class ZawOsc(
    val freq: Param = Param(440f)
) : Signal() {
    private var phase: Double = 0.0
    private var freqBuf = FloatArray(0)

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

    override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (freqBuf.size < numFrames) freqBuf = FloatArray(numFrames)
        freq.render(freqBuf, numFrames, ctx)
        val invSr = ctx.inverseSampleRate.toDouble()
        for (i in 0 until numFrames) {
            output[i] = (phase * 2.0 - 1.0).toFloat()
            phase += freqBuf[i].toDouble() * invSr
            phase -= floor(phase)
        }
    }
}

class PulzeOsc

Naive variable-width pulse — a hard comparator on the phase ramp, with no band-limiting. dough calls this pulze; see ZawOsc on why the aliasing is wanted, and PulseOsc for the clean version.

class PulzeOsc(
    val freq: Param = Param(440f),
    /** Duty cycle 0…1 (0.5 = square). */
    val duty: Param = Param(0.5f)
) : Signal() {
    private var phase: Double = 0.0
    private var freqBuf = FloatArray(0)
    private var dutyBuf = FloatArray(0)

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

    override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (freqBuf.size < numFrames) {
            freqBuf = FloatArray(numFrames); dutyBuf = FloatArray(numFrames)
        }
        freq.render(freqBuf, numFrames, ctx)
        duty.render(dutyBuf, numFrames, ctx)
        val invSr = ctx.inverseSampleRate.toDouble()
        for (i in 0 until numFrames) {
            output[i] = if (phase < dutyBuf[i]) 1f else -1f
            phase += freqBuf[i].toDouble() * invSr
            phase -= floor(phase)
        }
    }
}