Skip to main content

rust_synth/audio/
preset.rs

1//! Preset = a stereo audio graph parameterised by [`TrackParams`] + [`GlobalParams`].
2//!
3//! Math-heavy modulation lives inside `lfo(|t| …)` closures that read
4//! `Shared` atomics cloned at build time (lock-free). Everything is
5//! f64-throughout (FunDSP `hacker` module) so multi-hour playback stays
6//! phase-stable — f32 time counters drift at ~5 min at 48 kHz.
7
8use fundsp::hacker::*;
9
10use std::sync::atomic::Ordering;
11
12use super::track::TrackParams;
13use crate::math::pulse::{arp_offset_semitones, pulse_decay, pulse_sine};
14use crate::math::rhythm;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PresetKind {
18    PadZimmer,
19    DroneSub,
20    Shimmer,
21    Heartbeat,
22    BassPulse,
23    Bell,
24    SuperSaw,
25    PluckSaw,
26}
27
28/// All preset kinds in cycle order. Used by the TUI `t` / `T` keys.
29pub const ALL_KINDS: [PresetKind; 8] = [
30    PresetKind::PadZimmer,
31    PresetKind::BassPulse,
32    PresetKind::Heartbeat,
33    PresetKind::DroneSub,
34    PresetKind::Shimmer,
35    PresetKind::Bell,
36    PresetKind::SuperSaw,
37    PresetKind::PluckSaw,
38];
39
40impl PresetKind {
41    pub fn label(self) -> &'static str {
42        match self {
43            PresetKind::PadZimmer => "Pad",
44            PresetKind::DroneSub => "Drone",
45            PresetKind::Shimmer => "Shimmer",
46            PresetKind::Heartbeat => "Heartbeat",
47            PresetKind::BassPulse => "Bass",
48            PresetKind::Bell => "Bell",
49            PresetKind::SuperSaw => "SuperSaw",
50            PresetKind::PluckSaw => "Pluck",
51        }
52    }
53
54    pub fn next(self) -> Self {
55        let i = ALL_KINDS.iter().position(|&k| k == self).unwrap_or(0);
56        ALL_KINDS[(i + 1) % ALL_KINDS.len()]
57    }
58
59    pub fn prev(self) -> Self {
60        let i = ALL_KINDS.iter().position(|&k| k == self).unwrap_or(0);
61        ALL_KINDS[(i + ALL_KINDS.len() - 1) % ALL_KINDS.len()]
62    }
63}
64
65#[derive(Clone)]
66pub struct GlobalParams {
67    pub bpm: Shared,
68    pub master_gain: Shared,
69    /// Master high-shelf amount in [0.0, 1.0] — shelf centre fixed at
70    /// 3.5 kHz, q 0.7. Maps linearly to shelf *amplitude*:
71    ///   0.0 → 0.2  (−14 dB, dark)
72    ///   0.5 → 0.6  (−4.4 dB)
73    ///   0.6 → 0.68 (−3.3 dB, default)
74    ///   1.0 → 1.0  (0 dB, bypass)
75    /// A shelf keeps the mids full, so lowering it removes harshness
76    /// without sounding like a volume drop.
77    pub brightness: Shared,
78    /// Arpeggiator scale mode — 0 major pent · 1 minor pent · 2 bhairavi.
79    pub scale_mode: Shared,
80}
81
82impl Default for GlobalParams {
83    fn default() -> Self {
84        Self {
85            bpm: shared(72.0),
86            master_gain: shared(0.7),
87            brightness: shared(0.6),
88            scale_mode: shared(0.0),
89        }
90    }
91}
92
93pub const MASTER_SHELF_HZ: f64 = 3500.0;
94pub const MIN_SHELF_GAIN: f64 = 0.2;
95
96/// Map brightness [0..1] → shelf amplitude gain [MIN..1.0] linearly.
97#[inline]
98pub fn brightness_to_shelf_gain(b: f64) -> f64 {
99    MIN_SHELF_GAIN + (1.0 - MIN_SHELF_GAIN) * b.clamp(0.0, 1.0)
100}
101
102/// Amplitude → dB for header readout.
103#[inline]
104pub fn shelf_gain_db(g: f64) -> f64 {
105    20.0 * g.max(1e-6).log10()
106}
107
108/// Map brightness to the master lowpass cutoff (Hz).
109/// 0 → 3000 Hz (hard cut of reverb HF resonances)
110/// 0.6 → ~8.6 kHz (default — mellow)
111/// 1.0 → 18 kHz (effective bypass)
112#[inline]
113pub fn brightness_to_lp_cutoff(b: f64) -> f64 {
114    3000.0 * 6.0_f64.powf(b.clamp(0.0, 1.0))
115}
116
117/// Stereo master bus: per-channel **high-shelf** (tilt) → **lowpass**
118/// (hard cut) → limiter. Both EQ stages driven by the same `brightness`.
119///
120/// Why two stages? Shelf gives the tonal character ("dark vs bright")
121/// while keeping mids full. Lowpass actually *removes* the 3–8 kHz
122/// reverb/chorus resonance buildup that otherwise still leaks through
123/// a shelf. Turn brightness fully up and both become passthrough.
124pub fn master_bus(brightness: Shared) -> Net {
125    let b_shelf_l = brightness.clone();
126    let b_shelf_r = brightness.clone();
127    let b_lp_l = brightness.clone();
128    let b_lp_r = brightness;
129
130    // ── Shelf stage ──
131    let sh_f_l = lfo(|_t: f64| MASTER_SHELF_HZ);
132    let sh_f_r = lfo(|_t: f64| MASTER_SHELF_HZ);
133    let sh_q_l = lfo(|_t: f64| 0.7_f64);
134    let sh_q_r = lfo(|_t: f64| 0.7_f64);
135    let sh_g_l = lfo(move |_t: f64| brightness_to_shelf_gain(b_shelf_l.value() as f64));
136    let sh_g_r = lfo(move |_t: f64| brightness_to_shelf_gain(b_shelf_r.value() as f64));
137    let shelf_l = (pass() | sh_f_l | sh_q_l | sh_g_l) >> highshelf();
138    let shelf_r = (pass() | sh_f_r | sh_q_r | sh_g_r) >> highshelf();
139
140    // ── Lowpass stage ──
141    let lp_c_l = lfo(move |_t: f64| brightness_to_lp_cutoff(b_lp_l.value() as f64));
142    let lp_c_r = lfo(move |_t: f64| brightness_to_lp_cutoff(b_lp_r.value() as f64));
143    let lp_q_l = lfo(|_t: f64| 0.5_f64);
144    let lp_q_r = lfo(|_t: f64| 0.5_f64);
145
146    let left = shelf_l >> (pass() | lp_c_l | lp_q_l) >> lowpass();
147    let right = shelf_r >> (pass() | lp_c_r | lp_q_r) >> lowpass();
148    let stereo = left | right;
149
150    let chain = stereo >> limiter_stereo(0.001, 0.3);
151    Net::wrap(Box::new(chain))
152}
153
154pub struct Preset;
155
156impl Preset {
157    pub fn build(kind: PresetKind, p: &TrackParams, g: &GlobalParams) -> Net {
158        match kind {
159            PresetKind::PadZimmer => pad_zimmer(p, g),
160            PresetKind::DroneSub => drone_sub(p, g),
161            PresetKind::Shimmer => shimmer(p, g),
162            PresetKind::Heartbeat => heartbeat(p, g),
163            PresetKind::BassPulse => bass_pulse(p, g),
164            PresetKind::Bell => bell_preset(p, g),
165            PresetKind::SuperSaw => super_saw(p, g),
166            PresetKind::PluckSaw => pluck_saw(p, g),
167        }
168    }
169}
170
171// ── LFO targets ─────────────────────────────────────────────────────────
172
173pub const LFO_OFF: u32 = 0;
174pub const LFO_CUTOFF: u32 = 1;
175pub const LFO_GAIN: u32 = 2;
176pub const LFO_FREQ: u32 = 3;
177pub const LFO_REVERB: u32 = 4;
178pub const LFO_TARGETS: u32 = 5;
179
180pub fn lfo_target_name(idx: u32) -> &'static str {
181    match idx {
182        LFO_OFF => "OFF",
183        LFO_CUTOFF => "CUT",
184        LFO_GAIN => "GAIN",
185        LFO_FREQ => "FREQ",
186        LFO_REVERB => "REV",
187        _ => "?",
188    }
189}
190
191/// Lightweight bundle of the three Shared atomics that drive a track's
192/// per-voice LFO. Clone is ~3× Arc-clone (refcount bumps) — cheap.
193#[derive(Clone)]
194pub struct LfoBundle {
195    pub rate: Shared,
196    pub depth: Shared,
197    pub target: Shared,
198}
199
200impl LfoBundle {
201    pub fn from_params(p: &TrackParams) -> Self {
202        Self {
203            rate: p.lfo_rate.clone(),
204            depth: p.lfo_depth.clone(),
205            target: p.lfo_target.clone(),
206        }
207    }
208
209    /// Apply this LFO to `base` only if `this_target` matches the user
210    /// selection *and* depth is audible. Otherwise `base` is returned
211    /// unchanged — the LFO adds zero cost when it's off.
212    #[inline]
213    pub fn apply(
214        &self,
215        base: f64,
216        this_target: u32,
217        t: f64,
218        scaler: impl Fn(f64, f64) -> f64,
219    ) -> f64 {
220        let tgt = self.target.value().round() as u32;
221        if tgt != this_target {
222            return base;
223        }
224        let depth = self.depth.value() as f64;
225        if depth < 1.0e-4 {
226            return base;
227        }
228        let rate = self.rate.value() as f64;
229        let lv = (std::f64::consts::TAU * rate * t).sin();
230        scaler(base, lv * depth)
231    }
232}
233
234// ── Helpers ─────────────────────────────────────────────────────────────
235
236#[allow(dead_code)]
237fn stereo_from_shared(s: Shared) -> Net {
238    Net::wrap(Box::new(lfo(move |_t: f64| s.value() as f64) >> split::<U2>()))
239}
240
241/// Three-point linear interpolation: `c=0 → a`, `c=0.5 → b`, `c=1 → d`.
242/// Used by every preset's `character` morph so the neutral 0.5 setting
243/// reproduces the hand-tuned original formula exactly.
244#[inline]
245pub fn lerp3(a: f64, b: f64, d: f64, c: f64) -> f64 {
246    let c = c.clamp(0.0, 1.0);
247    if c < 0.5 {
248        a + (b - a) * (c * 2.0)
249    } else {
250        b + (d - b) * ((c - 0.5) * 2.0)
251    }
252}
253
254/// Shared bundle of everything a freq-generating closure needs to apply
255/// arp + LFO on top of a base pitch. Cloning it is just a handful of
256/// Arc refcount bumps.
257#[derive(Clone)]
258pub struct FreqMod {
259    pub arp: Shared,
260    pub bpm: Shared,
261    pub scale_mode: Shared,
262    pub lb: LfoBundle,
263}
264
265impl FreqMod {
266    pub fn new(p: &TrackParams, g: &GlobalParams) -> Self {
267        Self {
268            arp: p.arp.clone(),
269            bpm: g.bpm.clone(),
270            scale_mode: g.scale_mode.clone(),
271            lb: LfoBundle::from_params(p),
272        }
273    }
274
275    /// Apply arpeggiator (scale-snapped pitch walk) and LFO-FREQ to a
276    /// base frequency. `seed` should be stable per track — we hash
277    /// `base` so different tracks naturally play different arp sequences.
278    #[inline]
279    pub fn apply(&self, base: f64, t: f64) -> f64 {
280        let seed = (base.max(1.0).ln() * 1_000.0) as u64;
281        let scale = self.scale_mode.value().round() as u32;
282        let off = arp_offset_semitones(
283            t,
284            self.bpm.value() as f64,
285            self.arp.value() as f64,
286            seed,
287            scale,
288        );
289        let arped = base * 2.0_f64.powf(off / 12.0);
290        self.lb
291            .apply(arped, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
292    }
293}
294
295/// Reverb-mix signal that respects LFO when target = REV.
296/// Additive ±0.4 at depth=1, clamped to [0, 1].
297fn stereo_reverb_mix(base: Shared, lb: LfoBundle) -> Net {
298    let mono = lfo(move |t: f64| {
299        let v = base.value() as f64;
300        lb.apply(v, LFO_REVERB, t, |b, m| (b + m * 0.4).clamp(0.0, 1.0))
301    });
302    Net::wrap(Box::new(mono >> split::<U2>()))
303}
304
305fn supermass_send(amount: Shared) -> Net {
306    let a1 = amount.clone();
307    let a2 = amount;
308    let amount_l = lfo(move |_t: f64| a1.value() as f64);
309    let amount_r = lfo(move |_t: f64| a2.value() as f64);
310    let amount_stereo = Net::wrap(Box::new(amount_l | amount_r));
311
312    // 2nd reverb damping bumped 0.72 → 0.90 so a 28-second T60 does not
313    // accumulate endless 4–8 kHz resonances in the tail.
314    let effect = reverb_stereo(35.0, 15.0, 0.88)
315        >> (chorus(3, 0.0, 0.022, 0.28) | chorus(4, 0.0, 0.026, 0.28))
316        >> reverb_stereo(50.0, 28.0, 0.90);
317
318    let wet_scaled = Net::wrap(Box::new(effect)) * amount_stereo;
319    let dry = Net::wrap(Box::new(multipass::<U2>()));
320    dry & wet_scaled
321}
322
323fn stereo_gate_voiced(
324    gain: Shared,
325    mute: Shared,
326    pulse_depth: Shared,
327    bpm: Shared,
328    life_mod: Shared,
329    lb: LfoBundle,
330) -> Net {
331    let raw = lfo(move |t: f64| {
332        let g_raw = (gain.value() * (1.0 - mute.value())) as f64;
333        // Tremolo — ±60 % at depth=1, additive around base.
334        let g = lb.apply(g_raw, LFO_GAIN, t, |b, m| (b * (1.0 + m * 0.6)).max(0.0));
335        let depth = pulse_depth.value().clamp(0.0, 1.0) as f64;
336        let pulse = pulse_sine(t, bpm.value() as f64);
337        let life = life_mod.value().clamp(0.0, 1.0) as f64;
338        let life_scaled = 0.4 + 0.9 * life;
339        g * (1.0 - depth + depth * pulse) * life_scaled
340    });
341    Net::wrap(Box::new(raw >> follow(0.4) >> split::<U2>()))
342}
343
344// ── Pad ──
345fn pad_zimmer(p: &TrackParams, g: &GlobalParams) -> Net {
346    let cut = p.cutoff.clone();
347    let res_s = p.resonance.clone();
348    let det = p.detune.clone();
349
350    let lb = LfoBundle::from_params(p);
351    let f0 = p.freq.clone();
352    let f1 = p.freq.clone();
353    let f2 = p.freq.clone();
354    let f3 = p.freq.clone();
355    let d1 = det.clone();
356    let d2 = det.clone();
357    let (lb0, lb1, lb2, lb3, lb_c) = (
358        lb.clone(),
359        lb.clone(),
360        lb.clone(),
361        lb.clone(),
362        lb.clone(),
363    );
364
365    // `character` morphs the partial ratios:
366    //   0.0 → pure harmonic [1, 2, 3, 4]  (octave + fifth + fourth)
367    //   0.5 → hand-tuned [1, 1.501, 2.013, 3.007]  (classic Zimmer)
368    //   1.0 → stretched [1, 1.618, 2.414, 3.739]  (golden-ratio inharmonic)
369    let char0 = p.character.clone();
370    let char1 = p.character.clone();
371    let char2 = p.character.clone();
372    let fm = FreqMod::new(p, g);
373    let fm0 = fm.clone();
374    let fm1 = fm.clone();
375    let fm2 = fm.clone();
376    let fm3 = fm.clone();
377    let _ = (lb0, lb1, lb2, lb3); // consumed via fm.* now
378    let osc = ((lfo(move |t: f64| fm0.apply(f0.value() as f64, t)) >> follow(0.08)
379            >> (sine() * 0.30))
380        + (lfo(move |t: f64| {
381            let c = char0.value() as f64;
382            let r = 1.0 + lerp3(1.0, 0.501, 0.618, c);
383            let b = f1.value() as f64 * r * (1.0 + d1.value() as f64 * 0.000578);
384            fm1.apply(b, t)
385        }) >> follow(0.08) >> (sine() * 0.20))
386        + (lfo(move |t: f64| {
387            let c = char1.value() as f64;
388            let r = 2.0 + lerp3(0.0, 0.013, 0.414, c);
389            let b = f2.value() as f64 * r * (1.0 + d2.value() as f64 * 0.000578);
390            fm2.apply(b, t)
391        }) >> follow(0.08) >> (sine() * 0.14))
392        + (lfo(move |t: f64| {
393            let c = char2.value() as f64;
394            let r = 3.0 + lerp3(0.0, 0.007, 0.739, c);
395            let b = f3.value() as f64 * r;
396            fm3.apply(b, t)
397        }) >> follow(0.08) >> (sine() * 0.08)))
398        * 0.9;
399
400    let cutoff_mod = lfo(move |t: f64| {
401        let wobble = 1.0 + 0.10 * (0.5 - 0.5 * (t * 0.08).sin());
402        let base = cut.value() as f64 * wobble;
403        lb_c.apply(base, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
404    }) >> follow(0.08);
405    // Hard cap at 0.65: above that the Moog self-oscillates into a
406    // sustained whistle at cutoff. We'd rather lose a tiny bit of range
407    // at the top than let auto-evolve park a track in squeal territory.
408    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.08);
409
410    // Tame pad whistle: fixed −3.5 dB shelf at 3 kHz before the reverb.
411    // This kills the resonance that builds between detuned partials
412    // × 3.007 and moog filter peak — the whistle user reported.
413    let filtered = (osc | cutoff_mod | res_mod) >> moog()
414        >> highshelf_hz(3000.0, 0.7, 0.67);
415
416    let stereo = filtered
417        >> split::<U2>()
418        >> (chorus(0, 0.0, 0.015, 0.35) | chorus(1, 0.0, 0.020, 0.35))
419        >> reverb_stereo(18.0, 4.0, 0.9);
420
421    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
422    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
423    voiced
424        * stereo_gate_voiced(
425            p.gain.clone(),
426            p.mute.clone(),
427            p.pulse_depth.clone(),
428            g.bpm.clone(),
429            p.life_mod.clone(),
430            lb,
431        )
432}
433
434// ── Drone ──
435fn drone_sub(p: &TrackParams, g: &GlobalParams) -> Net {
436    let lb = LfoBundle::from_params(p);
437    let cut = p.cutoff.clone();
438    let res_s = p.resonance.clone();
439
440    let f0 = p.freq.clone();
441    let f1 = p.freq.clone();
442    let (lb0, lb1, lb_c) = (lb.clone(), lb.clone(), lb.clone());
443
444    let fm = FreqMod::new(p, g);
445    let fm0 = fm.clone();
446    let fm1 = fm.clone();
447    let _ = (lb0, lb1);
448    let sub = (lfo(move |t: f64| fm0.apply(f0.value() as f64 * 0.5, t))
449            >> follow(0.08) >> (sine() * 0.45))
450        + (lfo(move |t: f64| fm1.apply(f1.value() as f64, t))
451            >> follow(0.08) >> (sine() * 0.12));
452
453    let noise_cut = lfo(move |t: f64| {
454        let b = cut.value().clamp(40.0, 300.0) as f64;
455        lb_c.apply(b, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
456    }) >> follow(0.08);
457    let noise_q = lfo(move |_t: f64| res_s.value() as f64) >> follow(0.08);
458    let noise = (brown() | noise_cut | noise_q) >> moog();
459    let noise_body = noise * 0.28;
460
461    let bpm_am = g.bpm.clone();
462    let am = lfo(move |t: f64| 0.88 + 0.12 * pulse_sine(t, bpm_am.value() as f64));
463    let body = (sub + noise_body) * am;
464
465    // Stereo widening: chorus L/R with different delays turns the mono
466    // body into a real wide stereo image before the reverb.
467    let stereo = body
468        >> split::<U2>()
469        >> (chorus(10, 0.0, 0.025, 0.18) | chorus(11, 0.0, 0.031, 0.18))
470        >> reverb_stereo(20.0, 5.0, 0.85);
471
472    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
473    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
474    voiced
475        * stereo_gate_voiced(
476            p.gain.clone(),
477            p.mute.clone(),
478            p.pulse_depth.clone(),
479            g.bpm.clone(),
480            p.life_mod.clone(),
481            lb,
482        )
483}
484
485// ── Shimmer ──
486fn shimmer(p: &TrackParams, g: &GlobalParams) -> Net {
487    let lb = LfoBundle::from_params(p);
488    let f0 = p.freq.clone();
489    let f1 = p.freq.clone();
490    let f2 = p.freq.clone();
491    let (lb0, lb1, lb2) = (lb.clone(), lb.clone(), lb.clone());
492
493    // `character` stretches the high partials from harmonic to inharmonic:
494    //   0.0 → pure [×2, ×3, ×4]
495    //   0.5 → current [×2, ×3, ×4.007]
496    //   1.0 → stretched [×2.1, ×3.3, ×4.8] (bell-like top end)
497    let char_s1 = p.character.clone();
498    let char_s2 = p.character.clone();
499    let char_s3 = p.character.clone();
500    let fm = FreqMod::new(p, g);
501    let fm0 = fm.clone();
502    let fm1 = fm.clone();
503    let fm2 = fm.clone();
504    let _ = (lb0, lb1, lb2);
505    let osc = (lfo(move |t: f64| {
506            let c = char_s1.value() as f64;
507            let r = lerp3(2.0, 2.0, 2.1, c);
508            fm0.apply(f0.value() as f64 * r, t)
509        }) >> follow(0.08) >> (sine() * 0.18))
510        + (lfo(move |t: f64| {
511            let c = char_s2.value() as f64;
512            let r = lerp3(3.0, 3.0, 3.3, c);
513            fm1.apply(f1.value() as f64 * r, t)
514        }) >> follow(0.08) >> (sine() * 0.12))
515        + (lfo(move |t: f64| {
516            let c = char_s3.value() as f64;
517            let r = lerp3(4.0, 4.007, 4.8, c);
518            fm2.apply(f2.value() as f64 * r, t)
519        }) >> follow(0.08) >> (sine() * 0.08));
520
521    let bright = osc >> highpass_hz(400.0, 0.5);
522    // Dual chorus gives the shimmer actual stereo spread, not just
523    // reverb-ambient stereo from a mono source.
524    let stereo = bright
525        >> split::<U2>()
526        >> (chorus(20, 0.0, 0.008, 0.6) | chorus(21, 0.0, 0.011, 0.6))
527        >> reverb_stereo(22.0, 6.0, 0.85);
528
529    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
530    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
531    voiced
532        * stereo_gate_voiced(
533            p.gain.clone(),
534            p.mute.clone(),
535            p.pulse_depth.clone(),
536            g.bpm.clone(),
537            p.life_mod.clone(),
538            lb,
539        )
540}
541
542// ── Heartbeat: 3-layer kick drum with Euclidean 16-step pattern ──
543// Every layer fires only on active pattern steps (step resolution = 4
544// per beat). Envelopes are step-length (~1/4 beat). Pattern bitmask is
545// read with an atomic Relaxed load — lock-free, ~1 ns per sample.
546fn heartbeat(p: &TrackParams, g: &GlobalParams) -> Net {
547    let bpm = g.bpm.clone();
548
549    // Body — pitch-swept sine (pitch drop happens only within active steps).
550    let bpm_body_f = bpm.clone();
551    let freq_body = p.freq.clone();
552    let pat_body_f = p.pattern_bits.clone();
553    let body_osc = lfo(move |t: f64| {
554        let bpm_v = bpm_body_f.value() as f64;
555        let bits = pat_body_f.load(Ordering::Relaxed);
556        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
557        let base = freq_body.value() as f64;
558        if active {
559            let drop = (-phi * 40.0).exp();
560            base * (0.7 + 1.5 * drop)
561        } else {
562            // No hit — hold the osc at its base so there is no phase
563            // pop when the next step arrives.
564            base
565        }
566    }) >> sine();
567
568    let bpm_body_e = bpm.clone();
569    let pat_body_e = p.pattern_bits.clone();
570    let body_env = lfo(move |t: f64| {
571        let bpm_v = bpm_body_e.value() as f64;
572        let bits = pat_body_e.load(Ordering::Relaxed);
573        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
574        if active {
575            (-phi * 4.0).exp()
576        } else {
577            0.0
578        }
579    });
580    let body = body_osc * body_env * 0.85;
581
582    // Sub — low sine, slower decay bleeds across the step boundary.
583    // Amplitude comes from the sub_scale LFO defined below so we can
584    // lean into 808 boom at low character values.
585    let freq_sub = p.freq.clone();
586    let sub_osc = lfo(move |_t: f64| freq_sub.value() as f64 * 0.5) >> sine();
587    let bpm_sub_e = bpm.clone();
588    let pat_sub = p.pattern_bits.clone();
589    let sub_env = lfo(move |t: f64| {
590        let bpm_v = bpm_sub_e.value() as f64;
591        let bits = pat_sub.load(Ordering::Relaxed);
592        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
593        if active {
594            (-phi * 1.5).exp()
595        } else {
596            0.0
597        }
598    });
599    let sub = sub_osc * sub_env;
600
601    // Click — short burst on active steps. Amplitude is driven by
602    // `character`: low → no click (pure 808 boom), high → snappy punch.
603    let bpm_click = bpm.clone();
604    let pat_click = p.pattern_bits.clone();
605    let char_click = p.character.clone();
606    let click_env = lfo(move |t: f64| {
607        let bpm_v = bpm_click.value() as f64;
608        let bits = pat_click.load(Ordering::Relaxed);
609        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
610        if active {
611            // Envelope amplitude scales with character:
612            //   0.0 → 0.02 (barely there)
613            //   0.5 → 0.12 (classic, current)
614            //   1.0 → 0.22 (snappy)
615            let amp = 0.02 + char_click.value().clamp(0.0, 1.0) as f64 * 0.20;
616            (-phi * 40.0).exp() * amp
617        } else {
618            0.0
619        }
620    });
621    let click = (brown() >> highpass_hz(1800.0, 0.5)) * click_env;
622
623    // Sub amplitude inversely scales with character — at low character
624    // the kick is ALL sub-boom; at high character the click and short
625    // body carry the energy instead.
626    let char_sub = p.character.clone();
627    let sub_scale = lfo(move |_t: f64| {
628        // 1.0 → 0.55 (lots of sub)  ·  0.5 → 0.45  ·  0.0 → 0.35
629        0.35 + (1.0 - char_sub.value().clamp(0.0, 1.0) as f64) * 0.20
630    });
631    let sub_scaled = sub * sub_scale;
632
633    let kick = body + sub_scaled + click;
634
635    // Haas-effect stereo: 8 ms L/R delay widens the kick without
636    // destroying its punch (subtle enough to avoid phase cancellation
637    // on mono playback).
638    let stereo = kick
639        >> split::<U2>()
640        >> (pass() | delay(0.008))
641        >> reverb_stereo(10.0, 1.5, 0.88);
642
643    let lb = LfoBundle::from_params(p);
644    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
645    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
646    voiced
647        * stereo_gate_voiced(
648            p.gain.clone(),
649            p.mute.clone(),
650            p.pulse_depth.clone(),
651            g.bpm.clone(),
652            p.life_mod.clone(),
653            lb,
654        )
655}
656
657// ── BassPulse: sustained bass line with BPM groove ──
658// Fundamental + 2nd harmonic + sub, Moog-lowpassed; groove envelope
659// pumps amplitude on every beat so the bass pulses instead of droning.
660fn bass_pulse(p: &TrackParams, g: &GlobalParams) -> Net {
661    let lb = LfoBundle::from_params(p);
662    let f1 = p.freq.clone();
663    let f2 = p.freq.clone();
664    let f3 = p.freq.clone();
665    let cut = p.cutoff.clone();
666    let res_s = p.resonance.clone();
667    let (lb1, lb2, lb3, lb_c) = (lb.clone(), lb.clone(), lb.clone(), lb.clone());
668
669    let fm = FreqMod::new(p, g);
670    let (fm1_, fm2_, fm3_) = (fm.clone(), fm.clone(), fm.clone());
671    let _ = (lb1, lb2, lb3);
672    let fundamental = lfo(move |t: f64| fm1_.apply(f1.value() as f64, t))
673        >> follow(0.08) >> (sine() * 0.55);
674    let second = lfo(move |t: f64| fm2_.apply(f2.value() as f64 * 2.0, t))
675        >> follow(0.08) >> (sine() * 0.22);
676    let sub = lfo(move |t: f64| fm3_.apply(f3.value() as f64 * 0.5, t))
677        >> follow(0.08) >> (sine() * 0.35);
678    let osc = fundamental + second + sub;
679
680    let cut_mod = lfo(move |t: f64| {
681        let b = cut.value().min(900.0) as f64;
682        lb_c.apply(b, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
683    }) >> follow(0.08);
684    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.08);
685    let filtered = (osc | cut_mod | res_mod) >> moog();
686
687    let bpm_groove = g.bpm.clone();
688    let groove = lfo(move |t: f64| {
689        let pump = pulse_decay(t, bpm_groove.value() as f64, 3.5);
690        0.45 + 0.55 * pump
691    });
692    let grooved = filtered * groove;
693
694    // Haas 14 ms — widens the bass line but stays mono-compatible so
695    // sub content still sums properly on club systems.
696    let stereo = grooved
697        >> split::<U2>()
698        >> (pass() | delay(0.014))
699        >> reverb_stereo(14.0, 2.5, 0.88);
700
701    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
702    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
703    voiced
704        * stereo_gate_voiced(
705            p.gain.clone(),
706            p.mute.clone(),
707            p.pulse_depth.clone(),
708            g.bpm.clone(),
709            p.life_mod.clone(),
710            lb,
711        )
712}
713
714// ── Bell: two-operator FM tone (inharmonic ratio 2.76) ──
715// Modulator at freq·2.76 with depth = resonance·450 Hz frequency
716// modulates the carrier at freq. Dial `resonance` for metallic shimmer.
717// Named `bell_preset` to avoid collision with fundsp's `bell()` filter.
718fn bell_preset(p: &TrackParams, g: &GlobalParams) -> Net {
719    let lb = LfoBundle::from_params(p);
720    let fc = p.freq.clone();
721    let fm = p.freq.clone();
722    let fm_depth = p.resonance.clone();
723    let (lb_c, lb_m) = (lb.clone(), lb.clone());
724
725    // `character` shifts FM ratio:
726    //   0.0 → 1.41 (harmonic-ish — metallic pad)
727    //   0.5 → 2.76 (classic inharmonic bell)
728    //   1.0 → 4.18 (bright glassy)
729    let char_m = p.character.clone();
730    let fmm = FreqMod::new(p, g);
731    let fmm_m = fmm.clone();
732    let fmm_c = fmm.clone();
733    let _ = (lb_m, lb_c);
734    let modulator_freq = lfo(move |t: f64| {
735        let c = char_m.value() as f64;
736        let ratio = lerp3(1.41, 2.76, 4.18, c);
737        let b = fm.value() as f64 * ratio;
738        fmm_m.apply(b, t)
739    }) >> follow(0.08);
740    let modulator = modulator_freq >> sine();
741    let mod_scale = lfo(move |_t: f64| fm_depth.value().min(0.65) as f64 * 450.0);
742    let modulator_scaled = modulator * mod_scale;
743
744    let carrier_base = lfo(move |t: f64| fmm_c.apply(fc.value() as f64, t))
745        >> follow(0.08);
746    let bell_sig = (carrier_base + modulator_scaled) >> sine();
747
748    let bpm_am = g.bpm.clone();
749    let am = lfo(move |t: f64| 0.85 + 0.15 * pulse_sine(t, bpm_am.value() as f64 * 0.25));
750    let body = bell_sig * am * 0.30;
751
752    // Dual chorus gives the FM tone true stereo movement — bells need it.
753    let stereo = body
754        >> split::<U2>()
755        >> (chorus(30, 0.0, 0.018, 0.25) | chorus(31, 0.0, 0.022, 0.25))
756        >> reverb_stereo(25.0, 8.0, 0.85);
757
758    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
759    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
760    voiced
761        * stereo_gate_voiced(
762            p.gain.clone(),
763            p.mute.clone(),
764            p.pulse_depth.clone(),
765            g.bpm.clone(),
766            p.life_mod.clone(),
767            lb,
768        )
769}
770
771// ── SuperSaw: Serum-style 7-voice detuned saw stack + sine sub ──
772// Seven saws spread symmetrically across ±|detune| cents. Classic
773// trance/lead texture — as `detune` grows the stack goes from clean
774// unison to lush chorus. Amplitude 1/(N+2) keeps the sum safe from clip.
775fn super_saw(p: &TrackParams, g: &GlobalParams) -> Net {
776    let lb = LfoBundle::from_params(p);
777    let cut = p.cutoff.clone();
778    let res_s = p.resonance.clone();
779
780    const OFFS: [f64; 7] = [-1.0, -0.66, -0.33, 0.0, 0.33, 0.66, 1.0];
781    // FunDSP scalar ops on WaveSynth take f32 (not f64).
782    let voice_amp: f32 = 0.55 / OFFS.len() as f32;
783
784    // Build the 7-voice saw stack by folding Net additions.
785    let fm = FreqMod::new(p, g);
786    let mut stack: Option<Net> = None;
787    for &off in OFFS.iter() {
788        let f_c = p.freq.clone();
789        let d_c = p.detune.clone();
790        let fm_c = fm.clone();
791        let voice = lfo(move |t: f64| {
792            let width = (d_c.value().abs() as f64).max(1.0);
793            let cents = off * width;
794            let base = f_c.value() as f64 * 2.0_f64.powf(cents / 1200.0);
795            fm_c.apply(base, t)
796        }) >> follow(0.08) >> (saw() * voice_amp);
797        let wrapped = Net::wrap(Box::new(voice));
798        stack = Some(match stack {
799            Some(acc) => acc + wrapped,
800            None => wrapped,
801        });
802    }
803    let saw_stack = stack.expect("N > 0");
804
805    // Sub-octave sine for weight.
806    let f_sub = p.freq.clone();
807    let fm_sub = fm.clone();
808    let _ = lb.clone();
809    let sub = lfo(move |t: f64| fm_sub.apply(f_sub.value() as f64 * 0.5, t))
810        >> follow(0.08) >> (sine() * 0.22);
811    let sub_net = Net::wrap(Box::new(sub));
812
813    let mixed = saw_stack + sub_net;
814
815    let lb_cut = lb.clone();
816    let cut_mod = lfo(move |t: f64| {
817        let b = cut.value() as f64;
818        lb_cut.apply(b, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
819    }) >> follow(0.05);
820    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.08);
821
822    let filtered = (mixed | Net::wrap(Box::new(cut_mod)) | Net::wrap(Box::new(res_mod)))
823        >> Net::wrap(Box::new(moog()));
824
825    let stereo = filtered
826        >> Net::wrap(Box::new(split::<U2>()))
827        >> Net::wrap(Box::new(
828            chorus(0, 0.0, 0.012, 0.4) | chorus(1, 0.0, 0.014, 0.4),
829        ))
830        >> Net::wrap(Box::new(reverb_stereo(16.0, 3.0, 0.88)));
831
832    let with_super = stereo >> supermass_send(p.supermass.clone());
833    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
834    voiced
835        * stereo_gate_voiced(
836            p.gain.clone(),
837            p.mute.clone(),
838            p.pulse_depth.clone(),
839            g.bpm.clone(),
840            p.life_mod.clone(),
841            lb,
842        )
843}
844
845// ── PluckSaw: step-gated saw pluck with filter envelope ──
846// Fires on every active Euclidean step. Each hit opens the Moog from
847// 180 Hz up to the user cutoff and decays, making notes feel plucked.
848fn pluck_saw(p: &TrackParams, g: &GlobalParams) -> Net {
849    let lb = LfoBundle::from_params(p);
850
851    let fm = FreqMod::new(p, g);
852    let fm_a = fm.clone();
853    let fm_b = fm.clone();
854    let f_a = p.freq.clone();
855    let osc_a = lfo(move |t: f64| fm_a.apply(f_a.value() as f64, t))
856        >> follow(0.08) >> (saw() * 0.35);
857
858    let f_b = p.freq.clone();
859    let det = p.detune.clone();
860    let osc_b = lfo(move |t: f64| {
861        let cents = det.value() as f64 * 0.5;
862        let b = f_b.value() as f64 * 2.0_f64.powf(cents / 1200.0);
863        fm_b.apply(b, t)
864    }) >> follow(0.08) >> (saw() * 0.35);
865    let osc = osc_a + osc_b;
866
867    // Filter envelope: on each active step, cutoff decays from user
868    // value down to 180 Hz across the step. Off-steps stay muffled.
869    let bpm_f = g.bpm.clone();
870    let pat_f = p.pattern_bits.clone();
871    let cut_shared = p.cutoff.clone();
872    let lb_c = lb.clone();
873    let cut_env = lfo(move |t: f64| {
874        let bpm = bpm_f.value() as f64;
875        let bits = pat_f.load(Ordering::Relaxed);
876        let (active, phi) = rhythm::step_is_active(bits, t, bpm);
877        let user_cut = cut_shared.value() as f64;
878        let base = if active {
879            180.0 + (user_cut - 180.0) * (-phi * 5.0).exp()
880        } else {
881            180.0
882        };
883        lb_c.apply(base, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
884    }) >> follow(0.01);
885
886    let res_s = p.resonance.clone();
887    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.05);
888
889    let filtered =
890        (osc | Net::wrap(Box::new(cut_env)) | Net::wrap(Box::new(res_mod))) >> Net::wrap(Box::new(moog()));
891
892    // Amplitude envelope — step-gated, fast decay.
893    let bpm_env = g.bpm.clone();
894    let pat_env = p.pattern_bits.clone();
895    let amp_env = lfo(move |t: f64| {
896        let bpm = bpm_env.value() as f64;
897        let bits = pat_env.load(Ordering::Relaxed);
898        let (active, phi) = rhythm::step_is_active(bits, t, bpm);
899        if active {
900            (-phi * 4.5).exp()
901        } else {
902            0.0
903        }
904    });
905    let plucked = filtered * Net::wrap(Box::new(amp_env));
906
907    let stereo = plucked
908        >> Net::wrap(Box::new(split::<U2>()))
909        >> Net::wrap(Box::new(
910            chorus(0, 0.0, 0.010, 0.5) | chorus(1, 0.0, 0.013, 0.5),
911        ))
912        >> Net::wrap(Box::new(reverb_stereo(18.0, 3.5, 0.88)));
913
914    let with_super = stereo >> supermass_send(p.supermass.clone());
915    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
916    voiced
917        * stereo_gate_voiced(
918            p.gain.clone(),
919            p.mute.clone(),
920            p.pulse_depth.clone(),
921            g.bpm.clone(),
922            p.life_mod.clone(),
923            lb,
924        )
925}