Skip to content

DoughDspSmokeTest.kt

src/jvmMain/kotlin/afield/audio/synth/DoughDspSmokeTest.kt · 509 lines

package afield.audio.synth

import kotlin.math.abs
import kotlin.math.log2
import kotlin.math.max
import kotlin.math.sin
import kotlin.math.sqrt

FIELD_TEST id: synth.dough-dsp.smoke runner: jvm-main entrypoint: afield.audio.synth.DoughDspSmokeTest tags: [smoke, synth, dsp, dough, audio] timeoutSec: 60 expect: exitCode: 0 stdoutContains: - "DoughDsp smoke tests passed"

Smoke test for the dough DSP ports: approximations, filter, phaser, reverb and envelope, checked by behaviour rather than by exact samples.

object DoughDspSmokeTest

Numeric checks on the dough DSP ports in DoughDsp.kt.

These assert behaviour rather than exact sample values: that the approximations are within tolerance of kotlin.math, that the filter actually attenuates above cutoff, that the phaser's notch notches, that the reverb produces a decaying decorrelated stereo tail, and that CurvedADSR retriggers without a discontinuity.

object DoughDspSmokeTest {

    private const val SR = 48_000f
    private const val BLOCK = 128

fun main(args: Array<String>)

    @JvmStatic
    fun main(args: Array<String>) {
        check(runTests()) { "DoughDsp smoke tests failed" }
    }

fun runTests(): Boolean

    fun runTests(): Boolean {
        val report = Reporter()

        println("DoughMath")
        checkMath(report)

        println("ChamberlinLowpass")
        checkChamberlin(report)

        println("Phaser")
        checkPhaser(report)

        println("PlateReverb")
        checkPlateReverb(report)

        println("CurvedADSR")
        checkCurvedAdsr(report)

        println("SoftDistort")
        checkSoftDistort(report)

        println("ZawOsc / PulzeOsc")
        checkNaiveOscillators(report)

        return if (report.failures.isEmpty()) {
            println("DoughDsp smoke tests passed")
            true
        } else {
            println("DoughDsp smoke tests FAILED: ${report.failures.joinToString(", ")}")
            false
        }
    }

Collects pass/fail lines so every check runs even after one fails.

    private class Reporter {
        val failures = mutableListOf<String>()

        fun check(name: String, condition: Boolean, detail: String = "") {
            if (condition) {
                println("  ok   $name${if (detail.isEmpty()) "" else "  ($detail)"}")
            } else {
                println("  FAIL $name${if (detail.isEmpty()) "" else "  ($detail)"}")
                failures += name
            }
        }
    }

DoughMath

source — 58 lines, from line 93
    private fun checkMath(report: Reporter) {
        var maxLog2Err = 0.0
        var x = 0.01
        while (x < 20_000.0) {
            val approx = DoughMath.log2(x.toFloat()).toDouble()
            val exact = log2(x)
            maxLog2Err = max(maxLog2Err, abs(approx - exact))
            x *= 1.05
        }
        report.check("log2 within 1e-3 absolute over 0.01…20k", maxLog2Err < 1e-3, "max err %.2e".format(maxLog2Err))

        var maxExp2RelErr = 0.0
        var e = -30.0
        while (e < 30.0) {
            val approx = DoughMath.exp2(e.toFloat()).toDouble()
            val exact = Math.pow(2.0, e)
            maxExp2RelErr = max(maxExp2RelErr, abs(approx - exact) / exact)
            e += 0.031
        }
        report.check("exp2 within 1e-3 relative over -30…30", maxExp2RelErr < 1e-3, "max rel err %.2e".format(maxExp2RelErr))

        report.check("exp2 saturates instead of returning NaN", DoughMath.exp2(1e9f).isFinite() && DoughMath.exp2(-1e9f) == 0f)

        val pow = DoughMath.pow(2f, 10f)
        report.check("pow(2,10) ≈ 1024", abs(pow - 1024f) / 1024f < 2e-3, "got $pow")
        report.check("pow(x<0) is NaN", DoughMath.pow(-1f, 2f).isNaN())
        report.check("pow10(3) ≈ 1000", abs(DoughMath.pow10(3f) - 1000f) / 1000f < 2e-3, "got ${DoughMath.pow10(3f)}")
        report.check("powHalf(1) ≈ 0.5", abs(DoughMath.powHalf(1f) - 0.5f) < 1e-3, "got ${DoughMath.powHalf(1f)}")

        var maxParSinErr = 0.0
        var maxSinFastErr = 0.0
        var t = -12.0
        while (t < 12.0) {
            maxParSinErr = max(maxParSinErr, abs(DoughMath.parSin(t.toFloat()) - sin(t)))
            maxSinFastErr = max(maxSinFastErr, abs(DoughMath.sinFast(t.toFloat()) - sin(t)))
            t += 0.001
        }
        // The parabola is a crude fit — ~5.6e-2 peak error is inherent to it, not a port defect.
        report.check("parSin within 6e-2 absolute", maxParSinErr < 6e-2, "max err %.2e".format(maxParSinErr))
        report.check("sinFast within 1e-4 absolute", maxSinFastErr < 1e-4, "max err %.2e".format(maxSinFastErr))
        report.check(
            "parCos is parSin shifted a quarter turn",
            abs(DoughMath.parCos(0f) - 1f) < 6e-2,
            "parCos(0) = ${DoughMath.parCos(0f)}"
        )

        report.check("ftz flushes denormals", DoughMath.ftz(1e-20f) == 0f && DoughMath.ftz(0.5f) == 0.5f)

        report.check("lerpCurve clamps endpoints", DoughMath.lerpCurve(-1f, 2f, 7f, 2f) == 2f && DoughMath.lerpCurve(2f, 2f, 7f, 2f) == 7f)
        val linear = DoughMath.lerpCurve(0.5f, 0f, 1f, 0f)
        val easeIn = DoughMath.lerpCurve(0.5f, 0f, 1f, 2f)
        val easeOut = DoughMath.lerpCurve(0.5f, 0f, 1f, -2f)
        report.check(
            "lerpCurve ease-in < linear < ease-out at midpoint",
            easeIn < linear && linear < easeOut,
            "easeIn=%.3f linear=%.3f easeOut=%.3f".format(easeIn, linear, easeOut)
        )
    }

ChamberlinLowpass

