灵符lingfu

ling-lang

↓ 0 downloads

Ling - The Omniglot Systems Language

Keywords systems, polyglot, ai, crypto, game
lingfu install ling-lang
LicenseApache-2.0 OR MIT
AuthorsLing Lin, Sanny Lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (995)

crates/ling-daw/src/dsp/hypergrowl.rs
//! Rainbow Hypergrowl: the digital harmonic growl of a resampled-wavetable bass, as one effect with a deep signal chain.
//!
//! The growl in a "HyperGrowl" bass is not one trick but a stack: a square / saw is driven and folded until it is full of harmonics,
//! a formant-like filter is swept across it while the wavetable position moves, a comb gives it a metallic voice, and finally it is
//! squashed spectrally so every harmonic speaks at once. This effect is that whole stack, with its own motion so it moves by itself,
//! and a grain delay for the shattered, granular tails of modern bass music.
//!
//!   drive:    pre-gain, asymmetric bias, tanh -> sine fold, a ring modulator, a bit / rate crusher, DC blocked
//!   table:    24 band-pass filters (50 Hz - 12 kHz) whose gains are read from TWO spectral wavetables (12 shapes: vowels, "yoi",
//!             comb, harmonic, hollow, rainbow, digital, ...) crossfaded by `blend`; the shared position scans with the motion,
//!             `shift` slides the whole bank in octaves, `spread` detunes the bands apart (the "rainbow", and wider on the right), `tilt`,
//!             and `body` keeps the clean low end underneath so a bass does not thin out
//!   comb:     a feedback comb tuned in Hz, wobbled by the second LFO
//!   grain:    a granular delay: up to 32 overlapping grains read a 2 s buffer at random delays, pitches (+-24 st), directions and
//!             pans, with feedback
//!   spectral: a 2048-point STFT: peaks pulled toward the spectral envelope (`squash`, `focus`), a bin shifter (`sshift`), a gate that
//!             kills weak bins (`sgate`), spectral blur (`blur`) and a spectral tilt
//!   out:      stereo width, level, soft limiter, and mix against the (latency-matched) dry signal
//! Motion: an LFO (sine / triangle / saw / square / sample & hold) with optional stepping, an envelope follower that scans the table by
//! how hard the input hits, a slow random drift and a second LFO that wobbles the shift, the comb and the grain pitch.
use super::fx::Effect;
use crate::dsp::TAU;

pub const LATENCY: usize = 2048;
const N: usize = LATENCY;
const HOP: usize = N / 4;
pub const BANDS: usize = 24;
const BLOCK: usize = 16;
const GRAINS: usize = 32;
const GBUF_SECS: f32 = 2.0;

macro_rules! params {
    ($($n:literal = $d:expr),* $(,)?) => { [$(($n, $d)),*] };
}

/// (name, default); the order is the order of the knobs and of automation indices
pub const PARAMS: [(&str, f32); 52] = params![
    // drive
    "drive" = 0.35, "fold" = 0.35, "bias" = 0.5, "crush" = 0.0, "ring" = 0.0, "ringhz" = 0.4,
    // table
    "table" = 0.7083, "tableb" = 0.0417, "blend" = 0.3, "pos" = 0.4, "res" = 0.6, "shift" = 0.5, "spread" = 0.4, "tilt" = 0.5, "body" = 0.35,
    // comb
    "comb" = 0.0, "ctune" = 0.4, "cmod" = 0.0,
    // motion
    "rate" = 0.4, "motion" = 0.7, "shape" = 0.3, "steps" = 0.0, "follow" = 0.0, "drift" = 0.0, "wobble" = 0.0,
    // grain
    "gmix" = 0.0, "gtime" = 0.35, "gsize" = 0.4, "gdens" = 0.5, "gspray" = 0.3, "gpitch" = 0.5, "grand" = 0.2, "grev" = 0.15, "gfb" = 0.2, "gwidth" = 0.6,
    // spectral
    "squash" = 0.5, "focus" = 0.6, "sshift" = 0.5, "sgate" = 0.0, "blur" = 0.0, "stilt" = 0.5,
    // out
    "width" = 0.5, "level" = 0.5, "mix" = 1.0,
    // sample: a WAV (or the built-in vowel sweep) as a spectral source
    "smix" = 0.0, "spos" = 0.4, "sscan" = 0.5, "xsyn" = 0.0, "freeze" = 0.0, "gsrc" = 0.0,
    // character: an octave-down sub for bass, and an even-harmonic warmth before the fold
    "sub" = 0.0, "warm" = 0.0,
];

fn pidx(n: &str) -> usize {
    match n {
    "drive" => 0,
    "fold" => 1,
    "bias" => 2,
    "crush" => 3,
    "ring" => 4,
    "ringhz" => 5,
    "table" => 6,
    "tableb" => 7,
    "blend" => 8,
    "pos" => 9,
    "res" => 10,
    "shift" => 11,
    "spread" => 12,
    "tilt" => 13,
    "body" => 14,
    "comb" => 15,
    "ctune" => 16,
    "cmod" => 17,
    "rate" => 18,
    "motion" => 19,
    "shape" => 20,
    "steps" => 21,
    "follow" => 22,
    "drift" => 23,
    "wobble" => 24,
    "gmix" => 25,
    "gtime" => 26,
    "gsize" => 27,
    "gdens" => 28,
    "gspray" => 29,
    "gpitch" => 30,
    "grand" => 31,
    "grev" => 32,
    "gfb" => 33,
    "gwidth" => 34,
    "squash" => 35,
    "focus" => 36,
    "sshift" => 37,
    "sgate" => 38,
    "blur" => 39,
    "stilt" => 40,
    "width" => 41,
    "level" => 42,
    "mix" => 43,
        "smix" => 44,
        "spos" => 45,
        "sscan" => 46,
        "xsyn" => 47,
        "freeze" => 48,
        "gsrc" => 49,
        "sub" => 50,
        "warm" => 51,
        _ => usize::MAX,
    }
}

pub const TABLES: [&str; 12] = ["Vowel", "Comb", "Harmonic", "Hollow", "Rainbow", "Digital", "Tilt", "Bell", "Yoi", "Ripple", "Notches", "Stairs"];
pub const SHAPES: [&str; 5] = ["sine", "tri", "saw", "square", "s&h"];

/// ready-made sounds: overrides on top of the defaults
pub const PRESETS: [(&str, &[(&str, f32)]); 13] = [
    ("Init", &[("gmix", 0.0)]),
    ("Hypergrowl", &[("drive", 0.5), ("fold", 0.45), ("table", 0.7083), ("tableb", 0.0417), ("blend", 0.3), ("pos", 0.4), ("res", 0.65), ("spread", 0.5), ("rate", 0.42), ("motion", 0.85), ("shape", 0.3), ("squash", 0.75), ("gmix", 0.18), ("gsize", 0.3), ("gdens", 0.6), ("gpitch", 0.5), ("grand", 0.1)]),
    ("Yoi Bass", &[("drive", 0.5), ("fold", 0.3), ("table", 0.7083), ("tableb", 0.0417), ("blend", 0.25), ("pos", 0.5), ("res", 0.72), ("motion", 1.0), ("rate", 0.46), ("shape", 0.3), ("body", 0.5), ("squash", 0.6), ("gmix", 0.0)]),
    ("Digital Chop", &[("drive", 0.55), ("fold", 0.6), ("crush", 0.35), ("table", 0.4583), ("tableb", 0.9583), ("blend", 0.5), ("steps", 0.7), ("rate", 0.56), ("shape", 0.5), ("squash", 0.9), ("sgate", 0.3), ("gmix", 0.1), ("gdens", 0.7), ("gsize", 0.2)]),
    ("Granular Cloud", &[("drive", 0.15), ("fold", 0.1), ("motion", 0.3), ("gmix", 0.75), ("gtime", 0.5), ("gsize", 0.6), ("gdens", 0.62), ("gspray", 0.6), ("grand", 0.45), ("gfb", 0.45), ("gwidth", 0.95), ("squash", 0.3), ("blur", 0.5)]),
    ("Octave Shatter", &[("drive", 0.4), ("fold", 0.5), ("ring", 0.3), ("ringhz", 0.5), ("gmix", 0.55), ("gdens", 0.72), ("gsize", 0.22), ("gpitch", 0.75), ("grand", 0.1), ("gfb", 0.3), ("gspray", 0.4), ("squash", 0.6)]),
    ("Comb Scream", &[("drive", 0.6), ("fold", 0.5), ("table", 0.1250), ("tableb", 0.2083), ("blend", 0.4), ("res", 0.8), ("comb", 0.8), ("ctune", 0.35), ("cmod", 0.5), ("motion", 0.5), ("rate", 0.3), ("squash", 0.7)]),
    ("Vowel Melt", &[("drive", 0.3), ("fold", 0.25), ("table", 0.0417), ("tableb", 0.3750), ("blend", 0.5), ("motion", 0.9), ("rate", 0.25), ("shape", 0.0), ("spread", 0.8), ("wobble", 0.4), ("squash", 0.5), ("blur", 0.6), ("sshift", 0.55), ("gmix", 0.2), ("gspray", 0.7)]),
    ("Vocoder Bass", &[("drive", 0.5), ("fold", 0.35), ("xsyn", 0.9), ("smix", 0.35), ("spos", 0.4), ("sscan", 0.6), ("motion", 0.6), ("rate", 0.3), ("squash", 0.35), ("res", 0.6), ("body", 0.5)]),
    ("Sample Cloud", &[("drive", 0.15), ("fold", 0.1), ("gsrc", 0.85), ("gmix", 0.75), ("gdens", 0.6), ("gsize", 0.45), ("gspray", 0.5), ("grand", 0.3), ("gfb", 0.35), ("gwidth", 0.9), ("spos", 0.5), ("sscan", 0.5), ("squash", 0.2), ("blur", 0.4)]),
    ("Sample Scan", &[("drive", 0.4), ("fold", 0.3), ("smix", 0.95), ("spos", 0.3), ("sscan", 1.0), ("motion", 0.8), ("rate", 0.3), ("res", 0.7), ("squash", 0.6), ("body", 0.4)]),
    ("Freeze Pad", &[("drive", 0.2), ("fold", 0.15), ("freeze", 0.9), ("blur", 0.55), ("gmix", 0.3), ("gspray", 0.6), ("squash", 0.4), ("xsyn", 0.3), ("width", 0.8)]),
    ("Sub Rattler", &[("drive", 0.6), ("fold", 0.2), ("table", 0.2917), ("tableb", 0.8750), ("blend", 0.4), ("pos", 0.2), ("body", 0.75), ("motion", 0.6), ("squash", 0.8), ("sgate", 0.2), ("level", 0.45)]),
];

