// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later

package space.rcmd.android.sizzle.audio

import space.rcmd.android.sizzle.model.DelayDivisions
import space.rcmd.android.sizzle.model.TimeDivision
import space.rcmd.android.sizzle.model.ToolboxSlot
import space.rcmd.android.sizzle.model.ToolboxType
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.tanh

/**
 * Real-time audio effect processors used by the mixer's per-channel insert
 * chains. Each effect reads its settings from the owning [ToolboxSlot] once per
 * audio block (via [update]) and then processes the signal one sample at a time
 * (via [process]) on the audio thread — so [process] must never allocate.
 *
 * The MIDI effects (arpeggiator / transposer / LFO) are NOT here: they alter note
 * generation rather than the audio signal, and hook into the sequencer instead.
 * Only the signal-processing effect types return a processor from [create].
 */
interface AudioEffect {
    /** Refresh cached parameters from the slot. Called once per block. */
    fun update(slot: ToolboxSlot, tempoBpm: Float)
    /** Process one mono sample. */
    fun process(x: Float): Float

    companion object {
        fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
            ToolboxType.DELAY -> TapeDelay(sampleRate)
            ToolboxType.AMBIENCE -> AmbienceReverb(sampleRate)
            ToolboxType.FILTER -> BiquadFilterEffect(sampleRate)
            ToolboxType.BITCRUSHER -> Bitcrusher()
            ToolboxType.EQ10 -> GraphicEq(sampleRate)
            ToolboxType.MIX_TIGHTENER -> MixTightener(sampleRate)
            ToolboxType.TAPE_ENGINE -> TapeEngine(sampleRate)
            ToolboxType.GRANULAR -> GranularGlitch(sampleRate)
            else -> null // arpeggiator / transposer / LFO are handled by the sequencer
        }
    }
}

/**
 * An effect that publishes a small visualization snapshot for its editor to draw.
 * The audio thread writes into plain fields/arrays; the UI reads them at frame rate
 * (a stale value for one frame is harmless, so no locking is used). Read only through
 * the engine, which hands back the live processor for the edited slot.
 */
interface VizEffect {
    /** Copy the most recent scope samples (oldest → newest) into [out]; return the
     *  count written (0 if nothing yet). */
    fun copyScope(out: FloatArray): Int
    /** Current read/playhead position within the scope window, 0 (oldest) … 1 (newest). */
    fun playhead(): Float
}

// ---------------------------------------------------------------------------
// Multi-tap tape delay: one shared delay line read by up to four independent
// "tape heads", each with its own tempo-synced division and on/off switch. A tape
// character control adds saturation, per-repeat darkening and wow/flutter.
//
// The wet signal is the AVERAGE of the active heads and feedback is hard-capped
// below 1, so the round-trip loop gain can never exceed the feedback amount — a
// runaway positive-feedback loop is impossible regardless of how many heads are on.
// ---------------------------------------------------------------------------
private class TapeDelay(private val sampleRate: Int) : AudioEffect {
    private val buffer = FloatArray(sampleRate * 4)              // up to 4 s of delay line
    private var writeIndex = 0
    private val delaySamples = IntArray(DelayDivisions.HEADS) { sampleRate / 2 }
    private val headOn = BooleanArray(DelayDivisions.HEADS)
    private var feedback = 0.4f
    private var dryWet = 0.35f
    // Tape character:
    private var satBlend = 0f          // 0 = clean, 1 = full soft saturation on peaks
    private var lpAlpha = 1f           // feedback-path one-pole LP: 1 = bright, →0 = dark
    private var lpState = 0f
    private var flutterInc = 0.0
    private var flutterDepth = 0f
    private var flutterPhase = 0.0

    override fun update(slot: ToolboxSlot, tempoBpm: Float) {
        val samplesPerBeat = (60.0 / tempoBpm) * sampleRate
        for (h in 0 until DelayDivisions.HEADS) {
            // Precomputed key strings — building "head${h}On" here would allocate on
            // the audio thread every block (this runs from fillBlock).
            headOn[h] = slot.float(HEAD_ON_KEYS[h], if (h == 0) 1f else 0f) >= 0.5f
            val idx = slot.float(HEAD_DIV_KEYS[h], DelayDivisions.DEFAULT.toFloat()).toInt()
            delaySamples[h] = (DelayDivisions.beatFraction(idx) * samplesPerBeat)
                .toInt().coerceIn(1, buffer.size - 2)
        }
        feedback = slot.float("feedback", 0.4f).coerceIn(0f, DelayDivisions.MAX_FEEDBACK)
        dryWet = slot.float("drywet", 0.35f).coerceIn(0f, 1f)
        val tape = slot.float("tape", 0.3f).coerceIn(0f, 1f)
        satBlend = tape                              // more tape → more saturation
        lpAlpha = 1f - tape * 0.85f                  // more tape → darker repeats
        flutterInc = 2.0 * PI * FLUTTER_HZ / sampleRate
        flutterDepth = tape * sampleRate * 0.0015f   // up to ~1.5 ms wow/flutter
    }

