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    let stereo = body >> split::<U2>() >> reverb_stereo(20.0, 5.0, 0.85);
466
467    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
468    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
469    voiced
470        * stereo_gate_voiced(
471            p.gain.clone(),
472            p.mute.clone(),
473            p.pulse_depth.clone(),
474            g.bpm.clone(),
475            p.life_mod.clone(),
476            lb,
477        )
478}
479
480// ── Shimmer ──
481fn shimmer(p: &TrackParams, g: &GlobalParams) -> Net {
482    let lb = LfoBundle::from_params(p);
483    let f0 = p.freq.clone();
484    let f1 = p.freq.clone();
485    let f2 = p.freq.clone();
486    let (lb0, lb1, lb2) = (lb.clone(), lb.clone(), lb.clone());
487
488    // `character` stretches the high partials from harmonic to inharmonic:
489    //   0.0 → pure [×2, ×3, ×4]
490    //   0.5 → current [×2, ×3, ×4.007]
491    //   1.0 → stretched [×2.1, ×3.3, ×4.8] (bell-like top end)
492    let char_s1 = p.character.clone();
493    let char_s2 = p.character.clone();
494    let char_s3 = p.character.clone();
495    let fm = FreqMod::new(p, g);
496    let fm0 = fm.clone();
497    let fm1 = fm.clone();
498    let fm2 = fm.clone();
499    let _ = (lb0, lb1, lb2);
500    let osc = (lfo(move |t: f64| {
501            let c = char_s1.value() as f64;
502            let r = lerp3(2.0, 2.0, 2.1, c);
503            fm0.apply(f0.value() as f64 * r, t)
504        }) >> follow(0.08) >> (sine() * 0.18))
505        + (lfo(move |t: f64| {
506            let c = char_s2.value() as f64;
507            let r = lerp3(3.0, 3.0, 3.3, c);
508            fm1.apply(f1.value() as f64 * r, t)
509        }) >> follow(0.08) >> (sine() * 0.12))
510        + (lfo(move |t: f64| {
511            let c = char_s3.value() as f64;
512            let r = lerp3(4.0, 4.007, 4.8, c);
513            fm2.apply(f2.value() as f64 * r, t)
514        }) >> follow(0.08) >> (sine() * 0.08));
515
516    let bright = osc >> highpass_hz(400.0, 0.5);
517    let stereo = bright >> split::<U2>() >> reverb_stereo(22.0, 6.0, 0.85);
518
519    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
520    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
521    voiced
522        * stereo_gate_voiced(
523            p.gain.clone(),
524            p.mute.clone(),
525            p.pulse_depth.clone(),
526            g.bpm.clone(),
527            p.life_mod.clone(),
528            lb,
529        )
530}
531
532// ── Heartbeat: 3-layer kick drum with Euclidean 16-step pattern ──
533// Every layer fires only on active pattern steps (step resolution = 4
534// per beat). Envelopes are step-length (~1/4 beat). Pattern bitmask is
535// read with an atomic Relaxed load — lock-free, ~1 ns per sample.
536fn heartbeat(p: &TrackParams, g: &GlobalParams) -> Net {
537    let bpm = g.bpm.clone();
538
539    // Body — pitch-swept sine (pitch drop happens only within active steps).
540    let bpm_body_f = bpm.clone();
541    let freq_body = p.freq.clone();
542    let pat_body_f = p.pattern_bits.clone();
543    let body_osc = lfo(move |t: f64| {
544        let bpm_v = bpm_body_f.value() as f64;
545        let bits = pat_body_f.load(Ordering::Relaxed);
546        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
547        let base = freq_body.value() as f64;
548        if active {
549            let drop = (-phi * 40.0).exp();
550            base * (0.7 + 1.5 * drop)
551        } else {
552            // No hit — hold the osc at its base so there is no phase
553            // pop when the next step arrives.
554            base
555        }
556    }) >> sine();
557
558    let bpm_body_e = bpm.clone();
559    let pat_body_e = p.pattern_bits.clone();
560    let body_env = lfo(move |t: f64| {
561        let bpm_v = bpm_body_e.value() as f64;
562        let bits = pat_body_e.load(Ordering::Relaxed);
563        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
564        if active {
565            (-phi * 4.0).exp()
566        } else {
567            0.0
568        }
569    });
570    let body = body_osc * body_env * 0.85;
571
572    // Sub — low sine, slower decay bleeds across the step boundary.
573    // Amplitude comes from the sub_scale LFO defined below so we can
574    // lean into 808 boom at low character values.
575    let freq_sub = p.freq.clone();
576    let sub_osc = lfo(move |_t: f64| freq_sub.value() as f64 * 0.5) >> sine();
577    let bpm_sub_e = bpm.clone();
578    let pat_sub = p.pattern_bits.clone();
579    let sub_env = lfo(move |t: f64| {
580        let bpm_v = bpm_sub_e.value() as f64;
581        let bits = pat_sub.load(Ordering::Relaxed);
582        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
583        if active {
584            (-phi * 1.5).exp()
585        } else {
586            0.0
587        }
588    });
589    let sub = sub_osc * sub_env;
590
591    // Click — short burst on active steps. Amplitude is driven by
592    // `character`: low → no click (pure 808 boom), high → snappy punch.
593    let bpm_click = bpm.clone();
594    let pat_click = p.pattern_bits.clone();
595    let char_click = p.character.clone();
596    let click_env = lfo(move |t: f64| {
597        let bpm_v = bpm_click.value() as f64;
598        let bits = pat_click.load(Ordering::Relaxed);
599        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
600        if active {
601            // Envelope amplitude scales with character:
602            //   0.0 → 0.02 (barely there)
603            //   0.5 → 0.12 (classic, current)
604            //   1.0 → 0.22 (snappy)
605            let amp = 0.02 + char_click.value().clamp(0.0, 1.0) as f64 * 0.20;
606            (-phi * 40.0).exp() * amp
607        } else {
608            0.0
609        }
610    });
611    let click = (brown() >> highpass_hz(1800.0, 0.5)) * click_env;
612
613    // Sub amplitude inversely scales with character — at low character
614    // the kick is ALL sub-boom; at high character the click and short
615    // body carry the energy instead.
616    let char_sub = p.character.clone();
617    let sub_scale = lfo(move |_t: f64| {
618        // 1.0 → 0.55 (lots of sub)  ·  0.5 → 0.45  ·  0.0 → 0.35
619        0.35 + (1.0 - char_sub.value().clamp(0.0, 1.0) as f64) * 0.20
620    });
621    let sub_scaled = sub * sub_scale;
622
623    let kick = body + sub_scaled + click;
624
625    let stereo = kick >> split::<U2>() >> reverb_stereo(10.0, 1.5, 0.88);
626
627    let lb = LfoBundle::from_params(p);
628    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
629    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
630    voiced
631        * stereo_gate_voiced(
632            p.gain.clone(),
633            p.mute.clone(),
634            p.pulse_depth.clone(),
635            g.bpm.clone(),
636            p.life_mod.clone(),
637            lb,
638        )
639}
640
641// ── BassPulse: sustained bass line with BPM groove ──
642// Fundamental + 2nd harmonic + sub, Moog-lowpassed; groove envelope
643// pumps amplitude on every beat so the bass pulses instead of droning.
644fn bass_pulse(p: &TrackParams, g: &GlobalParams) -> Net {
645    let lb = LfoBundle::from_params(p);
646    let f1 = p.freq.clone();
647    let f2 = p.freq.clone();
648    let f3 = p.freq.clone();
649    let cut = p.cutoff.clone();
650    let res_s = p.resonance.clone();
651    let (lb1, lb2, lb3, lb_c) = (lb.clone(), lb.clone(), lb.clone(), lb.clone());
652
653    let fm = FreqMod::new(p, g);
654    let (fm1_, fm2_, fm3_) = (fm.clone(), fm.clone(), fm.clone());
655    let _ = (lb1, lb2, lb3);
656    let fundamental = lfo(move |t: f64| fm1_.apply(f1.value() as f64, t))
657        >> follow(0.08) >> (sine() * 0.55);
658    let second = lfo(move |t: f64| fm2_.apply(f2.value() as f64 * 2.0, t))
659        >> follow(0.08) >> (sine() * 0.22);
660    let sub = lfo(move |t: f64| fm3_.apply(f3.value() as f64 * 0.5, t))
661        >> follow(0.08) >> (sine() * 0.35);
662    let osc = fundamental + second + sub;
663
664    let cut_mod = lfo(move |t: f64| {
665        let b = cut.value().min(900.0) as f64;
666        lb_c.apply(b, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
667    }) >> follow(0.08);
668    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.08);
669    let filtered = (osc | cut_mod | res_mod) >> moog();
670
671    let bpm_groove = g.bpm.clone();
672    let groove = lfo(move |t: f64| {
673        let pump = pulse_decay(t, bpm_groove.value() as f64, 3.5);
674        0.45 + 0.55 * pump
675    });
676    let grooved = filtered * groove;
677
678    let stereo = grooved >> split::<U2>() >> reverb_stereo(14.0, 2.5, 0.88);
679
680    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
681    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
682    voiced
683        * stereo_gate_voiced(
684            p.gain.clone(),
685            p.mute.clone(),
686            p.pulse_depth.clone(),
687            g.bpm.clone(),
688            p.life_mod.clone(),
689            lb,
690        )
691}
692
693// ── Bell: two-operator FM tone (inharmonic ratio 2.76) ──
694// Modulator at freq·2.76 with depth = resonance·450 Hz frequency
695// modulates the carrier at freq. Dial `resonance` for metallic shimmer.
696// Named `bell_preset` to avoid collision with fundsp's `bell()` filter.
697fn bell_preset(p: &TrackParams, g: &GlobalParams) -> Net {
698    let lb = LfoBundle::from_params(p);
699    let fc = p.freq.clone();
700    let fm = p.freq.clone();
701    let fm_depth = p.resonance.clone();
702    let (lb_c, lb_m) = (lb.clone(), lb.clone());
703
704    // `character` shifts FM ratio:
705    //   0.0 → 1.41 (harmonic-ish — metallic pad)
706    //   0.5 → 2.76 (classic inharmonic bell)
707    //   1.0 → 4.18 (bright glassy)
708    let char_m = p.character.clone();
709    let fmm = FreqMod::new(p, g);
710    let fmm_m = fmm.clone();
711    let fmm_c = fmm.clone();
712    let _ = (lb_m, lb_c);
713    let modulator_freq = lfo(move |t: f64| {
714        let c = char_m.value() as f64;
715        let ratio = lerp3(1.41, 2.76, 4.18, c);
716        let b = fm.value() as f64 * ratio;
717        fmm_m.apply(b, t)
718    }) >> follow(0.08);
719    let modulator = modulator_freq >> sine();
720    let mod_scale = lfo(move |_t: f64| fm_depth.value().min(0.65) as f64 * 450.0);
721    let modulator_scaled = modulator * mod_scale;
722
723    let carrier_base = lfo(move |t: f64| fmm_c.apply(fc.value() as f64, t))
724        >> follow(0.08);
725    let bell_sig = (carrier_base + modulator_scaled) >> sine();
726
727    let bpm_am = g.bpm.clone();
728    let am = lfo(move |t: f64| 0.85 + 0.15 * pulse_sine(t, bpm_am.value() as f64 * 0.25));
729    let body = bell_sig * am * 0.30;
730
731    let stereo = body >> split::<U2>() >> reverb_stereo(25.0, 8.0, 0.85);
732
733    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
734    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
735    voiced
736        * stereo_gate_voiced(
737            p.gain.clone(),
738            p.mute.clone(),
739            p.pulse_depth.clone(),
740            g.bpm.clone(),
741            p.life_mod.clone(),
742            lb,
743        )
744}
745
746// ── SuperSaw: Serum-style 7-voice detuned saw stack + sine sub ──
747// Seven saws spread symmetrically across ±|detune| cents. Classic
748// trance/lead texture — as `detune` grows the stack goes from clean
749// unison to lush chorus. Amplitude 1/(N+2) keeps the sum safe from clip.
750fn super_saw(p: &TrackParams, g: &GlobalParams) -> Net {
751    let lb = LfoBundle::from_params(p);
752    let cut = p.cutoff.clone();
753    let res_s = p.resonance.clone();
754
755    const OFFS: [f64; 7] = [-1.0, -0.66, -0.33, 0.0, 0.33, 0.66, 1.0];
756    // FunDSP scalar ops on WaveSynth take f32 (not f64).
757    let voice_amp: f32 = 0.55 / OFFS.len() as f32;
758
759    // Build the 7-voice saw stack by folding Net additions.
760    let fm = FreqMod::new(p, g);
761    let mut stack: Option<Net> = None;
762    for &off in OFFS.iter() {
763        let f_c = p.freq.clone();
764        let d_c = p.detune.clone();
765        let fm_c = fm.clone();
766        let voice = lfo(move |t: f64| {
767            let width = (d_c.value().abs() as f64).max(1.0);
768            let cents = off * width;
769            let base = f_c.value() as f64 * 2.0_f64.powf(cents / 1200.0);
770            fm_c.apply(base, t)
771        }) >> follow(0.08) >> (saw() * voice_amp);
772        let wrapped = Net::wrap(Box::new(voice));
773        stack = Some(match stack {
774            Some(acc) => acc + wrapped,
775            None => wrapped,
776        });
777    }
778    let saw_stack = stack.expect("N > 0");
779
780    // Sub-octave sine for weight.
781    let f_sub = p.freq.clone();
782    let fm_sub = fm.clone();
783    let _ = lb.clone();
784    let sub = lfo(move |t: f64| fm_sub.apply(f_sub.value() as f64 * 0.5, t))
785        >> follow(0.08) >> (sine() * 0.22);
786    let sub_net = Net::wrap(Box::new(sub));
787
788    let mixed = saw_stack + sub_net;
789
790    let lb_cut = lb.clone();
791    let cut_mod = lfo(move |t: f64| {
792        let b = cut.value() as f64;
793        lb_cut.apply(b, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
794    }) >> follow(0.05);
795    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.08);
796
797    let filtered = (mixed | Net::wrap(Box::new(cut_mod)) | Net::wrap(Box::new(res_mod)))
798        >> Net::wrap(Box::new(moog()));
799
800    let stereo = filtered
801        >> Net::wrap(Box::new(split::<U2>()))
802        >> Net::wrap(Box::new(
803            chorus(0, 0.0, 0.012, 0.4) | chorus(1, 0.0, 0.014, 0.4),
804        ))
805        >> Net::wrap(Box::new(reverb_stereo(16.0, 3.0, 0.88)));
806
807    let with_super = stereo >> supermass_send(p.supermass.clone());
808    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
809    voiced
810        * stereo_gate_voiced(
811            p.gain.clone(),
812            p.mute.clone(),
813            p.pulse_depth.clone(),
814            g.bpm.clone(),
815            p.life_mod.clone(),
816            lb,
817        )
818}
819
820// ── PluckSaw: step-gated saw pluck with filter envelope ──
821// Fires on every active Euclidean step. Each hit opens the Moog from
822// 180 Hz up to the user cutoff and decays, making notes feel plucked.
823fn pluck_saw(p: &TrackParams, g: &GlobalParams) -> Net {
824    let lb = LfoBundle::from_params(p);
825
826    let fm = FreqMod::new(p, g);
827    let fm_a = fm.clone();
828    let fm_b = fm.clone();
829    let f_a = p.freq.clone();
830    let osc_a = lfo(move |t: f64| fm_a.apply(f_a.value() as f64, t))
831        >> follow(0.08) >> (saw() * 0.35);
832
833    let f_b = p.freq.clone();
834    let det = p.detune.clone();
835    let osc_b = lfo(move |t: f64| {
836        let cents = det.value() as f64 * 0.5;
837        let b = f_b.value() as f64 * 2.0_f64.powf(cents / 1200.0);
838        fm_b.apply(b, t)
839    }) >> follow(0.08) >> (saw() * 0.35);
840    let osc = osc_a + osc_b;
841
842    // Filter envelope: on each active step, cutoff decays from user
843    // value down to 180 Hz across the step. Off-steps stay muffled.
844    let bpm_f = g.bpm.clone();
845    let pat_f = p.pattern_bits.clone();
846    let cut_shared = p.cutoff.clone();
847    let lb_c = lb.clone();
848    let cut_env = lfo(move |t: f64| {
849        let bpm = bpm_f.value() as f64;
850        let bits = pat_f.load(Ordering::Relaxed);
851        let (active, phi) = rhythm::step_is_active(bits, t, bpm);
852        let user_cut = cut_shared.value() as f64;
853        let base = if active {
854            180.0 + (user_cut - 180.0) * (-phi * 5.0).exp()
855        } else {
856            180.0
857        };
858        lb_c.apply(base, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
859    }) >> follow(0.01);
860
861    let res_s = p.resonance.clone();
862    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.05);
863
864    let filtered =
865        (osc | Net::wrap(Box::new(cut_env)) | Net::wrap(Box::new(res_mod))) >> Net::wrap(Box::new(moog()));
866
867    // Amplitude envelope — step-gated, fast decay.
868    let bpm_env = g.bpm.clone();
869    let pat_env = p.pattern_bits.clone();
870    let amp_env = lfo(move |t: f64| {
871        let bpm = bpm_env.value() as f64;
872        let bits = pat_env.load(Ordering::Relaxed);
873        let (active, phi) = rhythm::step_is_active(bits, t, bpm);
874        if active {
875            (-phi * 4.5).exp()
876        } else {
877            0.0
878        }
879    });
880    let plucked = filtered * Net::wrap(Box::new(amp_env));
881
882    let stereo = plucked
883        >> Net::wrap(Box::new(split::<U2>()))
884        >> Net::wrap(Box::new(
885            chorus(0, 0.0, 0.010, 0.5) | chorus(1, 0.0, 0.013, 0.5),
886        ))
887        >> Net::wrap(Box::new(reverb_stereo(18.0, 3.5, 0.88)));
888
889    let with_super = stereo >> supermass_send(p.supermass.clone());
890    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
891    voiced
892        * stereo_gate_voiced(
893            p.gain.clone(),
894            p.mute.clone(),
895            p.pulse_depth.clone(),
896            g.bpm.clone(),
897            p.life_mod.clone(),
898            lb,
899        )
900}