fn gauss(d: f32, w: f32) -> f32 {
    (-(d * d) / (2.0 * w * w)).exp()
}

fn hash01(n: u32) -> f32 {
    let mut h = n.wrapping_mul(2654435761).wrapping_add(0x9e3779b9);
    h ^= h >> 15;
    h = h.wrapping_mul(2246822519);
    h ^= h >> 13;
    (h & 0xffff) as f32 / 65535.0
}

fn wrapdist(a: f32, b: f32) -> f32 {
    let d = (a - b).abs() % 1.0;
    d.min(1.0 - d)
}

/// gain (0..~1.6) of a band at log-frequency position `x` (0..1) for table `t` at position `m` (0..1)
pub fn table_gain(t: usize, x: f32, m: f32) -> f32 {
    match t {
        0 => {
            // five vowels (A E I O U) as formant pairs, morphed between neighbours
            const V: [(f32, f32); 5] = [(0.45, 0.62), (0.38, 0.72), (0.28, 0.8), (0.42, 0.55), (0.3, 0.5)];
            let f = m.clamp(0.0, 0.9999) * 4.0;
            let i = f.floor() as usize;
            let u = f - i as f32;
            let (a, b) = (V[i], V[i + 1]);
            let (f1, f2) = (a.0 + (b.0 - a.0) * u, a.1 + (b.1 - a.1) * u);
            gauss(x - f1, 0.055) * 1.5 + gauss(x - f2, 0.045) * 1.1 + gauss(x - (f2 + 0.14), 0.04) * 0.5 + 0.03
        }
        1 => {
            let c = 2.0 + m * 14.0;
            let v = 0.5 + 0.5 * (TAU * x * c).cos();
            v * v * 1.4 + 0.03
        }
        2 => {
            let c = 3.0 + m * 20.0;
            let v = (0.5 + 0.5 * (TAU * x * c).cos()).powi(6);
            v * 1.6 + 0.02
        }
        3 => 1.0 - gauss(x - m, 0.07) * 0.97,
        4 => {
            let mut s = 0.0;
            for k in 0..3 {
                let c = (m + k as f32 / 3.0).fract();
                s += (-(wrapdist(x, c).powi(2)) / (2.0 * 0.05 * 0.05)).exp();
            }
            s * 1.3 + 0.03
        }
        5 => {
            let frame = (m * 8.0).floor() as u32;
            let v = hash01(frame * 31 + (x * (BANDS as f32 - 1.0)).round() as u32);
            if v > 0.5 {
                v * 1.5
            } else {
                0.02
            }
        }
        6 => {
            let tilt = m * 2.0 - 1.0;
            ((0.5 + (x - 0.5) * tilt * 2.0).clamp(0.0, 1.0) * 1.5).max(0.03)
        }
        7 => gauss(x - m, 0.035) * 1.7 + 0.02,
        8 => {
            // "yoi": a low formant that dips while a high formant sweeps across the whole range, the classic growl vowel
            let f1 = 0.26 + 0.07 * (TAU * m).sin();
            let f2 = 0.3 + m * 0.55;
            gauss(x - f1, 0.05) * 1.3 + gauss(x - f2, 0.04) * 1.5 + gauss(x - (f2 + 0.17), 0.035) * 0.6 + 0.02
        }
        9 => (0.55 + 0.45 * (TAU * (x * (2.0 + 6.0 * m) + m * 3.0)).sin()).max(0.02) * 1.2,
        10 => {
            let mut g = 1.0;
            for j in 0..3 {
                g -= gauss(x - (m + j as f32 / 3.0).fract(), 0.035) * 0.95;
            }
            g.max(0.02) * 1.1
        }
        _ => {
            // stairs: alternating on / off blocks that slide with the position
            let k = ((x + m) * 6.0).floor() as i32;
            if k.rem_euclid(2) == 0 {
                1.3
            } else {
                0.03
            }
        }
    }
}

// ── a radix-2 FFT ──────────────────────────────────────────────────────────────
struct Fft {
    cos: Vec<f32>,
    sin: Vec<f32>,
    rev: Vec<usize>,
}
impl Fft {
    fn new() -> Self {
        let bits = N.trailing_zeros();
        let rev = (0..N).map(|i| i.reverse_bits() >> (usize::BITS - bits)).collect();
        let cos = (0..N / 2).map(|k| (TAU * k as f32 / N as f32).cos()).collect();
        let sin = (0..N / 2).map(|k| -(TAU * k as f32 / N as f32).sin()).collect();
        Fft { cos, sin, rev }
    }
    /// in place; `inverse` conjugates the twiddles (no scaling)
    fn run(&self, re: &mut [f32], im: &mut [f32], inverse: bool) {
        for i in 0..N {
            let j = self.rev[i];
            if j > i {
                re.swap(i, j);
                im.swap(i, j);
            }
        }
        let mut len = 2;
        while len <= N {
            let step = N / len;
            for s in (0..N).step_by(len) {
                for k in 0..len / 2 {
                    let (wr, wi) = (self.cos[k * step], if inverse { -self.sin[k * step] } else { self.sin[k * step] });
                    let (a, b) = (s + k, s + k + len / 2);
                    let (tr, ti) = (re[b] * wr - im[b] * wi, re[b] * wi + im[b] * wr);
                    re[b] = re[a] - tr;
                    im[b] = im[a] - ti;
                    re[a] += tr;
                    im[a] += ti;
                }
            }
            len <<= 1;
        }
    }
}

/// what the spectral stage does this frame
#[derive(Clone, Copy)]
struct SpecParams {
    squash: f32,
    radius: usize,
    shift_bins: i32,
    gate: f32,
    blur: f32,
    tilt: f32,
    /// how far the sample's spectral envelope is laid over the input's (0 = not at all)
    xsyn: f32,
    /// hold the spectrum as it was (0 = live, 1 = frozen)
    freeze: f32,
}

/// streaming spectral processor for one channel
struct Spectral {
    inbuf: Vec<f32>,
    outacc: Vec<f32>,
    pos: usize,
    count: usize,
    re: Vec<f32>,
    im: Vec<f32>,
    mag: Vec<f32>,
    env: Vec<f32>,
    sre: Vec<f32>,
    sim: Vec<f32>,
    prev_g: Vec<f32>,
    frozen: Vec<f32>,
    fphase: Vec<f32>,
}
impl Spectral {
    fn new() -> Self {
        let h = N / 2 + 1;
        Spectral { inbuf: vec![0.0; N], outacc: vec![0.0; N], pos: 0, count: 0, re: vec![0.0; N], im: vec![0.0; N], mag: vec![0.0; h], env: vec![0.0; h], sre: vec![0.0; h], sim: vec![0.0; h], prev_g: vec![1.0; h], frozen: vec![0.0; h], fphase: vec![0.0; h] }
    }

    #[inline]
    fn tick(&mut self, x: f32, fft: &Fft, win: &[f32], sp: &SpecParams, smag: Option<&[f32]>) -> f32 {
        let p = self.pos;
        let wet = self.outacc[p];
        self.outacc[p] = 0.0;
        self.inbuf[p] = x;
        self.pos = (p + 1) % N;
        self.count += 1;
        if self.count % HOP == 0 {
            self.frame(fft, win, sp, smag);
        }
        wet
    }