    override fun process(x: Float): Float {
        flutterPhase += flutterInc
        if (flutterPhase > 2 * PI) flutterPhase -= 2 * PI
        val flutter = sin(flutterPhase).toFloat() * flutterDepth

        // Read each active head (fractional read + linear interpolation for flutter).
        // The wet signal is the AVERAGE of the active heads, so the summed level — and
        // the feedback loop gain — stays independent of how many heads are on.
        var wet = 0f
        var active = 0
        for (h in 0 until DelayDivisions.HEADS) {
            if (!headOn[h]) continue
            active++
            var pos = writeIndex - delaySamples[h] - flutter
            while (pos < 0f) pos += buffer.size
            val i0 = pos.toInt() % buffer.size
            val i1 = (i0 + 1) % buffer.size
            val frac = pos - pos.toInt()
            wet += buffer[i0] + (buffer[i1] - buffer[i0]) * frac
        }
        if (active > 0) wet /= active

        var fb = wet * feedback
        fb += satBlend * (tanh(fb) - fb)
        lpState += lpAlpha * (fb - lpState)
        fb = lpState

        buffer[writeIndex] = x + fb
        writeIndex = (writeIndex + 1) % buffer.size
        return x * (1f - dryWet) + wet * dryWet
    }

    private companion object {
        const val FLUTTER_HZ = 5.0
        val HEAD_ON_KEYS = Array(DelayDivisions.HEADS) { "head${it}On" }
        val HEAD_DIV_KEYS = Array(DelayDivisions.HEADS) { "head${it}Div" }
    }
}

// ---------------------------------------------------------------------------
// RBJ biquad filter with selectable LP / HP / BP response, an ADSR cutoff envelope
// (triggered by signal activity on the channel) and analog-style warmth saturation.
// ---------------------------------------------------------------------------
private class BiquadFilterEffect(private val sampleRate: Int) : AudioEffect {
    private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
    private var a1 = 0.0; private var a2 = 0.0
    private var z1 = 0.0; private var z2 = 0.0 // transposed direct form II state

    private val sr = sampleRate.toFloat()
    private var type = "LPF"
    private var baseCutoff = 0.7f
    private var q = 1.0
    private var warmth = 0f
    private var envAmount = 0f

    // Cutoff ADSR (0..1), retriggered by an input-level gate.
    private enum class Phase { IDLE, ATTACK, DECAY, SUSTAIN, RELEASE }
    private var phase = Phase.IDLE
    private var env = 0f
    private var attackInc = 1f
    private var decayInc = 1f
    private var sustainLevel = 1f
    private var releaseSeconds = 0.2f
    private var releaseInc = 1f
    private var levelEnv = 0f
    private var levelCoeff = 0f
    private var gateOpen = false
    private var ctrl = 0

    override fun update(slot: ToolboxSlot, tempoBpm: Float) {
        type = slot.string("type", "LPF")
        baseCutoff = slot.float("cutoff", 0.7f).coerceIn(0f, 1f)
        q = (0.5 + slot.float("resonance", 0.2f) * 8f).toDouble()
        warmth = slot.float("warmth", 0f).coerceIn(0f, 1f)
        envAmount = slot.float("envamt", 0f).coerceIn(0f, 1f)
        sustainLevel = slot.float("sustain", 1f).coerceIn(0f, 1f)
        attackInc = 1f / (max(slot.float("attack", 0.005f), 0.0005f) * sr)
        val decay = slot.float("decay", 0.10f)
        decayInc = if (decay <= 0f) 1f else (1f - sustainLevel) / (decay * sr)
        releaseSeconds = slot.float("release", 0.20f)
        levelCoeff = 1f - exp(-1f / (sr * 0.005f)) // ~5 ms input-level follower
        ctrl = 0 // force a coefficient refresh on the next sample
    }

    private fun computeCoefficients(type: String, freq: Double, q: Double) {
        val w0 = 2.0 * PI * freq / sampleRate
        val cosW = cos(w0)
        val alpha = sin(w0) / (2.0 * q)
        val a0: Double
        when (type) {
            "HPF" -> {
                b0 = (1 + cosW) / 2; b1 = -(1 + cosW); b2 = (1 + cosW) / 2
                a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
            }
            "BPF" -> {
                b0 = alpha; b1 = 0.0; b2 = -alpha
                a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
            }
            else -> { // LPF
                b0 = (1 - cosW) / 2; b1 = 1 - cosW; b2 = (1 - cosW) / 2
                a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
            }
        }
        // Normalise by a0.
        b0 /= a0; b1 /= a0; b2 /= a0; a1 /= a0; a2 /= a0
    }