    private fun checkChamberlin(report: Reporter) {
        // A 6 kHz tone should survive a 12 kHz cutoff and be crushed by a 300 Hz one.
        val passed = rms(renderMono(SineOsc(Param(6000f)).through(ChamberlinLowpass(Param(12000f), Param(0f))), 0.5f))
        val blocked = rms(renderMono(SineOsc(Param(6000f)).through(ChamberlinLowpass(Param(300f), Param(0f))), 0.5f))
        report.check(
            "6 kHz tone attenuated >20x more at 300 Hz cutoff than at 12 kHz",
            blocked < passed / 20f,
            "passed=%.4f blocked=%.4f".format(passed, blocked)
        )

        // Resonance should boost energy at the cutoff, not merely change tone.
        val dry = rms(renderMono(SineOsc(Param(1000f)).through(ChamberlinLowpass(Param(1000f), Param(0f))), 0.5f))
        val resonant = rms(renderMono(SineOsc(Param(1000f)).through(ChamberlinLowpass(Param(1000f), Param(0.9f))), 0.5f))
        report.check(
            "resonance boosts energy at cutoff",
            resonant > dry * 1.2f,
            "dry=%.4f resonant=%.4f".format(dry, resonant)
        )

        // Must stay stable at extreme settings — this is what the c<=1.14 clamp buys.
        val extreme = renderMono(
            SineOsc(Param(200f)).through(ChamberlinLowpass(Param(20_000f), Param(1f))),
            1f
        )
        report.check(
            "stable at cutoff 20 kHz / resonance 1.0",
            extreme.all { it.isFinite() } && extreme.max() < 100f,
            "peak=%.3f".format(extreme.maxOf { abs(it) })
        )
    }

Phaser