    fn frame(&mut self, fft: &Fft, win: &[f32], sp: &SpecParams, smag: Option<&[f32]>) {
        let half = N / 2;
        for i in 0..N {
            self.re[i] = self.inbuf[(self.pos + i) % N] * win[i];
            self.im[i] = 0.0;
        }
        fft.run(&mut self.re, &mut self.im, false);
        // bin shift: the whole spectrum slides up or down by whole bins (inharmonic, digital)
        let sh = sp.shift_bins;
        for k in 0..=half {
            let src = k as i32 - sh;
            if src >= 0 && src <= half as i32 {
                self.sre[k] = self.re[src as usize];
                self.sim[k] = self.im[src as usize];
            } else {
                self.sre[k] = 0.0;
                self.sim[k] = 0.0;
            }
            self.mag[k] = (self.sre[k] * self.sre[k] + self.sim[k] * self.sim[k]).sqrt();
        }
        // the local spectral envelope: a moving average of the magnitudes
        let r = sp.radius.max(1);
        let mut acc: f32 = self.mag[..=r.min(half)].iter().sum();
        let mut cnt = r.min(half) + 1;
        let mut peak = 1e-9f32;
        for k in 0..=half {
            self.env[k] = (acc / cnt as f32).max(0.0);
            peak = peak.max(self.mag[k]);
            let add = k + r + 1;
            if add <= half {
                acc += self.mag[add];
                cnt += 1;
            }
            if k >= r {
                acc -= self.mag[k - r];
                cnt -= 1;
            }
        }
        // cross-synthesis: the sample's spectral envelope, level-matched to the input's, is laid over the input's own envelope
        let xs = sp.xsyn;
        let (xscale, use_x) = match smag {
            Some(sm) if xs > 0.01 && sm.len() > half => {
                let ei: f32 = self.env[..=half].iter().sum();
                let es: f32 = sm[..=half].iter().sum();
                (ei / (es + 1e-12), true)
            }
            _ => (1.0, false),
        };
        let amt = sp.squash * 0.9;
        let gate_level = peak * sp.gate * sp.gate * 0.25;
        let smooth = sp.blur * 0.92;
        for k in 0..=half {
            let m = self.mag[k] + 1e-9;
            // peaks come down, valleys come up toward the envelope (bounded so the noise floor does not explode)
            let mut g = ((self.env[k] + 1e-9) / m).powf(amt).clamp(0.05, 6.0);
            if sp.gate > 0.01 && m < gate_level {
                g *= 0.04;
            }
            // spectral tilt: up to +-9 dB across the range
            g *= 10f32.powf(sp.tilt * (k as f32 / half as f32 - 0.5) * 18.0 / 20.0);
            if use_x {
                let sm = smag.unwrap();
                g *= (((sm[k] * xscale) + 1e-9) / (self.env[k] + 1e-9)).powf(xs).clamp(0.03, 10.0);
            }

            // blur: the gains glide between frames so the spectrum smears in time
            g = self.prev_g[k] * smooth + g * (1.0 - smooth);
            self.prev_g[k] = g;
            let (mut ore, mut oim) = (self.sre[k] * g, self.sim[k] * g);
            // freeze: while the knob is down the picture of the spectrum (magnitudes and phases) is refreshed every frame; turned up, the
            // last picture keeps sounding, its phases advancing like steady partials, and fades over the live sound
            let fz = sp.freeze.min(1.0);
            if fz < 0.02 {
                self.frozen[k] = m * g;
                self.fphase[k] = self.sim[k].atan2(self.sre[k]);
            } else {
                self.fphase[k] = (self.fphase[k] + TAU * k as f32 * HOP as f32 / N as f32).rem_euclid(TAU);
                let fm = self.frozen[k];
                ore = ore * (1.0 - fz) + fm * self.fphase[k].cos() * fz;
                oim = oim * (1.0 - fz) + fm * self.fphase[k].sin() * fz;
            }
            self.re[k] = ore;
            self.im[k] = oim;
            if k > 0 && k < half {
                self.re[N - k] = self.re[k];
                self.im[N - k] = -self.im[k];
            }
        }
        self.im[0] = 0.0;
        self.im[half] = 0.0;
        fft.run(&mut self.re, &mut self.im, true);
        // sqrt-hann analysis + synthesis at 4x overlap sums to 2, the inverse FFT scales by N
        let scale = 0.5 / N as f32;
        for i in 0..N {
            self.outacc[(self.pos + i) % N] += self.re[i] * win[i] * scale;
        }
    }
}

// ── the band-pass filter bank ──────────────────────────────────────────────────
#[derive(Clone, Copy, Default)]
struct Band {
    ic1: f32,
    ic2: f32,
    a1: f32,
    a2: f32,
    a3: f32,
    k: f32,
}
impl Band {
    /// zero-delay-feedback state variable band-pass, normalised to unity gain at the centre
    fn set(&mut self, f: f32, res: f32, sr: f32) {
        let g = (std::f32::consts::PI * f.clamp(30.0, sr * 0.45) / sr).tan();
        let q = 0.7 + res * res * 45.0;
        self.k = 1.0 / q;
        self.a1 = 1.0 / (1.0 + g * (g + self.k));
        self.a2 = g * self.a1;
        self.a3 = g * self.a2;
    }
    #[inline]
    fn tick(&mut self, x: f32) -> f32 {
        let v3 = x - self.ic2;
        let v1 = self.a1 * self.ic1 + self.a2 * v3;
        let v2 = self.ic2 + self.a2 * self.ic1 + self.a3 * v3;
        self.ic1 = 2.0 * v1 - self.ic1;
        self.ic2 = 2.0 * v2 - self.ic2;
        v1 * self.k
    }
}

/// A sample analysed for the growl: its mono audio (at the effect's rate) for the grains, and per analysis frame its smoothed spectral
/// envelope (for cross-synthesis) and 24 band gains (for the wavetable filter).
struct SampleBank {
    path: String,
    mono: Vec<f32>,
    nframes: usize,
    env: Vec<f32>,
    gains: Vec<[f32; BANDS]>,
}

impl SampleBank {
    fn empty() -> Self {
        SampleBank { path: String::new(), mono: vec![], nframes: 0, env: vec![], gains: vec![] }
    }

    /// `mono` must already be at the effect's sample rate
    fn analyze(mut mono: Vec<f32>, path: &str, sr: f32, fft: &Fft) -> SampleBank {
        mono.truncate((sr * 20.0) as usize);
        let peak = mono.iter().fold(0.0f32, |a, b| a.max(b.abs())).max(1e-6);
        for x in mono.iter_mut() {
            *x = *x / peak * 0.7;
        }
        let half = N / 2;
        let hop = (mono.len().saturating_sub(N) / 383).max(512);
        let nframes = if mono.len() <= N { 1 } else { (mono.len() - N) / hop + 1 };
        let hann: Vec<f32> = (0..N).map(|i| 0.5 - 0.5 * (TAU * i as f32 / N as f32).cos()).collect();
        // band edges: geometric means between the filter bank's centres, as bins
        let centre = |k: usize| 50.0 * (12000.0f32 / 50.0).powf(k as f32 / (BANDS - 1) as f32);
        let bin_of = |f: f32| ((f * N as f32 / sr) as usize).min(half);
        let mut edges = [0usize; BANDS + 1];
        for k in 0..=BANDS {
            let f = if k == 0 { centre(0) / 1.15 } else if k == BANDS { centre(BANDS - 1) * 1.15 } else { (centre(k - 1) * centre(k)).sqrt() };
            edges[k] = bin_of(f);
        }
        let mut env = vec![0.0f32; nframes * (half + 1)];
        let mut raw_gains: Vec<[f32; BANDS]> = Vec::with_capacity(nframes);
        let (mut re, mut im) = (vec![0.0f32; N], vec![0.0f32; N]);
        for f in 0..nframes {
            for i in 0..N {
                re[i] = mono.get(f * hop + i).copied().unwrap_or(0.0) * hann[i];
                im[i] = 0.0;
            }
            fft.run(&mut re, &mut im, false);
            let mag: Vec<f32> = (0..=half).map(|k| (re[k] * re[k] + im[k] * im[k]).sqrt() / (N as f32 * 0.25)).collect();
            // smooth: a moving average of 6 bins each way (the envelope, not the harmonics)
            let row = &mut env[f * (half + 1)..(f + 1) * (half + 1)];
            let mut acc: f32 = mag[..=6.min(half)].iter().sum();
            let mut cnt = 6.min(half) + 1;
            for k in 0..=half {
                row[k] = acc / cnt as f32;
                if k + 7 <= half {
                    acc += mag[k + 7];
                    cnt += 1;
                }
                if k >= 6 {
                    acc -= mag[k - 6];
                    cnt -= 1;
                }
                if acc < 0.0 {
                    acc = 0.0;
                }
            }
            let mut g = [0.0f32; BANDS];
            for k in 0..BANDS {
                let (a, b) = (edges[k], edges[k + 1].max(edges[k] + 1).min(half));
                g[k] = row[a..=b.max(a)].iter().sum::<f32>() / ((b.max(a) - a + 1) as f32);
            }
            raw_gains.push(g);
        }
        let gmax = raw_gains.iter().flat_map(|g| g.iter()).fold(1e-9f32, |a, b| a.max(*b));
        let gains = raw_gains.iter().map(|g| std::array::from_fn(|k| (g[k] / gmax).sqrt() * 1.5)).collect();
        SampleBank { path: path.to_string(), mono, nframes, env, gains }
    }

    /// the frame position 0..1 as (frame, next frame, fraction)
    fn at(&self, pos: f32) -> (usize, usize, f32) {
        if self.nframes <= 1 {
            return (0, 0, 0.0);
        }
        let f = pos.clamp(0.0, 1.0) * (self.nframes - 1) as f32;
        let i = f.floor() as usize;
        (i, (i + 1).min(self.nframes - 1), f - i as f32)
    }

    fn band_gain(&self, pos: f32, k: usize) -> f32 {
        if self.nframes == 0 {
            return 1.0;
        }
        let (a, b, t) = self.at(pos);
        self.gains[a][k] * (1.0 - t) + self.gains[b][k] * t
    }

    fn fill_env(&self, pos: f32, out: &mut [f32]) {
        if self.nframes == 0 {
            out.iter_mut().for_each(|x| *x = 1.0);
            return;
        }
        let w = N / 2 + 1;
        let (a, b, t) = self.at(pos);
        for k in 0..w.min(out.len()) {
            out[k] = self.env[a * w + k] * (1.0 - t) + self.env[b * w + k] * t;
        }
    }
}

