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