    private fun checkPhaser(report: Reporter) {
        // rate = 0 parks the LFO at phase 0, so notch 1 sits exactly on `center`.
        val notchFreq = 1000f
        fun run(inputFreq: Float): Float = rms(
            renderMono(
                SineOsc(Param(inputFreq)).through(
                    Phaser(
                        rate = Param(0f), depth = Param(0f),
                        center = Param(notchFreq), sweep = Param(0f), mix = Param(1f)
                    )
                ),
                0.5f
            )
        )

        val atNotch = run(notchFreq)
        val wellBelow = run(notchFreq / 8f)
        report.check(
            "tone at notch centre attenuated >5x vs three octaves below",
            atNotch < wellBelow / 5f,
            "atNotch=%.5f below=%.5f".format(atNotch, wellBelow)
        )

        // With the LFO running the output must move but stay finite and bounded.
        val swept = renderMono(
            SawOsc(Param(220f)).through(
                Phaser(rate = Param(2f), depth = Param(0.8f), center = Param(800f), sweep = Param(900f))
            ),
            1f
        )
        report.check("swept output finite and bounded", swept.all { it.isFinite() && abs(it) < 8f },
            "peak=%.3f".format(swept.maxOf { abs(it) }))

        val dryRms = rms(renderMono(SawOsc(Param(220f)), 1f))
        report.check("phaser changes the signal", abs(rms(swept) - dryRms) > 1e-4f,
            "dry=%.4f wet=%.4f".format(dryRms, rms(swept)))
    }

PlateReverb

source — 45 lines, from line 227
    private fun checkPlateReverb(report: Reporter) {
        fun tail(decay: Float, mix: Float = 1f): Pair<FloatArray, FloatArray> =
            renderStereo(
                PlateReverb(
                    source = OneShotImpulse(),
                    decay = Param(decay),
                    mix = Param(mix)
                ),
                2f
            )

        val (l, r) = tail(0.85f)
        report.check("tail is finite", l.all { it.isFinite() } && r.all { it.isFinite() })
        report.check("tail is bounded", l.maxOf { abs(it) } < 10f, "peak=%.4f".format(l.maxOf { abs(it) }))

        val late = window(l, 0.5f, 1.0f)
        report.check("impulse produces a non-trivial late tail at 0.5–1.0 s", rms(late) > 1e-5f,
            "rms=%.3e".format(rms(late)))

        // Stereo must be decorrelated, not a copy: the tap sets differ per side.
        val correlation = correlation(window(l, 0.1f, 1.0f), window(r, 0.1f, 1.0f))
        report.check("left and right are decorrelated", abs(correlation) < 0.5f, "corr=%.3f".format(correlation))

        // Energy must decay over time and grow with the decay parameter.
        val early = rms(window(l, 0.05f, 0.25f))
        val later = rms(window(l, 1.0f, 1.5f))
        report.check("tail decays", later < early, "early=%.3e later=%.3e".format(early, later))

        val shortTail = rms(window(tail(0.3f).first, 0.5f, 1.0f))
        val longTail = rms(window(tail(0.9f).first, 0.5f, 1.0f))
        report.check("higher decay = longer tail", longTail > shortTail * 2f,
            "decay0.3=%.3e decay0.9=%.3e".format(shortTail, longTail))

        // mix = 0 must be exactly the dry impulse.
        val (dryL, dryR) = tail(0.85f, mix = 0f)
        report.check("mix=0 passes dry only", dryL[0] == 1f && dryL.drop(1).all { it == 0f } && dryR[0] == 1f)

        // Unscaled mode must also run clean (dough-identical tap lengths).
        val (unscaledL, _) = renderStereo(
            PlateReverb(source = OneShotImpulse(), mix = Param(1f), scaleToSampleRate = false),
            1f
        )
        report.check("scaleToSampleRate=false also produces a finite tail",
            unscaledL.all { it.isFinite() } && rms(window(unscaledL, 0.3f, 0.9f)) > 1e-6f)
    }

CurvedADSR

source — 88 lines, from line 275
    private fun checkCurvedAdsr(report: Reporter) {
        val attack = 0.02f
        val decay = 0.05f
        val sustain = 0.6f
        val release = 0.1f

        val gate = Gate()
        val env = CurvedADSR(
            gate = gate,
            attack = Param(attack), decay = Param(decay),
            sustain = Param(sustain), release = Param(release)
        )

        // Gate on for 0.15 s, off for 0.03 s (mid-release), on again, then off.
        val ctx = SynthContext(SR, BLOCK)
        val buf = FloatArray(BLOCK)
        val trace = ArrayList<Float>()
        val blocksPerSecond = SR / BLOCK
        fun blocks(seconds: Float) = (seconds * blocksPerSecond).toInt()

        val schedule = listOf(
            true to blocks(0.15f),
            false to blocks(0.03f),
            true to blocks(0.15f),
            false to blocks(0.3f)
        )
        val gateDropBlock = schedule[0].second
        val retriggerBlock = schedule[0].second + schedule[1].second
        for ((open, count) in schedule) {
            if (open) gate.open() else gate.close()
            repeat(count) {
                env.render(buf, BLOCK, ctx)
                ctx.advanceCycle()
                for (i in 0 until BLOCK) trace += buf[i]
            }
        }

        val samples = trace.toFloatArray()
        report.check("envelope finite and in [0,1]", samples.all { it.isFinite() && it >= -1e-6f && it <= 1f + 1e-6f },
            "peak=%.4f".format(samples.maxOf { it }))

        val peak = samples.max()
        report.check("reaches full level after attack", peak > 0.99f, "peak=%.4f".format(peak))

        // Sustain plateau: sample just before the gate drops.
        val sustainLevel = samples[gateDropBlock * BLOCK - 1]
        report.check("holds at the sustain level", abs(sustainLevel - sustain) < 0.01f, "got %.4f".format(sustainLevel))

        report.check("returns to zero after release", samples.last() == 0f, "last=%.4f".format(samples.last()))

        // The distinguishing feature: no discontinuity anywhere, including the
        // retrigger part-way through the release segment.
        var maxJump = 0f
        var maxJumpAt = 0
        for (i in 1 until samples.size) {
            val jump = abs(samples[i] - samples[i - 1])
            if (jump > maxJump) { maxJump = jump; maxJumpAt = i }
        }
        report.check(
            "retriggers without a discontinuity",
            maxJump < 0.01f,
            "max jump %.5f at sample %d".format(maxJump, maxJumpAt)
        )

        // For contrast: the same gating through plain ADSR restarts from zero.
        val plainGate = Gate()
        val plain = ADSR(
            gate = plainGate,
            attack = Param(attack), decay = Param(decay),
            sustain = Param(sustain), release = Param(release)
        )
        val plainCtx = SynthContext(SR, BLOCK)
        val plainTrace = ArrayList<Float>()
        for ((open, count) in schedule) {
            if (open) plainGate.open() else plainGate.close()
            repeat(count) {
                plain.render(buf, BLOCK, plainCtx)
                plainCtx.advanceCycle()
                for (i in 0 until BLOCK) plainTrace += buf[i]
            }
        }
        val plainSamples = plainTrace.toFloatArray()
        var plainMaxJump = 0f
        for (i in 1 until plainSamples.size) {
            plainMaxJump = max(plainMaxJump, abs(plainSamples[i] - plainSamples[i - 1]))
        }
        println("       (for contrast, ADSR's max jump under the same gating: %.4f)".format(plainMaxJump))
    }

SoftDistort