/// The built-in sample: three seconds of a sung vowel gliding A - E - I - O - U over a 110 Hz saw with a little vibrato and breath.
fn default_sample(sr: f32) -> Vec<f32> {
    const VOW: [(f32, f32); 5] = [(800.0, 1200.0), (450.0, 2000.0), (300.0, 2300.0), (500.0, 900.0), (320.0, 800.0)];
    let n = (sr * 3.0) as usize;
    let mut f1 = Band::default();
    let mut f2 = Band::default();
    let mut rng = Rng(0xC0FFEE);
    let mut ph = 0.0f32;
    let mut out = Vec::with_capacity(n);
    for i in 0..n {
        let t = i as f32 / n as f32;
        let pos = t * 4.0;
        let a = (pos.floor() as usize).min(3);
        let u = pos - a as f32;
        let (c1, c2) = (VOW[a].0 + (VOW[a + 1].0 - VOW[a].0) * u, VOW[a].1 + (VOW[a + 1].1 - VOW[a].1) * u);
        if i % 64 == 0 {
            f1.set(c1, 0.8, sr);
            f2.set(c2, 0.8, sr);
        }
        ph = (ph + (110.0 * (1.0 + 0.012 * (TAU * 5.5 * i as f32 / sr).sin())) / sr).fract();
        let src = (ph * 2.0 - 1.0) * 0.8 + (rng.next() - 0.5) * 0.12;
        out.push(f1.tick(src) * 1.0 + f2.tick(src) * 0.6);
    }
    out
}

#[derive(Clone, Copy, Default)]
struct Grain {
    /// reads the loaded sample instead of the live input
    src: bool,
    live: bool,
    pos: f32,
    step: f32,
    age: u32,
    len: u32,
    gl: f32,
    gr: f32,
}

struct Rng(u32);
impl Rng {
    #[inline]
    fn next(&mut self) -> f32 {
        self.0 ^= self.0 << 13;
        self.0 ^= self.0 >> 17;
        self.0 ^= self.0 << 5;
        (self.0 & 0xffffff) as f32 / 16777215.0
    }
}

pub struct Hypergrowl {
    sr: f32,
    p: [f32; 52],
    bands: [[Band; BANDS]; 2],
    gains: [[f32; BANDS]; 2],
    norm: [f32; 2],
    // motion
    lfo: f32,
    sh_val: f32,
    drift_v: f32,
    drift_t: f32,
    env: f32,
    l1: f32,
    l2: f32,
    rng: Rng,
    // drive
    dc: [(f32, f32); 2],
    ring_ph: f32,
    hold: [(f32, u32); 2],
    low: [f32; 2],
    /// the fold stage's oversampling memory (last input, last shaped value)
    os: [(f32, f32); 2],
    /// the octave-down generator: flip-flop, last low-passed input, two smoothing stages
    sub_div: [bool; 2],
    sub_last: [f32; 2],
    sub_lp: [(f32, f32); 2],
    // comb
    comb_buf: [Vec<f32>; 2],
    comb_pos: usize,
    // grain
    gbuf: [Vec<f32>; 2],
    gpos: usize,
    grains: [Grain; GRAINS],
    gphase: f32,
    gfeed: [f32; 2],
    // spectral + dry
    fft: Fft,
    win: Vec<f32>,
    spec: [Spectral; 2],
    bank: SampleBank,
    cur_smag: [Vec<f32>; 2],
    spos_now: [f32; 2],
    dryring: [Vec<f32>; 2],
    dpos: usize,
    ticks: usize,
}

impl Hypergrowl {
    pub fn new(sr: f32) -> Self {
        let win = (0..N).map(|i| (0.5 - 0.5 * (TAU * i as f32 / N as f32).cos()).sqrt()).collect();
        let gn = (sr * GBUF_SECS) as usize + 8;
        let mut s = Hypergrowl {
            sr,
            p: [0.0; 52],
            bands: [[Band::default(); BANDS]; 2],
            gains: [[1.0; BANDS]; 2],
            norm: [1.0; 2],
            lfo: 0.0,
            sh_val: 0.5,
            drift_v: 0.0,
            drift_t: 0.0,
            env: 0.0,
            l1: 0.5,
            l2: 0.5,
            rng: Rng(0x1234_5678),
            dc: [(0.0, 0.0); 2],
            ring_ph: 0.0,
            hold: [(0.0, 0); 2],
            low: [0.0; 2],
            os: [(0.0, 0.0); 2],
            sub_div: [false; 2],
            sub_last: [0.0; 2],
            sub_lp: [(0.0, 0.0); 2],
            comb_buf: [vec![0.0; (sr * 0.06) as usize + 4], vec![0.0; (sr * 0.06) as usize + 4]],
            comb_pos: 0,
            gbuf: [vec![0.0; gn], vec![0.0; gn]],
            gpos: 0,
            grains: [Grain::default(); GRAINS],
            gphase: 0.0,
            gfeed: [0.0; 2],
            fft: Fft::new(),
            win,
            spec: [Spectral::new(), Spectral::new()],
            bank: SampleBank::empty(),
            cur_smag: [vec![0.0; N / 2 + 1], vec![0.0; N / 2 + 1]],
            spos_now: [0.4; 2],
            dryring: [vec![0.0; N], vec![0.0; N]],
            dpos: 0,
            ticks: 0,
        };
        for (n, v) in PARAMS {
            s.set_param(n, v);
        }
        s.bank = SampleBank::analyze(default_sample(sr), "", sr, &s.fft);
        s.control();
        s
    }

    #[inline]
    fn v(&self, name: usize) -> f32 {
        self.p[name]
    }

    /// the motion generators and the band gains / centres; every BLOCK samples
    fn control(&mut self) {
        let rate_hz = 0.05 * 200f32.powf(self.v(18));
        let shape = ((self.v(20) * SHAPES.len() as f32) as usize).min(SHAPES.len() - 1);
        let steps = self.v(21);
        let spread = self.v(12);
        // drift: a slow random walk toward fresh targets
        if self.ticks % 2048 == 0 {
            self.drift_t = self.rng.next() * 2.0 - 1.0;
        }
        self.drift_v += (self.drift_t - self.drift_v) * 0.0008;
        let wave = |ph: f32, sh: f32| -> f32 {
            let f = ph.rem_euclid(1.0);
            match shape {
                0 => 0.5 + 0.5 * (TAU * f).sin(),
                1 => 1.0 - (2.0 * f - 1.0).abs(),
                2 => f,
                3 => {
                    if f < 0.5 {
                        1.0
                    } else {
                        0.0
                    }
                }
                _ => sh,
            }
        };
        let quant = |v: f32| -> f32 {
            if steps > 0.03 {
                let n = 2.0 + (1.0 - steps) * 14.0;
                ((v * n).floor() / (n - 1.0)).clamp(0.0, 1.0)
            } else {
                v
            }
        };
        let l1 = quant(wave(self.lfo, self.sh_val));
        self.l1 = l1;
        self.l2 = 0.5 + 0.5 * (TAU * (self.lfo * 0.37 + 0.2)).sin();
        let (pos, motion, follow, wobble) = (self.v(9), self.v(19), self.v(22), self.v(24));
        let (tbl_a, tbl_b) = (((self.v(6) * 12.0) as usize).min(11), ((self.v(7) * 12.0) as usize).min(11));
        let (blend, res, shift, tilt, drift) = (self.v(8), self.v(10), self.v(11), self.v(13), self.v(23));
        if self.v(47) > 0.01 && self.ticks % 128 == 0 {
            let (a, b) = (self.spos_now[0], self.spos_now[1]);
            let mut buf0 = std::mem::take(&mut self.cur_smag[0]);
            let mut buf1 = std::mem::take(&mut self.cur_smag[1]);
            self.bank.fill_env(a, &mut buf0);
            self.bank.fill_env(b, &mut buf1);
            self.cur_smag = [buf0, buf1];
        }
        for ch in 0..2 {
            // the right channel runs the motion a little late (the rainbow widens as `spread` rises)
            let l = if ch == 1 { quant(wave(self.lfo + spread * 0.5, self.sh_val)) } else { l1 };
            let m = (pos + (l - 0.5) * motion * 1.2 + follow * (self.env - 0.15) * 1.6 + self.drift_v * drift * 0.5).clamp(0.0, 1.0);
            // where in the sample this channel is reading (the motion scans it, scaled by `sscan`)
            let sp_ch = (self.v(45) + (l - 0.5) * motion * self.v(46) * 1.4 + follow * (self.env - 0.15) * 0.8).clamp(0.0, 1.0);
            self.spos_now[ch] = sp_ch;
            let smix = self.v(44);
            let sh = (shift - 0.5) * 3.0 + (self.l2 - 0.5) * wobble * 2.0 + if ch == 1 { spread * 0.12 } else { -spread * 0.12 };
            let mut sum2 = 0.0;
            for k in 0..BANDS {
                let x = k as f32 / (BANDS - 1) as f32;
                let g = table_gain(tbl_a, x, m) * (1.0 - blend) + table_gain(tbl_b, x, m) * blend;
                let g = if smix > 0.001 { g * (1.0 - smix) + self.bank.band_gain(sp_ch, k) * smix } else { g };
                let g = g * 10f32.powf((tilt - 0.5) * 2.0 * (x - 0.5) * 12.0 / 20.0);
                self.gains[ch][k] += (g - self.gains[ch][k]) * 0.4;
                sum2 += self.gains[ch][k] * self.gains[ch][k];
                let det = 2f32.powf(spread * 0.3 * (k as f32 * 2.1 + ch as f32 * 3.14).sin());
                let f = 50.0 * (12000.0f32 / 50.0).powf(x) * det * 2f32.powf(sh);
                self.bands[ch][k].set(f, res, self.sr);
            }
            self.norm[ch] = 0.9 * (BANDS as f32).sqrt() / (sum2.sqrt() + 0.3);
        }
    }