    override fun process(x: Float): Float {
        // Gate the ADSR from the input level (hysteresis avoids chatter). A rising
        // edge above GATE_HI (re)triggers attack; falling below GATE_LO releases.
        levelEnv += (abs(x) - levelEnv) * levelCoeff
        if (!gateOpen && levelEnv > GATE_HI) {
            gateOpen = true; phase = Phase.ATTACK
        } else if (gateOpen && levelEnv < GATE_LO) {
            gateOpen = false
            releaseInc = if (releaseSeconds <= 0f) 1f else env / (releaseSeconds * sr)
            if (releaseInc <= 0f) releaseInc = 1f
            phase = Phase.RELEASE
        }
        when (phase) {
            Phase.ATTACK -> { env += attackInc; if (env >= 1f) { env = 1f; phase = Phase.DECAY } }
            Phase.DECAY -> { env -= decayInc; if (env <= sustainLevel) { env = sustainLevel; phase = Phase.SUSTAIN } }
            Phase.SUSTAIN -> {}
            Phase.RELEASE -> { env -= releaseInc; if (env <= 0f) { env = 0f; phase = Phase.IDLE } }
            Phase.IDLE -> {}
        }

        // Recompute the biquad at control rate as the envelope opens the cutoff (the
        // envelope lifts the cutoff from its base toward fully open by Env Amount).
        if (ctrl <= 0) {
            ctrl = CTRL_INTERVAL
            val eff = (baseCutoff + envAmount * env * (1f - baseCutoff)).coerceIn(0f, 1f)
            val freq = 20.0 * 900.0.pow(eff.toDouble())
            computeCoefficients(type, freq.coerceIn(20.0, sampleRate * 0.45), q)
        }
        ctrl--

        val input = x.toDouble()
        val out = b0 * input + z1
        z1 = b1 * input - a1 * out + z2
        z2 = b2 * input - a2 * out
        var y = out.toFloat()

        // Warmth: soft saturation blended in so the small-signal gain (and level)
        // stays put — it only rounds off louder peaks, adding gentle harmonics.
        if (warmth > 0.0001f) y += warmth * (tanh(y * 1.8f) / 1.8f - y)
        return y
    }

    private companion object {
        const val CTRL_INTERVAL = 32   // samples between cutoff-coefficient refreshes
        const val GATE_HI = 0.010f     // input level that opens the envelope gate
        const val GATE_LO = 0.004f     // input level that releases it (hysteresis)
    }
}

// ---------------------------------------------------------------------------
// Bitcrusher: bit-depth reduction + sample-rate reduction + drive.
// ---------------------------------------------------------------------------
private class Bitcrusher : AudioEffect {
    private var levels = 256f
    private var downsample = 1
    private var drive = 1f
    private var counter = 0
    private var held = 0f

    override fun update(slot: ToolboxSlot, tempoBpm: Float) {
        val bits = slot.float("bits", 8f).coerceIn(1f, 16f)
        levels = 2f.pow(bits)
        downsample = slot.float("downsample", 1f).toInt().coerceAtLeast(1)
        drive = 1f + slot.float("drive", 0.2f) * 4f
    }

    override fun process(x: Float): Float {
        if (counter <= 0) {
            counter = downsample
            // Quantise to the reduced bit depth after applying drive.
            val driven = tanh(x * drive)
            held = (driven * levels).roundToInt() / levels
        }
        counter--
        return held
    }
}

// ---------------------------------------------------------------------------
// 10-band graphic EQ: ten peaking biquads at octave-spaced centre frequencies.
// ---------------------------------------------------------------------------
private class GraphicEq(private val sampleRate: Int) : AudioEffect {
    private val centers = floatArrayOf(31f, 63f, 125f, 250f, 500f, 1000f, 2000f, 4000f, 8000f, 16000f)
    private val bands = Array(10) { PeakBiquad() }
    // Precomputed keys — "band$i" would allocate on the audio thread every block.
    private val bandKeys = Array(10) { "band$it" }

    override fun update(slot: ToolboxSlot, tempoBpm: Float) {
        for (i in bands.indices) {
            val gainDb = slot.float(bandKeys[i], 0f).coerceIn(-1f, 1f) * 12f // -12..+12 dB
            bands[i].set(sampleRate, centers[i], gainDb)
        }
    }