    private fun checkSoftDistort(report: Reporter) {
        val clean = renderMono(SineOsc(Param(220f)).through(SoftDistort(Param(0f), Param(1f))), 0.2f)
        val reference = renderMono(SineOsc(Param(220f)), 0.2f)
        var maxDiff = 0f
        for (i in clean.indices) maxDiff = max(maxDiff, abs(clean[i] - reference[i]))
        report.check("amount=0 is near-identity", maxDiff < 2e-3f, "max diff %.2e".format(maxDiff))

        // Hard-driven output must stay bounded by (1+k)/k.
        val driven = renderMono((SineOsc(Param(220f)) * 20f).through(SoftDistort(Param(4f), Param(1f))), 0.2f)
        val k = DoughMath.expm1(4f)
        val bound = (1f + k) / k
        report.check(
            "bounded by (1+k)/k under heavy drive",
            driven.all { it.isFinite() && abs(it) <= bound + 1e-3f },
            "peak=%.4f bound=%.4f".format(driven.maxOf { abs(it) }, bound)
        )

        // Monotonic transfer curve.
        val shaper = SoftDistort(Param(3f), Param(1f))
        val ramp = Ramp()
        val shaped = renderMono(ramp.through(shaper), 0.05f)
        var monotonic = true
        for (i in 1 until shaped.size) if (shaped[i] < shaped[i - 1] - 1e-6f) monotonic = false
        report.check("transfer curve is monotonic", monotonic)

        val gained = renderMono(SineOsc(Param(220f)).through(SoftDistort(Param(1f), Param(0.5f))), 0.2f)
        val ungained = renderMono(SineOsc(Param(220f)).through(SoftDistort(Param(1f), Param(1f))), 0.2f)
        report.check("postGain scales the output", abs(rms(gained) * 2f - rms(ungained)) < 1e-4f,
            "gained=%.4f ungained=%.4f".format(rms(gained), rms(ungained)))
    }

ZawOsc / PulzeOsc