    fn spawn_grain(&mut self) {
        let Some(slot) = self.grains.iter().position(|g| !g.live) else { return };
        let (gtime, gsize, spray, gpitch, grand, grev, width) = (self.v(26), self.v(27), self.v(29), self.v(30), self.v(31), self.v(32), self.v(34));
        let use_sample = self.v(49) > 0.001 && !self.bank.mono.is_empty() && self.rng.next() < self.v(49);
        let size = 0.01 * 30f32.powf(gsize);
        let delay0 = 0.005 * 180f32.powf(gtime);
        let semis = (gpitch - 0.5) * 48.0 + (self.rng.next() * 2.0 - 1.0) * grand * 12.0 + (self.l2 - 0.5) * self.v(24) * 6.0;
        let ratio = 2f32.powf(semis / 12.0);
        let reverse = self.rng.next() < grev;
        let len = (size * self.sr).max(64.0);
        if use_sample {
            let sl = self.bank.mono.len() as f32;
            let start = (self.spos_now[0] * sl + (self.rng.next() - 0.5) * spray * 0.5 * self.sr).rem_euclid(sl);
            let pan = 0.5 + (self.rng.next() - 0.5) * width;
            self.grains[slot] = Grain { src: true, live: true, pos: start, step: if reverse { -ratio } else { ratio }, age: 0, len: len as u32, gl: (1.0 - pan).sqrt(), gr: pan.sqrt() };
            return;
        }
        let n = self.gbuf[0].len() as f32;
        // a grain reading forward must not catch the write head: it needs `len * ratio` of room
        let room = if reverse { 0.0 } else { len * ratio.max(1.0) };
        let delay = (delay0 * self.sr + self.rng.next() * spray * 0.5 * self.sr).max(room + 4.0).min(n - len * ratio.max(1.0) - 8.0);
        let start = (self.gpos as f32 - delay).rem_euclid(n);
        let pan = 0.5 + (self.rng.next() - 0.5) * width;
        self.grains[slot] = Grain { src: false, live: true, pos: start, step: if reverse { -ratio } else { ratio }, age: 0, len: len as u32, gl: (1.0 - pan).sqrt(), gr: pan.sqrt() };
    }

    /// clear every buffer and filter state (after a non-finite value slipped in)
    fn reset_state(&mut self) {
        for ch in 0..2 {
            for b in self.bands[ch].iter_mut() {
                b.ic1 = 0.0;
                b.ic2 = 0.0;
            }
            self.gains[ch] = [1.0; BANDS];
            self.comb_buf[ch].iter_mut().for_each(|x| *x = 0.0);
            self.gbuf[ch].iter_mut().for_each(|x| *x = 0.0);
            self.dc[ch] = (0.0, 0.0);
            self.hold[ch] = (0.0, 0);
            self.low[ch] = 0.0;
            self.gfeed[ch] = 0.0;
            let sp = &mut self.spec[ch];
            sp.inbuf.iter_mut().for_each(|x| *x = 0.0);
            sp.outacc.iter_mut().for_each(|x| *x = 0.0);
            sp.prev_g.iter_mut().for_each(|x| *x = 1.0);
            self.dryring[ch].iter_mut().for_each(|x| *x = 0.0);
        }
        self.grains.iter_mut().for_each(|g| g.live = false);
        self.env = 0.0;
        self.drift_v = 0.0;
    }

    /// the bank's gains for the display: 24 gains of the left channel, then the LFO and the number of live grains (0..1)
    pub fn view_data(&self) -> Vec<f32> {
        let mut v: Vec<f32> = self.gains[0].iter().map(|g| (g / 1.6).clamp(0.0, 1.0)).collect();
        v.push(self.l1);
        v.push(self.grains.iter().filter(|g| g.live).count() as f32 / GRAINS as f32);
        // the sample's band gains at its current reading position, then that position
        v.extend((0..BANDS).map(|k| (self.bank.band_gain(self.spos_now[0], k) / 1.6).clamp(0.0, 1.0)));
        v.push(self.spos_now[0]);
        v
    }

    /// Load a WAV / FLAC / OGG as the spectral source. Returns false when the file cannot be read.
    pub fn load_sample(&mut self, path: &str) -> bool {
        let Ok(d) = ling_music::decode::load(path) else { return false };
        let ch = 2usize;
        let frames = d.stereo.len() / ch;
        if frames < 64 {
            return false;
        }
        let mono: Vec<f32> = (0..frames).map(|i| 0.5 * (d.stereo[i * 2] + d.stereo[i * 2 + 1])).collect();
        // to this effect's sample rate
        let ratio = d.rate as f32 / self.sr;
        let outn = ((frames as f32 / ratio) as usize).min((self.sr * 20.0) as usize);
        let res: Vec<f32> = (0..outn)
            .map(|i| {
                let x = i as f32 * ratio;
                let (a, f) = (x as usize, x.fract());
                let b = (a + 1).min(frames - 1);
                mono[a.min(frames - 1)] * (1.0 - f) + mono[b] * f
            })
            .collect();
        self.bank = SampleBank::analyze(res, path, self.sr, &self.fft);
        for g in self.grains.iter_mut() {
            g.live = false;
        }
        true
    }
}