    override fun process(x: Float): Float {
        var y = x
        for (b in bands) y = b.process(y)
        return y
    }

    private class PeakBiquad {
        private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
        private var a1 = 0.0; private var a2 = 0.0
        private var z1 = 0.0; private var z2 = 0.0

        fun set(sampleRate: Int, freq: Float, gainDb: Float) {
            if (freq >= sampleRate * 0.45f) return
            val a = 10.0.pow(gainDb / 40.0)
            val w0 = 2.0 * PI * freq / sampleRate
            val cosW = cos(w0)
            val alpha = sin(w0) / (2.0 * 1.0) // Q ~ 1 per band
            val a0 = 1 + alpha / a
            b0 = (1 + alpha * a) / a0
            b1 = (-2 * cosW) / a0
            b2 = (1 - alpha * a) / a0
            a1 = (-2 * cosW) / a0
            a2 = (1 - alpha / a) / a0
        }

        fun process(x: Float): Float {
            val input = x.toDouble()
            val out = b0 * input + z1
            z1 = b1 * input - a1 * out + z2
            z2 = b2 * input - a2 * out
            return out.toFloat()
        }
    }
}

// ---------------------------------------------------------------------------
// Reusable RBJ biquad (transposed direct form II, double state) for the Mix
// Tightener. Coefficients are set at block rate; process() runs per sample and
// self-recovers to silence if state ever goes non-finite.
// ---------------------------------------------------------------------------
private class Bq {
    private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
    private var a1 = 0.0; private var a2 = 0.0
    private var z1 = 0.0; private var z2 = 0.0

    fun identity() { b0 = 1.0; b1 = 0.0; b2 = 0.0; a1 = 0.0; a2 = 0.0 }

    private fun setRaw(nb0: Double, nb1: Double, nb2: Double, a0: Double, na1: Double, na2: Double) {
        b0 = nb0 / a0; b1 = nb1 / a0; b2 = nb2 / a0; a1 = na1 / a0; a2 = na2 / a0
    }

    fun peak(sr: Int, freq: Float, gainDb: Float, q: Float) {
        val f = freq.coerceIn(20f, sr * 0.45f)
        val a = 10.0.pow(gainDb / 40.0)
        val w0 = 2.0 * PI * f / sr; val cw = cos(w0); val alpha = sin(w0) / (2.0 * q.coerceAtLeast(0.1f))
        setRaw(1 + alpha * a, -2 * cw, 1 - alpha * a, 1 + alpha / a, -2 * cw, 1 - alpha / a)
    }

    fun lowShelf(sr: Int, freq: Float, gainDb: Float) {
        val f = freq.coerceIn(20f, sr * 0.45f)
        val a = 10.0.pow(gainDb / 40.0); val w0 = 2.0 * PI * f / sr; val cw = cos(w0)
        val alpha = sin(w0) / 2.0 * sqrt(2.0); val ta = 2.0 * sqrt(a) * alpha
        setRaw(
            a * ((a + 1) - (a - 1) * cw + ta), 2 * a * ((a - 1) - (a + 1) * cw),
            a * ((a + 1) - (a - 1) * cw - ta), (a + 1) + (a - 1) * cw + ta,
            -2 * ((a - 1) + (a + 1) * cw), (a + 1) + (a - 1) * cw - ta,
        )
    }

    fun highShelf(sr: Int, freq: Float, gainDb: Float) {
        val f = freq.coerceIn(20f, sr * 0.45f)
        val a = 10.0.pow(gainDb / 40.0); val w0 = 2.0 * PI * f / sr; val cw = cos(w0)
        val alpha = sin(w0) / 2.0 * sqrt(2.0); val ta = 2.0 * sqrt(a) * alpha
        setRaw(
            a * ((a + 1) + (a - 1) * cw + ta), -2 * a * ((a - 1) + (a + 1) * cw),
            a * ((a + 1) + (a - 1) * cw - ta), (a + 1) - (a - 1) * cw + ta,
            2 * ((a - 1) - (a + 1) * cw), (a + 1) - (a - 1) * cw - ta,
        )
    }

    fun lowPass(sr: Int, freq: Float, q: Float) {
        val f = freq.coerceIn(20f, sr * 0.45f)
        val w0 = 2.0 * PI * f / sr; val cw = cos(w0); val alpha = sin(w0) / (2.0 * q)
        setRaw((1 - cw) / 2, 1 - cw, (1 - cw) / 2, 1 + alpha, -2 * cw, 1 - alpha)
    }