    private fun checkNaiveOscillators(report: Reporter) {
        val zaw = renderMono(ZawOsc(Param(1000f)), 0.2f)
        report.check("zaw spans [-1, 1]", zaw.min() < -0.99f && zaw.max() > 0.99f && zaw.all { it >= -1f && it <= 1f },
            "min=%.3f max=%.3f".format(zaw.min(), zaw.max()))

        // 1 kHz at 48 kHz = 48 samples per period, rising monotonically within one.
        var rising = true
        for (i in 1 until 47) if (zaw[i] <= zaw[i - 1]) rising = false
        report.check("zaw ramps monotonically within a period", rising)
        // 1 kHz over 0.2 s = 200 periods. The period is 48.0000008 samples rather than
        // exactly 48 (inverseSampleRate is a Float), so count wraps instead of indexing one.
        var wraps = 0
        for (i in 1 until zaw.size) if (zaw[i] - zaw[i - 1] < -1f) wraps++
        report.check("zaw completes 200 periods in 0.2 s at 1 kHz", abs(wraps - 200) <= 1, "wraps=$wraps")

        for (duty in listOf(0.25f, 0.5f, 0.75f)) {
            val pulze = renderMono(PulzeOsc(Param(1000f), Param(duty)), 0.5f)
            val positiveFraction = pulze.count { it > 0f }.toFloat() / pulze.size
            report.check(
                "pulze duty $duty matches the positive fraction",
                abs(positiveFraction - duty) < 0.02f,
                "got %.4f".format(positiveFraction)
            )
            report.check("pulze is two-valued", pulze.all { it == 1f || it == -1f }, "")
        }
    }

helpers

A single 1.0 sample followed by silence.

    private class OneShotImpulse : Signal() {
        private var fired = false
        override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
            java.util.Arrays.fill(output, 0, numFrames, 0f)
            if (!fired) { output[0] = 1f; fired = true }
        }
    }

A slow rising ramp from -2 to +2, for probing transfer curves.

source — 71 lines, from line 438
    private class Ramp : Signal() {
        private var v = -2.0
        override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
            for (i in 0 until numFrames) {
                output[i] = v.toFloat()
                v += 4.0 / (SR * 0.05)
            }
        }
    }

    private fun renderMono(signal: Signal, seconds: Float): FloatArray {
        val ctx = SynthContext(SR, BLOCK)
        val total = (seconds * SR).toInt()
        val out = FloatArray(total)
        val buf = FloatArray(BLOCK)
        var written = 0
        while (written < total) {
            signal.render(buf, BLOCK, ctx)
            ctx.advanceCycle()
            val n = minOf(BLOCK, total - written)
            System.arraycopy(buf, 0, out, written, n)
            written += n
        }
        return out
    }

    private fun renderStereo(node: StereoRenderable, seconds: Float): Pair<FloatArray, FloatArray> {
        val ctx = SynthContext(SR, BLOCK)
        val total = (seconds * SR).toInt()
        val outL = FloatArray(total)
        val outR = FloatArray(total)
        val bufL = FloatArray(BLOCK)
        val bufR = FloatArray(BLOCK)
        var written = 0
        while (written < total) {
            node.render(bufL, bufR, BLOCK, ctx)
            ctx.advanceCycle()
            val n = minOf(BLOCK, total - written)
            System.arraycopy(bufL, 0, outL, written, n)
            System.arraycopy(bufR, 0, outR, written, n)
            written += n
        }
        return outL to outR
    }

    private fun window(samples: FloatArray, fromSeconds: Float, toSeconds: Float): FloatArray {
        val from = (fromSeconds * SR).toInt().coerceIn(0, samples.size)
        val to = (toSeconds * SR).toInt().coerceIn(from, samples.size)
        return samples.copyOfRange(from, to)
    }

    private fun rms(samples: FloatArray): Float {
        if (samples.isEmpty()) return 0f
        var sum = 0.0
        for (s in samples) sum += s.toDouble() * s
        return sqrt(sum / samples.size).toFloat()
    }

    private fun correlation(a: FloatArray, b: FloatArray): Float {
        val n = minOf(a.size, b.size)
        if (n == 0) return 0f
        var sumAB = 0.0; var sumAA = 0.0; var sumBB = 0.0
        for (i in 0 until n) {
            sumAB += a[i].toDouble() * b[i]
            sumAA += a[i].toDouble() * a[i]
            sumBB += b[i].toDouble() * b[i]
        }
        if (sumAA == 0.0 || sumBB == 0.0) return 0f
        return (sumAB / sqrt(sumAA * sumBB)).toFloat()
    }
}