impl Effect for Hypergrowl {
    fn process(&mut self, b: &mut [f32]) {
        let (drive, fold, bias, crush, ring, ringhz) = (self.v(0), self.v(1), self.v(2), self.v(3), self.v(4), self.v(5));
        let (body, rate, ctune, comb, cmod) = (self.v(14), self.v(18), self.v(16), self.v(15), self.v(17));
        let (gmix, gdens, gsize, gfb) = (self.v(25), self.v(28), self.v(27), self.v(33));
        let (squash, focus, sshift, sgate, blur, stilt) = (self.v(35), self.v(36), self.v(37), self.v(38), self.v(39), self.v(40));
        let (width, level, mix) = (self.v(41), self.v(42), self.v(43));
        let (sub_amt, warm) = (self.v(50), self.v(51));
        let sr = self.sr;
        let dphase = 0.05 * 200f32.powf(rate) / sr;
        let dg = 1.0 + drive * 14.0;
        let dbias = (bias - 0.5) * 1.2;
        let fa = 1.0 + fold * 5.0;
        let ring_inc = 20.0 * 100f32.powf(ringhz) / sr;
        let bits = 16.0 - crush * 13.0;
        let levels = 2f32.powf(bits) * 0.5;
        let hold_n = 1 + (crush * crush * 20.0) as u32;
        let lp_a = 1.0 - (-TAU * 140.0 / sr).exp();
        let comb_len = self.comb_buf[0].len();
        let sp = SpecParams { squash, radius: (2.0 + (1.0 - focus) * (1.0 - focus) * 40.0) as usize, shift_bins: ((sshift - 0.5) * 80.0).round() as i32, gate: sgate, blur, tilt: (stilt - 0.5) * 2.0, xsyn: self.v(47), freeze: self.v(48) };
        // +6.8 dB of makeup at the middle of the level knob: the filter bank and the spectral stage take level away
        let gain_out = 2.2 * 10f32.powf((level - 0.5) * 24.0 / 20.0);
        let grain_rate = 2.0 * 60f32.powf(gdens) / sr;
        let grain_secs = 0.01 * 30f32.powf(gsize);
        let overlap = (2.0 * 60f32.powf(gdens) * grain_secs).max(1.0);
        let gnorm = 1.2 / overlap.sqrt();
        let gbuf_n = self.gbuf[0].len();
        for fr in b.chunks_exact_mut(2) {
            if self.ticks % BLOCK == 0 {
                self.control();
            }
            self.ticks = self.ticks.wrapping_add(1);
            // motion clocks
            self.lfo += dphase;
            if self.lfo >= 1.0 {
                self.lfo -= 1.0;
                self.sh_val = self.rng.next();
            }
            let env_in = fr[0].abs().max(fr[1].abs());
            self.env += (env_in - self.env) * if env_in > self.env { 0.02 } else { 0.0015 };
            let ring_ph = self.ring_ph;
            self.ring_ph = (self.ring_ph + ring_inc).fract();
            let mut sig = [0.0f32; 2];
            let mut dry = [0.0f32; 2];
            let cp = self.comb_pos;
            for c in 0..2 {
                // NaN / infinity from upstream would live on in the feedback paths: treat them as silence
                let x = if fr[c].is_finite() { fr[c].clamp(-16.0, 16.0) } else { 0.0 };
                dry[c] = x;
                // ── drive ──
                let y = x * dg + dbias;
                // warmth: a squared term adds even harmonics (the DC it makes is removed below)
                let y = y + warm * 0.35 * y * y.abs();
                // the fold runs at twice the rate (a point halfway to the last sample, a 1-2-1 average back down) so it aliases less
                let shape = |v: f32| v.tanh() * (1.0 - fold) + (v * fa * 0.8).sin() * fold;
                let (dcur, dmid) = (shape(y), shape(0.5 * (self.os[c].0 + y)));
                let mut d = 0.25 * self.os[c].1 + 0.5 * dmid + 0.25 * dcur;
                self.os[c] = (y, dcur);
                if ring > 0.001 {
                    d = d * (1.0 - ring) + d * (TAU * ring_ph).sin() * ring * 1.4;
                }
                if crush > 0.001 {
                    if self.hold[c].1 == 0 {
                        self.hold[c] = ((d * levels).round() / levels, hold_n - 1);
                    } else {
                        self.hold[c].1 -= 1;
                    }
                    d = self.hold[c].0;
                }
                // DC block
                let (px, py) = self.dc[c];
                let o = d - px + 0.995 * py;
                self.dc[c] = (d, o);
                let d = o;
                // ── wavetable filter bank ──
                let mut y = 0.0;
                for k in 0..BANDS {
                    y += self.bands[c][k].tick(d) * self.gains[c][k];
                }
                y *= self.norm[c];
                // the clean low end underneath, so a bass keeps its weight
                self.low[c] += (x - self.low[c]) * lp_a;
                y += self.low[c] * body * 2.0;
                // an octave below: a flip-flop toggled by each rising zero crossing of the low end, smoothed to a soft wave, following the input level
                if sub_amt > 0.001 {
                    let l = self.low[c];
                    if self.sub_last[c] <= 0.0 && l > 0.0 {
                        self.sub_div[c] = !self.sub_div[c];
                    }
                    self.sub_last[c] = l;
                    let sq = if self.sub_div[c] { 1.0 } else { -1.0 };
                    self.sub_lp[c].0 += (sq - self.sub_lp[c].0) * 0.02;
                    self.sub_lp[c].1 += (self.sub_lp[c].0 - self.sub_lp[c].1) * 0.02;
                    y += self.sub_lp[c].1 * sub_amt * 1.4 * (self.env * 3.0).min(1.0);
                }
                // ── comb ──
                if comb > 0.002 {
                    let fc = 40.0 * 50f32.powf(ctune) * 2f32.powf((self.l2 - 0.5) * cmod * 1.5);
                    let dl = (sr / fc.clamp(30.0, sr * 0.4)).clamp(2.0, comb_len as f32 - 3.0);
                    let rp = (cp as f32 + comb_len as f32 - dl) % comb_len as f32;
                    let (i0, fr_) = (rp as usize, rp.fract());
                    let i1 = (i0 + 1) % comb_len;
                    let delayed = self.comb_buf[c][i0] * (1.0 - fr_) + self.comb_buf[c][i1] * fr_;
                    let fb = comb * 0.88;
                    self.comb_buf[c][cp] = (y + delayed * fb).clamp(-6.0, 6.0);
                    y = y * (1.0 - comb * 0.4) + delayed * comb * 0.9;
                }
                sig[c] = y;
            }
            self.comb_pos = (cp + 1) % comb_len;
            // ── grain delay ──
            let pre = sig;
            if gmix > 0.002 {
                self.gphase += grain_rate;
                while self.gphase >= 1.0 {
                    self.gphase -= 1.0;
                    self.spawn_grain();
                }
                let mut go = [0.0f32; 2];
                for gi in 0..GRAINS {
                    let g = self.grains[gi];
                    if !g.live {
                        continue;
                    }
                    let w = 0.5 - 0.5 * (TAU * g.age as f32 / g.len as f32).cos();
                    let i0 = g.pos.floor();
                    let f = g.pos - i0;
                    let (l, r, wrap) = if g.src {
                        let sl = self.bank.mono.len().max(1);
                        let i0 = (i0 as isize).rem_euclid(sl as isize) as usize;
                        let i1 = (i0 + 1) % sl;
                        let v = self.bank.mono[i0] * (1.0 - f) + self.bank.mono[i1] * f;
                        (v, v, sl as f32)
                    } else {
                        let i0 = (i0 as isize).rem_euclid(gbuf_n as isize) as usize;
                        let i1 = (i0 + 1) % gbuf_n;
                        (self.gbuf[0][i0] * (1.0 - f) + self.gbuf[0][i1] * f, self.gbuf[1][i0] * (1.0 - f) + self.gbuf[1][i1] * f, gbuf_n as f32)
                    };
                    go[0] += l * w * g.gl;
                    go[1] += r * w * g.gr;
                    let gm = &mut self.grains[gi];
                    gm.pos = (gm.pos + gm.step).rem_euclid(wrap);
                    gm.age += 1;
                    if gm.age >= gm.len {
                        gm.live = false;
                    }
                }
                for c in 0..2 {
                    go[c] *= gnorm;
                    // feedback: the grains feed what they hear back into the buffer
                    self.gfeed[c] = (go[c] * gfb * 0.85).clamp(-2.0, 2.0);
                }
                for c in 0..2 {
                    sig[c] = sig[c] * (1.0 - gmix * 0.4) + go[c] * gmix * 1.3;
                }
            }
            // the buffer always listens (so turning the grains on finds sound waiting), with the feedback of the last frame
            for c in 0..2 {
                self.gbuf[c][self.gpos] = (pre[c] + self.gfeed[c]).clamp(-8.0, 8.0);
            }
            self.gpos = (self.gpos + 1) % gbuf_n;
            // ── spectral ──
            let mut out = [0.0f32; 2];
            for c in 0..2 {
                let smag = if sp.xsyn > 0.01 { Some(&self.cur_smag[c][..]) } else { None };
                out[c] = self.spec[c].tick(sig[c].clamp(-6.0, 6.0), &self.fft, &self.win, &sp, smag);
            }
            // whatever went wrong inside, never let a non-finite value stay in the state
            if !(out[0].is_finite() && out[1].is_finite()) {
                self.reset_state();
                out = [0.0; 2];
            }
            // ── output: width, level, limiter, mix ──
            let (m, s) = ((out[0] + out[1]) * 0.5, (out[0] - out[1]) * 0.5);
            let s = s * width * 2.0;
            let (l, r) = ((m + s) * gain_out, (m - s) * gain_out);
            let dp = self.dpos;
            for (c, wet) in [l, r].into_iter().enumerate() {
                let dly = self.dryring[c][dp];
                self.dryring[c][dp] = dry[c];
                fr[c] = wet.tanh() * mix + dly * (1.0 - mix);
            }
            self.dpos = (dp + 1) % N;
        }
    }
    fn set_param(&mut self, n: &str, v: f32) {
        let i = pidx(n);
        if i < self.p.len() {
            self.p[i] = v.clamp(0.0, 1.0);
        }
    }
    fn name(&self) -> &'static str {
        "Rainbow Hypergrowl"
    }
    fn view(&self) -> Vec<f32> {
        self.view_data()
    }
    fn load_source(&mut self, path: &str) -> bool {
        if path.is_empty() {
            self.bank = SampleBank::analyze(default_sample(self.sr), "", self.sr, &self.fft);
            return true;
        }
        self.load_sample(path)
    }
    fn source(&self) -> String {
        self.bank.path.clone()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn saw(n: usize, sr: f32, hz: f32) -> Vec<f32> {
        let mut b = Vec::with_capacity(n * 2);
        for i in 0..n {
            let s = ((i as f32 * hz / sr).fract() * 2.0 - 1.0) * 0.5;
            b.push(s);
            b.push(s);
        }
        b
    }

    fn rms(b: &[f32]) -> f32 {
        (b.iter().map(|x| x * x).sum::<f32>() / b.len() as f32).sqrt()
    }

    fn with(sets: &[(&str, f32)]) -> Hypergrowl {
        let mut e = Hypergrowl::new(48000.0);
        for (n, v) in sets {
            e.set_param(n, *v);
        }
        e
    }

    #[test]
    fn params_line_up_with_their_indices() {
        for (i, (n, _)) in PARAMS.iter().enumerate() {
            assert_eq!(pidx(n), i, "{n}");
        }
        assert_eq!(PARAMS.len(), 52);
        for (_, set) in PRESETS {
            for (n, v) in set {
                assert!(pidx(n) < 52 && (0.0..=1.0).contains(v), "{n}");
            }
        }
    }

    #[test]
    fn every_table_pair_and_every_preset_makes_finite_audible_sound() {
        for t in 0..TABLES.len() {
            let mut e = with(&[("table", (t as f32 + 0.5) / 12.0), ("tableb", ((t + 5) % 12) as f32 / 12.0 + 0.04), ("steps", 0.5), ("gmix", 0.4)]);
            let mut b = saw(48000, 48000.0, 55.0);
            e.process(&mut b);
            assert!(b.iter().all(|x| x.is_finite() && x.abs() <= 1.0 + 1e-5), "table {t}");
            let r = rms(&b[b.len() / 2..]);
            assert!(r > 0.01 && r < 0.9, "table {t} rms {r}");
        }
        for (name, set) in PRESETS {
            let mut e = Hypergrowl::new(48000.0);
            for (n, v) in set {
                e.set_param(n, *v);
            }
            let mut b = saw(48000, 48000.0, 55.0);
            e.process(&mut b);
            let r = rms(&b[b.len() / 2..]);
            assert!(b.iter().all(|x| x.is_finite()) && r > 0.005, "preset {name} rms {r}");
        }
    }

    #[test]
    fn the_extremes_stay_bounded() {
        for v in [0.0f32, 1.0] {
            let mut e = Hypergrowl::new(48000.0);
            for (n, _) in PARAMS {
                e.set_param(n, v);
            }
            let mut b = saw(48000, 48000.0, 80.0);
            e.process(&mut b);
            assert!(b.iter().all(|x| x.is_finite() && x.abs() <= 1.0 + 1e-5), "all at {v}");
        }
    }

    #[test]
    fn dry_is_delayed_to_line_up() {
        let mut e = with(&[("mix", 0.0)]);
        let src = saw(10000, 48000.0, 110.0);
        let mut b = src.clone();
        e.process(&mut b);
        assert!(b[..LATENCY * 2].iter().all(|x| x.abs() < 1e-6), "silent until the latency has passed");
        assert!((b[LATENCY * 2 + 200] - src[200]).abs() < 1e-6, "the delayed dry comes through untouched");
    }

    #[test]
    fn the_motion_changes_the_sound() {
        let mut a = with(&[("motion", 1.0), ("rate", 0.5)]);
        let mut c = with(&[("motion", 0.0), ("pos", 0.9)]);
        let (mut x, mut y) = (saw(24000, 48000.0, 55.0), saw(24000, 48000.0, 55.0));
        a.process(&mut x);
        c.process(&mut y);
        let d: f32 = x.iter().zip(&y).map(|(p, q)| (p - q).abs()).sum();
        assert!(d > 5.0, "{d}");
        // stepped motion holds: with steps up, the table position takes only a few values over one cycle
        let mut e = with(&[("steps", 1.0), ("motion", 1.0), ("rate", 0.5)]);
        let mut seen = std::collections::BTreeSet::new();
        for _ in 0..400 {
            let mut b = saw(480, 48000.0, 55.0);
            e.process(&mut b);
            seen.insert((e.l1 * 100.0).round() as i32);
        }
        assert!(seen.len() <= 4, "{seen:?}");
    }

    #[test]
    fn the_grain_delay_scatters_sound_after_the_input_stops() {
        let mut e = with(&[("gmix", 1.0), ("gtime", 0.6), ("gsize", 0.45), ("gdens", 0.6), ("squash", 0.0), ("mix", 1.0), ("gfb", 0.4)]);
        let mut b = saw(24000, 48000.0, 110.0);
        b.extend(vec![0.0; 2 * 48000]);
        e.process(&mut b);
        let tail = rms(&b[2 * (24000 + LATENCY + 2000)..2 * (24000 + LATENCY + 40000)]);
        assert!(tail > 0.003, "grains keep sounding after the input has stopped: {tail}");
        // with the grain mix off the same tail is much quieter
        let mut e2 = with(&[("gmix", 0.0), ("squash", 0.0), ("gfb", 0.0)]);
        let mut b2 = saw(24000, 48000.0, 110.0);
        b2.extend(vec![0.0; 2 * 48000]);
        e2.process(&mut b2);
        let tail2 = rms(&b2[2 * (24000 + LATENCY + 2000)..2 * (24000 + LATENCY + 40000)]);
        assert!(tail > tail2 * 2.0, "{tail} vs {tail2}");
        // the grains are spread over the stereo field
        let (mut l, mut r) = (0.0f32, 0.0f32);
        for fr in b[2 * 30000..2 * 70000].chunks_exact(2) {
            l += fr[0].abs();
            r += fr[1].abs();
        }
        assert!(l > 0.0 && r > 0.0);
    }

    #[test]
    fn comb_ring_crush_and_bin_shift_change_the_spectrum() {
        let base = |sets: &[(&str, f32)]| {
            let mut e = with(sets);
            let mut b = saw(24000, 48000.0, 55.0);
            e.process(&mut b);
            b
        };
        let plain = base(&[("squash", 0.0)]);
        for (name, sets) in [("comb", vec![("squash", 0.0), ("comb", 0.8)]), ("ring", vec![("squash", 0.0), ("ring", 0.7)]), ("crush", vec![("squash", 0.0), ("crush", 0.6)]), ("sshift", vec![("squash", 0.0), ("sshift", 0.8)])] {
            let b = base(&sets);
            assert!(b.iter().all(|x| x.is_finite()), "{name}");
            let d: f32 = plain.iter().zip(&b).map(|(p, q)| (p - q).abs()).sum();
            assert!(d > 5.0, "{name} changed nothing ({d})");
        }
    }

    #[test]
    fn spectral_squash_flattens_the_spectrum() {
        fn crest(squash: f32) -> f32 {
            let mut e = with(&[("squash", squash), ("table", 0.99), ("pos", 0.5), ("motion", 0.0), ("gmix", 0.0)]);
            let mut b = saw(40000, 48000.0, 55.0);
            e.process(&mut b);
            let fft = Fft::new();
            let mut re: Vec<f32> = (0..N).map(|i| b[(30000 + i) * 2]).collect();
            let mut im = vec![0.0; N];
            fft.run(&mut re, &mut im, false);
            let m: Vec<f32> = (1..N / 2).map(|k| (re[k] * re[k] + im[k] * im[k]).sqrt()).collect();
            let mean = m.iter().sum::<f32>() / m.len() as f32;
            m.iter().cloned().fold(0.0, f32::max) / mean.max(1e-9)
        }
        assert!(crest(1.0) < crest(0.0), "{} vs {}", crest(1.0), crest(0.0));
    }

    #[test]
    fn the_view_has_a_gain_per_band_and_the_two_meters() {
        let e = Hypergrowl::new(48000.0);
        let v = e.view();
        assert_eq!(v.len(), BANDS * 2 + 3);
        assert!(v.iter().all(|x| (0.0..=1.0).contains(x)));
    }

    #[test]
    fn fft_round_trips() {
        let fft = Fft::new();
        let src: Vec<f32> = (0..N).map(|i| ((i * 7) % 13) as f32 - 6.0).collect();
        let (mut re, mut im) = (src.clone(), vec![0.0; N]);
        fft.run(&mut re, &mut im, false);
        fft.run(&mut re, &mut im, true);
        for i in 0..N {
            assert!((re[i] / N as f32 - src[i]).abs() < 1e-2, "{i}");
        }
    }

    #[test]
    fn it_is_cheap_enough() {
        let mut e = with(&[("gmix", 0.6), ("comb", 0.5), ("ring", 0.3), ("crush", 0.2)]);
        let mut b = saw(48000, 48000.0, 55.0);
        let t0 = std::time::Instant::now();
        e.process(&mut b);
        // one second of stereo audio must take well under one second even in a debug-ish build
        assert!(t0.elapsed().as_secs_f32() < 1.5, "{:?}", t0.elapsed());
    }

    /// not a test: `cargo test -p ling-daw --lib dump_presets -- --ignored` writes one WAV per preset to $HG_DUMP for listening / plotting
    #[test]
    #[ignore]
    fn dump_presets() {
        let Ok(dir) = std::env::var("HG_DUMP") else { return };
        for (name, set) in PRESETS {
            let mut e = Hypergrowl::new(48000.0);
            for (n, v) in set {
                e.set_param(n, *v);
            }
            // a bass line: A1 held, then E2, over three seconds, a bright saw with a little sub
            let sr = 48000.0;
            let mut b = Vec::new();
            let mut ph = 0.0f32;
            for i in 0..(sr as usize * 3) {
                let hz = if i < sr as usize * 2 { 55.0 } else { 82.4 };
                ph = (ph + hz / sr).fract();
                let env = if (i % (sr as usize)) < 100 { (i % sr as usize) as f32 / 100.0 } else { 1.0 };
                let s = ((ph * 2.0 - 1.0) * 0.55 + (TAU * ph).sin() * 0.25) * env;
                b.push(s);
                b.push(s);
            }
            e.process(&mut b);
            let mut out = Vec::new();
            let data_len = (b.len() * 4) as u32;
            out.extend(b"RIFF");
            out.extend((36 + data_len).to_le_bytes());
            out.extend(b"WAVEfmt ");
            out.extend(16u32.to_le_bytes());
            out.extend(3u16.to_le_bytes());
            out.extend(2u16.to_le_bytes());
            out.extend(48000u32.to_le_bytes());
            out.extend((48000u32 * 8).to_le_bytes());
            out.extend(8u16.to_le_bytes());
            out.extend(32u16.to_le_bytes());
            out.extend(b"data");
            out.extend(data_len.to_le_bytes());
            for x in &b {
                out.extend(x.to_le_bytes());
            }
            std::fs::write(format!("{dir}/{}.wav", name.replace(' ', "_")), out).unwrap();
        }
    }

    /// how many times faster than real time the effect runs (needs --release to mean anything)
    #[test]
    #[ignore]
    fn bench_realtime_factor() {
        for (name, sets) in [("defaults", vec![]), ("grains+comb+ring+crush", vec![("gmix", 0.6), ("comb", 0.5), ("ring", 0.3), ("crush", 0.2)]), ("hypergrowl preset", PRESETS[1].1.to_vec())] {
            let mut e = Hypergrowl::new(48000.0);
            for (n, v) in &sets {
                e.set_param(n, *v);
            }
            let mut b = saw(48000 * 4, 48000.0, 55.0);
            let t0 = std::time::Instant::now();
            e.process(&mut b);
            let secs = t0.elapsed().as_secs_f32();
            println!("{name}: 4 s of audio in {secs:.3} s = {:.1}x realtime", 4.0 / secs);
        }
    }

    /// knobs thrown around at random, odd sample rates and odd block sizes: nothing may panic or go non-finite
    #[test]
    fn survives_random_knobs_odd_rates_and_block_sizes() {
        let mut seed = 0x2545F491u32;
        let mut rnd = move || {
            seed ^= seed << 13;
            seed ^= seed >> 17;
            seed ^= seed << 5;
            (seed & 0xffff) as f32 / 65535.0
        };
        for sr in [22050.0f32, 44100.0, 48000.0, 88200.0, 96000.0, 192000.0] {
            let mut e = Hypergrowl::new(sr);
            let mut phase = 0.0f32;
            let mut total = 0usize;
            while total < (sr as usize) * 3 {
                // a random block of 1..700 frames
                let frames = 1 + (rnd() * 700.0) as usize;
                let mut b = Vec::with_capacity(frames * 2);
                for _ in 0..frames {
                    phase = (phase + 55.0 / sr).fract();
                    let x = if rnd() < 0.002 { 0.0 } else { (phase * 2.0 - 1.0) * 0.6 };
                    b.push(x);
                    b.push(x * 0.9);
                }
                // move a few knobs, sometimes all the way to the ends
                for _ in 0..3 {
                    let i = (rnd() * 52.0) as usize % 52;
                    let v = match (rnd() * 4.0) as u32 {
                        0 => 0.0,
                        1 => 1.0,
                        _ => rnd(),
                    };
                    e.set_param(PARAMS[i].0, v);
                }
                e.process(&mut b);
                if !b.iter().all(|x| x.is_finite() && x.abs() <= 1.0 + 1e-5) {
                    let bad = b.iter().position(|x| !x.is_finite() || x.abs() > 1.0 + 1e-5).unwrap();
                    let named: Vec<String> = PARAMS.iter().enumerate().map(|(i, (n, _))| format!("{n}={:.2}", e.p[i])).collect();
                    panic!("sr {sr} bad sample {} = {} at frame {total}+{}; {}", bad, b[bad], bad / 2, named.join(" "));
                }
                total += frames;
            }
        }
    }

    /// silence, denormal-sized input and NaN / infinity in the input must not poison the state for good
    #[test]
    fn bad_input_does_not_wedge_the_effect() {
        let mut e = with(&[("gmix", 0.5), ("comb", 0.5), ("gfb", 0.9), ("squash", 0.9)]);
        let mut b = vec![f32::NAN; 4000];
        e.process(&mut b);
        let mut b = vec![f32::INFINITY, -f32::INFINITY].repeat(2000);
        e.process(&mut b);
        let mut b = vec![1e-30f32; 20000];
        e.process(&mut b);
        // after the garbage the effect must come back to making sound on normal input
        let mut ok = false;
        for _ in 0..40 {
            let mut b = saw(4800, 48000.0, 110.0);
            e.process(&mut b);
            if b.iter().all(|x| x.is_finite()) && rms(&b) > 0.01 {
                ok = true;
                break;
            }
        }
        assert!(ok, "the effect stayed broken after a NaN burst");
    }

    fn tmp_wav(name: &str, secs: f32, hz: f32) -> String {
        let sr = 22050u32;
        let n = (sr as f32 * secs) as usize;
        let mut data = Vec::new();
        for i in 0..n {
            // a tone that glides up so the spectrum changes over the file
            let f = hz * (1.0 + i as f32 / n as f32);
            let x = ((TAU * f * i as f32 / sr as f32).sin() * 12000.0) as i16;
            data.extend(x.to_le_bytes());
        }
        let mut out = Vec::new();
        out.extend(b"RIFF");
        out.extend((36 + data.len() as u32).to_le_bytes());
        out.extend(b"WAVEfmt ");
        out.extend(16u32.to_le_bytes());
        out.extend(1u16.to_le_bytes());
        out.extend(1u16.to_le_bytes());
        out.extend(sr.to_le_bytes());
        out.extend((sr * 2).to_le_bytes());
        out.extend(2u16.to_le_bytes());
        out.extend(16u16.to_le_bytes());
        out.extend(b"data");
        out.extend((data.len() as u32).to_le_bytes());
        out.extend(data);
        let path = std::env::temp_dir().join(name);
        std::fs::write(&path, out).unwrap();
        path.to_string_lossy().to_string()
    }

    #[test]
    fn the_built_in_sample_exists_and_a_wav_can_replace_it() {
        let mut e = Hypergrowl::new(48000.0);
        assert!(e.bank.nframes > 8 && !e.bank.mono.is_empty() && e.source().is_empty());
        let path = tmp_wav("hg_test_glide.wav", 2.0, 400.0);
        assert!(e.load_source(&path));
        assert_eq!(e.source(), path);
        assert!(e.bank.nframes > 8);
        // a glide moves its energy up the bands: the first frame peaks lower than the last
        let peak_band = |f: usize| e.bank.gains[f].iter().enumerate().fold((0, 0.0f32), |a, (k, g)| if *g > a.1 { (k, *g) } else { a }).0;
        assert!(peak_band(e.bank.nframes - 1) > peak_band(0), "{} vs {}", peak_band(0), peak_band(e.bank.nframes - 1));
        assert!(!e.load_source("C:/no/such/file.wav"));
        assert_eq!(e.source(), path, "a failed load keeps the old sample");
    }

    #[test]
    fn sample_grains_sound_with_no_input_and_the_sample_shapes_the_filter() {
        // grains read from the sample: sound out of silence
        let mut e = with(&[("gmix", 1.0), ("gsrc", 1.0), ("gdens", 0.6), ("gsize", 0.45), ("squash", 0.0), ("mix", 1.0)]);
        let mut b = vec![0.0f32; 2 * 48000];
        e.process(&mut b);
        assert!(rms(&b[2 * 12000..]) > 0.003, "{}", rms(&b[2 * 12000..]));
        // with no sample grains and no input, silence
        let mut q = with(&[("gmix", 1.0), ("gsrc", 0.0), ("squash", 0.0)]);
        let mut b2 = vec![0.0f32; 2 * 48000];
        q.process(&mut b2);
        assert!(rms(&b2) < 1e-4);
        // the sample's spectrum drives the bank: scanning it changes the sound
        let mut a = with(&[("smix", 1.0), ("spos", 0.05), ("sscan", 0.0), ("motion", 0.0), ("squash", 0.0)]);
        let mut c = with(&[("smix", 1.0), ("spos", 0.95), ("sscan", 0.0), ("motion", 0.0), ("squash", 0.0)]);
        let (mut x, mut y) = (saw(24000, 48000.0, 110.0), saw(24000, 48000.0, 110.0));
        a.process(&mut x);
        c.process(&mut y);
        let d: f32 = x.iter().zip(&y).map(|(p, q)| (p - q).abs()).sum();
        assert!(d > 5.0, "{d}");
    }

    #[test]
    fn cross_synthesis_and_freeze_do_something_and_stay_bounded() {
        let run = |sets: &[(&str, f32)]| {
            let mut e = with(sets);
            let mut b = saw(36000, 48000.0, 82.0);
            e.process(&mut b);
            assert!(b.iter().all(|x| x.is_finite() && x.abs() <= 1.0 + 1e-5));
            b
        };
        let plain = run(&[("squash", 0.0)]);
        let voc = run(&[("squash", 0.0), ("xsyn", 1.0), ("spos", 0.3)]);
        let d: f32 = plain.iter().zip(&voc).map(|(p, q)| (p - q).abs()).sum();
        assert!(d > 5.0, "cross-synthesis changed nothing: {d}");
        // freeze holds the spectrum: play a note with the knob down, raise it, stop the input: the sound goes on
        let mut f = with(&[("freeze", 0.0), ("squash", 0.0), ("gmix", 0.0)]);
        let mut b = saw(12000, 48000.0, 110.0);
        f.process(&mut b);
        f.set_param("freeze", 1.0);
        let mut tail = vec![0.0f32; 2 * 36000];
        f.process(&mut tail);
        let held = rms(&tail[2 * 20000..2 * 30000]);
        let mut g = with(&[("freeze", 0.0), ("squash", 0.0), ("gmix", 0.0)]);
        let mut b2 = saw(12000, 48000.0, 110.0);
        g.process(&mut b2);
        let mut tail2 = vec![0.0f32; 2 * 36000];
        g.process(&mut tail2);
        let free = rms(&tail2[2 * 20000..2 * 30000]);
        assert!(held > 0.01 && held > free * 20.0, "{held} vs {free}");
    }

    #[test]
    fn sub_adds_an_octave_below_and_warm_adds_even_harmonics() {
        let power_at = |v: &[f32], hz: f32| {
            let w = TAU * hz / 48000.0;
            let (mut re, mut im) = (0.0f32, 0.0f32);
            for (i, x) in v.iter().enumerate() {
                re += x * (w * i as f32).cos();
                im += x * (w * i as f32).sin();
            }
            (re * re + im * im).sqrt() / v.len() as f32
        };
        let run = |sets: &[(&str, f32)]| {
            let mut e = with(sets);
            let mut b = saw(48000, 48000.0, 110.0);
            e.process(&mut b);
            assert!(b.iter().all(|x| x.is_finite() && x.abs() <= 1.0 + 1e-5));
            b.iter().step_by(2).copied().collect::<Vec<f32>>()
        };
        let base = &[("squash", 0.0), ("fold", 0.0), ("drive", 0.1), ("body", 0.0), ("gmix", 0.0)];
        let plain = run(base);
        let mut with_sub = base.to_vec();
        with_sub.push(("sub", 1.0));
        let subbed = run(&with_sub);
        let (a, b) = (power_at(&plain[12000..36000], 55.0), power_at(&subbed[12000..36000], 55.0));
        assert!(b > a * 3.0, "no octave below: {b} vs {a}");
        // warm: the second harmonic of a pure-ish tone grows
        let mut sine_base = base.to_vec();
        sine_base.push(("drive", 0.6));
        let mut warm_set = sine_base.clone();
        warm_set.push(("warm", 1.0));
        let (p0, p1) = (run(&sine_base), run(&warm_set));
        let d: f32 = p0.iter().zip(&p1).map(|(x, y)| (x - y).abs()).sum();
        assert!(d > 5.0, "warm changed nothing: {d}");
    }
}

← Back to index