    fun highPass(sr: Int, freq: Float, q: Float) {
        val f = freq.coerceIn(20f, sr * 0.45f)
        val w0 = 2.0 * PI * f / sr; val cw = cos(w0); val alpha = sin(w0) / (2.0 * q)
        setRaw((1 + cw) / 2, -(1 + cw), (1 + cw) / 2, 1 + alpha, -2 * cw, 1 - alpha)
    }

    fun reset() { z1 = 0.0; z2 = 0.0 }

    fun process(x: Float): Float {
        val inp = x.toDouble()
        var out = b0 * inp + z1
        z1 = b1 * inp - a1 * out + z2
        z2 = b2 * inp - a2 * out
        if (!out.isFinite()) { z1 = 0.0; z2 = 0.0; out = 0.0 }
        return out.toFloat()
    }
}

// ---------------------------------------------------------------------------
// Mix Tightener: a preset-driven "channel strip in a box" (parametric EQ →
// 3-band multiband compressor → peak limiter) behind ONE user control, Strength,
// which morphs the whole chain from transparent (0) to the full preset (1). Every
// per-band value is a hidden slot param the genre/instrument presets set; only
// Strength is exposed in the editor.
//
// Performance: crossover/EQ are biquads; the expensive per-band gain computation
// (log/pow) runs at control rate (every CTRL samples) while the envelope follower
// runs per sample. Idle/gentle settings short-circuit: Strength≈0 bypasses the
// whole effect, flat EQ bands and ratio≈1 comp bands are skipped.
// ---------------------------------------------------------------------------
private class MixTightener(private val sampleRate: Int) : AudioEffect {
    // Parametric EQ: low shelf, mid peak, high shelf.
    private val eqLo = Bq(); private val eqMid = Bq(); private val eqHi = Bq()
    private var eqActive = false
    // Linkwitz-Riley-ish 3-way crossover (Q≈0.5) into low / mid / high.
    private val xLoLP = Bq(); private val xLoHP = Bq(); private val xHiLP = Bq(); private val xHiHP = Bq()
    private var compActive = false
    // Per-band compressor state (0=low,1=mid,2=high).
    private val env = FloatArray(3)
    private val gain = FloatArray(3) { 1f }        // per-sample smoothed applied gain
    private val target = FloatArray(3) { 1f }      // control-rate gain target
    private val atkCoef = FloatArray(3); private val relCoef = FloatArray(3)
    private val thrDb = FloatArray(3); private val ratio = FloatArray(3); private val makeDb = FloatArray(3)
    private var ctrl = 0
    // Limiter + output.
    private var limCeil = 1f; private var limRelCoef = 0f; private var limEnv = 0f
    private var outGain = 1f
    private var strength = 1f
    private var compScale = 1f // master × per-component "Comp" amount, applied to the gain reduction

    private fun dbToLin(db: Float): Float = 10.0.pow(db / 20.0).toFloat()
    private fun coefFromMs(ms: Float): Float {
        val t = (ms.coerceAtLeast(0.1f)) * 0.001f * sampleRate
        return (1.0 - exp(-1.0 / t)).toFloat()
    }

    override fun update(slot: ToolboxSlot, tempoBpm: Float) {
        strength = slot.float("strength", 1f).coerceIn(0f, 1f)
        // Per-component amounts (default 1) let each stage be trimmed independently;
        // the effective scale for a stage is master strength × that stage's amount.
        val eqS = strength * slot.float("eqAmt", 1f).coerceIn(0f, 1f)
        val compAmt = slot.float("compAmt", 1f).coerceIn(0f, 1f)
        compScale = strength * compAmt
        val limS = strength * slot.float("limAmt", 1f).coerceIn(0f, 1f)

        // EQ — gains scaled by the EQ amount so 0 is flat.
        val loG = slot.float("eqLoGain", 0f).coerceIn(-18f, 18f) * eqS
        val midG = slot.float("eqMidGain", 0f).coerceIn(-18f, 18f) * eqS
        val hiG = slot.float("eqHiGain", 0f).coerceIn(-18f, 18f) * eqS
        if (abs(loG) > 0.1f) eqLo.lowShelf(sampleRate, slot.float("eqLoFreq", 120f), loG) else eqLo.identity()
        if (abs(midG) > 0.1f) eqMid.peak(sampleRate, slot.float("eqMidFreq", 1000f), midG,
            slot.float("eqMidQ", 1.0f).coerceIn(0.2f, 8f)) else eqMid.identity()
        if (abs(hiG) > 0.1f) eqHi.highShelf(sampleRate, slot.float("eqHiFreq", 8000f), hiG) else eqHi.identity()
        eqActive = abs(loG) > 0.1f || abs(midG) > 0.1f || abs(hiG) > 0.1f

        // Crossovers.
        val xl = slot.float("xLo", 200f).coerceIn(40f, sampleRate * 0.4f)
        val xh = slot.float("xHi", 2500f).coerceIn(xl + 50f, sampleRate * 0.45f)
        xLoLP.lowPass(sampleRate, xl, 0.5f); xLoHP.highPass(sampleRate, xl, 0.5f)
        xHiLP.lowPass(sampleRate, xh, 0.5f); xHiHP.highPass(sampleRate, xh, 0.5f)

        // Compressor bands.
        thrDb[0] = slot.float("cLoThr", -24f); ratio[0] = slot.float("cLoRatio", 2f).coerceIn(1f, 20f)
        makeDb[0] = slot.float("cLoMk", 0f); atkCoef[0] = coefFromMs(slot.float("cLoAtk", 10f)); relCoef[0] = coefFromMs(slot.float("cLoRel", 120f))
        thrDb[1] = slot.float("cMidThr", -22f); ratio[1] = slot.float("cMidRatio", 2f).coerceIn(1f, 20f)
        makeDb[1] = slot.float("cMidMk", 0f); atkCoef[1] = coefFromMs(slot.float("cMidAtk", 15f)); relCoef[1] = coefFromMs(slot.float("cMidRel", 140f))
        thrDb[2] = slot.float("cHiThr", -24f); ratio[2] = slot.float("cHiRatio", 2f).coerceIn(1f, 20f)
        makeDb[2] = slot.float("cHiMk", 0f); atkCoef[2] = coefFromMs(slot.float("cHiAtk", 5f)); relCoef[2] = coefFromMs(slot.float("cHiRel", 90f))
        compActive = compAmt > 0.001f && (ratio.any { it > 1.01f } || makeDb.any { abs(it) > 0.1f })

        // Limiter: at amount 0 the ceiling is 1.0 (no limiting) and output gain is unity,
        // at 1 they're the preset values.
        limCeil = 1f + (dbToLin(slot.float("limCeil", -0.5f).coerceIn(-24f, 0f)) - 1f) * limS
        limRelCoef = coefFromMs(slot.float("limRel", 100f))
        outGain = 1f + (dbToLin(slot.float("outGain", 0f).coerceIn(-12f, 18f)) - 1f) * limS
    }

    private fun compBand(i: Int, band: Float): Float {
        val r = abs(band)
        val e = env[i]
        env[i] = if (r > e) e + atkCoef[i] * (r - e) else e + relCoef[i] * (r - e)
        gain[i] += GAIN_SMOOTH * (target[i] - gain[i]) // click-free gain ramp
        return band * gain[i]
    }

    override fun process(x: Float): Float {
        if (strength < 0.001f) return x
        var s = x
        if (eqActive) { s = eqLo.process(s); s = eqMid.process(s); s = eqHi.process(s) }

        if (compActive) {
            val low = xLoLP.process(s)
            val hp = xLoHP.process(s)
            val mid = xHiLP.process(hp)
            val high = xHiHP.process(hp)
            if (ctrl <= 0) {
                ctrl = CTRL
                for (i in 0 until 3) {
                    val envDb = 20f * log10(env[i] + 1e-9f)
                    val over = envDb - thrDb[i]
                    val grDb = if (over > 0f) -over * (1f - 1f / ratio[i]) else 0f
                    target[i] = dbToLin(((grDb + makeDb[i]) * compScale).coerceIn(-40f, 24f))
                }
            }
            ctrl--
            s = compBand(0, low) + compBand(1, mid) + compBand(2, high)
        }

        // Peak limiter: instant attack, smoothed release; a plain divide (no log/pow).
        val r = abs(s)
        limEnv = if (r > limEnv) r else limEnv + limRelCoef * (r - limEnv)
        if (limEnv > limCeil && limEnv > 1e-6f) s *= limCeil / limEnv
        s *= outGain
        // Safety: never leave the effect hotter than full scale.
        return if (s > 1f) 1f else if (s < -1f) -1f else if (!s.isFinite()) 0f else s
    }

    private companion object {
        const val CTRL = 16           // samples between (expensive) gain recomputes
        const val GAIN_SMOOTH = 0.05f // per-sample gain ramp toward the control-rate target
    }
}

// ---------------------------------------------------------------------------
// Tape Engine: a varispeed reel-to-reel. Audio is written into a ring buffer at
// rate 1 and read back at `speed` (< 1 = slower = lower pitch), interpolated — a
// classic tape brake / tape-stop. `speed` is driven by the editor's reel gesture
// (press to brake); the engine GLIDES toward it so pitch dives and recovers with
// tape-like inertia rather than jumping. wow (slow, tempo-synced) and flutter (fast)
// wobble the read rate; hiss adds constant tape noise. Fully wet — the whole signal
// runs through the tape — so at speed 1 / no wobble it's just a short constant delay.
// ---------------------------------------------------------------------------
private class TapeEngine(private val sampleRate: Int) : AudioEffect {
    private val buf = FloatArray(sampleRate * 2)   // 2 s ring (headroom for long brakes)
    private var writePos = NOMINAL_DELAY
    private var readPos = 0.0
    private var targetSpeed = 1.0
    private var curSpeed = 1.0
    private var wowInc = 0.0; private var wowPhase = 0.0; private var wowDepth = 0f
    private var flutInc = FLUTTER_HZ / sampleRate; private var flutPhase = 0.0; private var flutDepth = 0f
    private var hiss = 0f
    private var rng = 0x51ED2A19

    override fun update(slot: ToolboxSlot, tempoBpm: Float) {
        targetSpeed = slot.float("speed", 1f).coerceIn(MIN_SPEED, 1f).toDouble()
        wowDepth = slot.float("wow", 0.15f).coerceIn(0f, 1f) * MAX_WOW
        flutDepth = slot.float("flutter", 0.15f).coerceIn(0f, 1f) * MAX_FLUT
        hiss = slot.float("hiss", 0.10f).coerceIn(0f, 1f) * MAX_HISS
        val secPerBeat = 60.0 / tempoBpm
        val div = TimeDivision.fromIndex(slot.float("division", 2f).toInt())
        val wowHz = 1.0 / (div.beatFraction * secPerBeat).coerceAtLeast(1e-4)
        wowInc = wowHz / sampleRate
        flutInc = FLUTTER_HZ / sampleRate
    }

    override fun process(x: Float): Float {
        buf[writePos] = x
        writePos++; if (writePos >= buf.size) writePos = 0

        curSpeed += (targetSpeed - curSpeed) * SPEED_GLIDE // tape inertia

        wowPhase += wowInc; if (wowPhase >= 1.0) wowPhase -= 1.0
        flutPhase += flutInc; if (flutPhase >= 1.0) flutPhase -= 1.0
        val wow = sin(2.0 * PI * wowPhase) * wowDepth
        val flut = sin(2.0 * PI * flutPhase) * flutDepth
        var rate = curSpeed * (1.0 + wow + flut)
        if (rate < 0.01) rate = 0.01

        val i0 = readPos.toInt() % buf.size
        val i1 = (i0 + 1) % buf.size
        val frac = (readPos - readPos.toInt()).toFloat()
        var out = buf[i0] + (buf[i1] - buf[i0]) * frac

        var rp = readPos + rate
        if (rp >= buf.size) rp -= buf.size
        // Keep the read head a valid distance behind the write head: never read the
        // just-written/future region (min gap), and never lag past the buffer (max gap
        // — a hard brake that would fall off snaps forward with a small glitch).
        var gap = writePos - rp; if (gap < 0) gap += buf.size
        val maxGap = buf.size - 2.0
        if (gap < MIN_GAP) { rp = writePos - MIN_GAP; if (rp < 0) rp += buf.size }
        else if (gap > maxGap) { rp = writePos - maxGap; if (rp < 0) rp += buf.size }
        readPos = rp

        if (hiss > 0f) out += nextRand() * hiss
        return out
    }

    private fun nextRand(): Float {
        var s = rng; s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5); rng = s
        return s * 4.656613e-10f // -1 … 1
    }

    private companion object {
        const val NOMINAL_DELAY = 128   // baseline read latency (samples) for interp headroom
        const val MIN_SPEED = 0.25f
        const val MAX_WOW = 0.03f       // ±3 % rate at full wow
        const val MAX_FLUT = 0.008f     // ±0.8 % rate at full flutter
        const val MAX_HISS = 0.02f
        const val FLUTTER_HZ = 11.0
        const val SPEED_GLIDE = 0.00012f // per-sample glide (~0.15 s) toward target speed
        const val MIN_GAP = 2.0
    }
}

// ---------------------------------------------------------------------------
// Granular Glitch: records the incoming signal into a rolling buffer and, driven by
// Intensity, periodically "jumps" the read head back to a random earlier point and
// replays a short grain (stutter / repeat / octave glitch). Between grains it passes
// the signal through dry, so Intensity 0 is transparent and higher = more frequent,
// more mangled glitches. Publishes a decimated scope of the incoming sound plus the
// jumping read-head position ([VizEffect]) for the editor to draw.
// ---------------------------------------------------------------------------
private class GranularGlitch(private val sampleRate: Int) : AudioEffect, VizEffect {
    private val buf = FloatArray(sampleRate)   // 1 s history
    private var writePos = 0
    private var readPos = 0.0
    private var grainLeft = 0
    private var grainRate = 1.0
    private var intensity = 0f
    private var rng = 0x2F6E1DB7

    // Visualization: a ring of decimated INPUT samples (the sound passing through) plus
    // the normalized read-head position within that window.
    private val scope = FloatArray(SCOPE_N)
    private var scopeWrite = 0
    private var decCount = 0
    @Volatile private var playheadNorm = 1f

    override fun update(slot: ToolboxSlot, tempoBpm: Float) {
        intensity = slot.float("intensity", 0.3f).coerceIn(0f, 1f)
    }

    override fun process(x: Float): Float {
        val wp = writePos
        buf[wp] = x
        writePos++; if (writePos >= buf.size) writePos = 0

        val out: Float
        if (grainLeft <= 0 && (intensity < 0.001f || !maybeStartGrain(wp))) {
            // Dry passthrough — keep the read head trailing the write head so the next
            // grain can start from "now" and jump back from there.
            readPos = wp.toDouble()
            out = x
        } else {
            val i0 = readPos.toInt() % buf.size
            val i1 = (i0 + 1) % buf.size
            val frac = (readPos - readPos.toInt()).toFloat()
            out = buf[i0] + (buf[i1] - buf[i0]) * frac
            var rp = readPos + grainRate
            if (rp >= buf.size) rp -= buf.size
            if (rp < 0) rp += buf.size
            var gap = wp - rp; if (gap < 0) gap += buf.size
            if (gap < 2.0) { rp = wp - 2.0; if (rp < 0) rp += buf.size } // don't read the future
            readPos = rp
            grainLeft--
        }

        // Scope: decimate the incoming signal into the ring.
        if (++decCount >= SCOPE_DECIM) {
            decCount = 0
            scope[scopeWrite] = x
            scopeWrite++; if (scopeWrite >= SCOPE_N) scopeWrite = 0
        }
        // Playhead: where the read head sits within the displayed window (0 oldest … 1 now).
        var behind = wp - readPos; if (behind < 0) behind += buf.size
        playheadNorm = (1f - behind.toFloat() / (SCOPE_N * SCOPE_DECIM)).coerceIn(0f, 1f)
        return out
    }

    /** Maybe (probability scales with Intensity) start a new grain, jumping the read
     *  head back to a random earlier point. Returns true if a grain was started. */
    private fun maybeStartGrain(wp: Int): Boolean {
        if (nextUnit() > intensity * intensity * MAX_TRIGGER) return false
        val minJ = MIN_JUMP_S * sampleRate
        val maxJ = (MIN_JUMP_S + intensity * (MAX_JUMP_S - MIN_JUMP_S)) * sampleRate
        val jump = minJ + nextUnit() * (maxJ - minJ)
        var rp = wp.toDouble() - jump
        while (rp < 0) rp += buf.size
        readPos = rp
        val lenSec = GRAIN_MAX_S - intensity * (GRAIN_MAX_S - GRAIN_MIN_S)
        grainLeft = (lenSec * sampleRate * (0.5f + nextUnit())).toInt().coerceAtLeast(64)
        grainRate = when {                       // occasional octave glitch, likelier when hot
            nextUnit() < intensity * 0.3f -> 2.0
            nextUnit() < intensity * 0.2f -> 0.5
            else -> 1.0
        }
        return true
    }

    override fun copyScope(out: FloatArray): Int {
        val n = if (out.size < SCOPE_N) out.size else SCOPE_N
        val start = scopeWrite // oldest sample in the ring
        for (k in 0 until n) out[k] = scope[(start + k) % SCOPE_N]
        return n
    }

    override fun playhead(): Float = playheadNorm

    private fun nextUnit(): Float {
        var s = rng; s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5); rng = s
        return (s.toLong() and 0xFFFFFFFFL).toFloat() * 2.3283064e-10f // 0 … 1
    }

    private companion object {
        const val SCOPE_N = 96          // scope points drawn
        const val SCOPE_DECIM = 96      // input samples per point → ~192 ms window @ 48 kHz
        const val MAX_TRIGGER = 0.0009f // per-sample grain-start probability at full intensity
        const val MIN_JUMP_S = 0.03f    // shortest jump-back
        const val MAX_JUMP_S = 0.5f     // longest jump-back at full intensity
        const val GRAIN_MIN_S = 0.02f   // shortest grain (hot = stutter)
        const val GRAIN_MAX_S = 0.15f   // longest grain (gentle)
    }
}
