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::{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/// Reverb-mix signal that respects LFO when target = REV.
239/// Additive ±0.4 at depth=1, clamped to [0, 1].
240fn stereo_reverb_mix(base: Shared, lb: LfoBundle) -> Net {
241    let mono = lfo(move |t: f64| {
242        let v = base.value() as f64;
243        lb.apply(v, LFO_REVERB, t, |b, m| (b + m * 0.4).clamp(0.0, 1.0))
244    });
245    Net::wrap(Box::new(mono >> split::<U2>()))
246}
247
248fn supermass_send(amount: Shared) -> Net {
249    let a1 = amount.clone();
250    let a2 = amount;
251    let amount_l = lfo(move |_t: f64| a1.value() as f64);
252    let amount_r = lfo(move |_t: f64| a2.value() as f64);
253    let amount_stereo = Net::wrap(Box::new(amount_l | amount_r));
254
255    // 2nd reverb damping bumped 0.72 → 0.90 so a 28-second T60 does not
256    // accumulate endless 4–8 kHz resonances in the tail.
257    let effect = reverb_stereo(35.0, 15.0, 0.88)
258        >> (chorus(3, 0.0, 0.022, 0.28) | chorus(4, 0.0, 0.026, 0.28))
259        >> reverb_stereo(50.0, 28.0, 0.90);
260
261    let wet_scaled = Net::wrap(Box::new(effect)) * amount_stereo;
262    let dry = Net::wrap(Box::new(multipass::<U2>()));
263    dry & wet_scaled
264}
265
266fn stereo_gate_voiced(
267    gain: Shared,
268    mute: Shared,
269    pulse_depth: Shared,
270    bpm: Shared,
271    life_mod: Shared,
272    lb: LfoBundle,
273) -> Net {
274    let raw = lfo(move |t: f64| {
275        let g_raw = (gain.value() * (1.0 - mute.value())) as f64;
276        // Tremolo — ±60 % at depth=1, additive around base.
277        let g = lb.apply(g_raw, LFO_GAIN, t, |b, m| (b * (1.0 + m * 0.6)).max(0.0));
278        let depth = pulse_depth.value().clamp(0.0, 1.0) as f64;
279        let pulse = pulse_sine(t, bpm.value() as f64);
280        let life = life_mod.value().clamp(0.0, 1.0) as f64;
281        let life_scaled = 0.4 + 0.9 * life;
282        g * (1.0 - depth + depth * pulse) * life_scaled
283    });
284    Net::wrap(Box::new(raw >> follow(0.4) >> split::<U2>()))
285}
286
287// ── Pad ──
288fn pad_zimmer(p: &TrackParams, g: &GlobalParams) -> Net {
289    let cut = p.cutoff.clone();
290    let res_s = p.resonance.clone();
291    let det = p.detune.clone();
292
293    let lb = LfoBundle::from_params(p);
294    let f0 = p.freq.clone();
295    let f1 = p.freq.clone();
296    let f2 = p.freq.clone();
297    let f3 = p.freq.clone();
298    let d1 = det.clone();
299    let d2 = det.clone();
300    let (lb0, lb1, lb2, lb3, lb_c) = (
301        lb.clone(),
302        lb.clone(),
303        lb.clone(),
304        lb.clone(),
305        lb.clone(),
306    );
307
308    let osc = ((lfo(move |t: f64| {
309            let b = f0.value() as f64;
310            lb0.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
311        }) >> (sine() * 0.30))
312        + (lfo(move |t: f64| {
313            let b = f1.value() as f64 * 1.501 * (1.0 + d1.value() as f64 * 0.000578);
314            lb1.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
315        }) >> (sine() * 0.20))
316        + (lfo(move |t: f64| {
317            let b = f2.value() as f64 * 2.013 * (1.0 + d2.value() as f64 * 0.000578);
318            lb2.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
319        }) >> (sine() * 0.14))
320        + (lfo(move |t: f64| {
321            let b = f3.value() as f64 * 3.007;
322            lb3.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
323        }) >> (sine() * 0.08)))
324        * 0.9;
325
326    let cutoff_mod = lfo(move |t: f64| {
327        let wobble = 1.0 + 0.10 * (0.5 - 0.5 * (t * 0.08).sin());
328        let base = cut.value() as f64 * wobble;
329        lb_c.apply(base, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
330    }) >> follow(0.08);
331    // Hard cap at 0.65: above that the Moog self-oscillates into a
332    // sustained whistle at cutoff. We'd rather lose a tiny bit of range
333    // at the top than let auto-evolve park a track in squeal territory.
334    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.08);
335
336    // Tame pad whistle: fixed −3.5 dB shelf at 3 kHz before the reverb.
337    // This kills the resonance that builds between detuned partials
338    // × 3.007 and moog filter peak — the whistle user reported.
339    let filtered = (osc | cutoff_mod | res_mod) >> moog()
340        >> highshelf_hz(3000.0, 0.7, 0.67);
341
342    let stereo = filtered
343        >> split::<U2>()
344        >> (chorus(0, 0.0, 0.015, 0.35) | chorus(1, 0.0, 0.020, 0.35))
345        >> reverb_stereo(18.0, 4.0, 0.9);
346
347    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
348    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
349    voiced
350        * stereo_gate_voiced(
351            p.gain.clone(),
352            p.mute.clone(),
353            p.pulse_depth.clone(),
354            g.bpm.clone(),
355            p.life_mod.clone(),
356            lb,
357        )
358}
359
360// ── Drone ──
361fn drone_sub(p: &TrackParams, g: &GlobalParams) -> Net {
362    let lb = LfoBundle::from_params(p);
363    let cut = p.cutoff.clone();
364    let res_s = p.resonance.clone();
365
366    let f0 = p.freq.clone();
367    let f1 = p.freq.clone();
368    let (lb0, lb1, lb_c) = (lb.clone(), lb.clone(), lb.clone());
369
370    let sub = (lfo(move |t: f64| {
371            let b = f0.value() as f64 * 0.5;
372            lb0.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
373        }) >> (sine() * 0.45))
374        + (lfo(move |t: f64| {
375            let b = f1.value() as f64;
376            lb1.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
377        }) >> (sine() * 0.12));
378
379    let noise_cut = lfo(move |t: f64| {
380        let b = cut.value().clamp(40.0, 300.0) as f64;
381        lb_c.apply(b, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
382    }) >> follow(0.08);
383    let noise_q = lfo(move |_t: f64| res_s.value() as f64) >> follow(0.08);
384    let noise = (brown() | noise_cut | noise_q) >> moog();
385    let noise_body = noise * 0.28;
386
387    let bpm_am = g.bpm.clone();
388    let am = lfo(move |t: f64| 0.88 + 0.12 * pulse_sine(t, bpm_am.value() as f64));
389    let body = (sub + noise_body) * am;
390
391    let stereo = body >> split::<U2>() >> reverb_stereo(20.0, 5.0, 0.85);
392
393    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
394    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
395    voiced
396        * stereo_gate_voiced(
397            p.gain.clone(),
398            p.mute.clone(),
399            p.pulse_depth.clone(),
400            g.bpm.clone(),
401            p.life_mod.clone(),
402            lb,
403        )
404}
405
406// ── Shimmer ──
407fn shimmer(p: &TrackParams, g: &GlobalParams) -> Net {
408    let lb = LfoBundle::from_params(p);
409    let f0 = p.freq.clone();
410    let f1 = p.freq.clone();
411    let f2 = p.freq.clone();
412    let (lb0, lb1, lb2) = (lb.clone(), lb.clone(), lb.clone());
413
414    let osc = (lfo(move |t: f64| {
415            let b = f0.value() as f64 * 2.0;
416            lb0.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
417        }) >> (sine() * 0.18))
418        + (lfo(move |t: f64| {
419            let b = f1.value() as f64 * 3.0;
420            lb1.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
421        }) >> (sine() * 0.12))
422        + (lfo(move |t: f64| {
423            let b = f2.value() as f64 * 4.007;
424            lb2.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
425        }) >> (sine() * 0.08));
426
427    let bright = osc >> highpass_hz(400.0, 0.5);
428    let stereo = bright >> split::<U2>() >> reverb_stereo(22.0, 6.0, 0.85);
429
430    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
431    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
432    voiced
433        * stereo_gate_voiced(
434            p.gain.clone(),
435            p.mute.clone(),
436            p.pulse_depth.clone(),
437            g.bpm.clone(),
438            p.life_mod.clone(),
439            lb,
440        )
441}
442
443// ── Heartbeat: 3-layer kick drum with Euclidean 16-step pattern ──
444// Every layer fires only on active pattern steps (step resolution = 4
445// per beat). Envelopes are step-length (~1/4 beat). Pattern bitmask is
446// read with an atomic Relaxed load — lock-free, ~1 ns per sample.
447fn heartbeat(p: &TrackParams, g: &GlobalParams) -> Net {
448    let bpm = g.bpm.clone();
449
450    // Body — pitch-swept sine (pitch drop happens only within active steps).
451    let bpm_body_f = bpm.clone();
452    let freq_body = p.freq.clone();
453    let pat_body_f = p.pattern_bits.clone();
454    let body_osc = lfo(move |t: f64| {
455        let bpm_v = bpm_body_f.value() as f64;
456        let bits = pat_body_f.load(Ordering::Relaxed);
457        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
458        let base = freq_body.value() as f64;
459        if active {
460            let drop = (-phi * 40.0).exp();
461            base * (0.7 + 1.5 * drop)
462        } else {
463            // No hit — hold the osc at its base so there is no phase
464            // pop when the next step arrives.
465            base
466        }
467    }) >> sine();
468
469    let bpm_body_e = bpm.clone();
470    let pat_body_e = p.pattern_bits.clone();
471    let body_env = lfo(move |t: f64| {
472        let bpm_v = bpm_body_e.value() as f64;
473        let bits = pat_body_e.load(Ordering::Relaxed);
474        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
475        if active {
476            (-phi * 4.0).exp()
477        } else {
478            0.0
479        }
480    });
481    let body = body_osc * body_env * 0.85;
482
483    // Sub — low sine, slower decay bleeds across the step boundary.
484    let freq_sub = p.freq.clone();
485    let sub_osc = lfo(move |_t: f64| freq_sub.value() as f64 * 0.5) >> sine();
486    let bpm_sub_e = bpm.clone();
487    let pat_sub = p.pattern_bits.clone();
488    let sub_env = lfo(move |t: f64| {
489        let bpm_v = bpm_sub_e.value() as f64;
490        let bits = pat_sub.load(Ordering::Relaxed);
491        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
492        if active {
493            (-phi * 1.5).exp()
494        } else {
495            0.0
496        }
497    });
498    let sub = sub_osc * sub_env * 0.45;
499
500    // Click — very short burst on every active step.
501    let bpm_click = bpm.clone();
502    let pat_click = p.pattern_bits.clone();
503    let click_env = lfo(move |t: f64| {
504        let bpm_v = bpm_click.value() as f64;
505        let bits = pat_click.load(Ordering::Relaxed);
506        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
507        if active {
508            (-phi * 40.0).exp()
509        } else {
510            0.0
511        }
512    });
513    let click = (brown() >> highpass_hz(1800.0, 0.5)) * click_env * 0.12;
514
515    let kick = body + sub + click;
516
517    let stereo = kick >> split::<U2>() >> reverb_stereo(10.0, 1.5, 0.88);
518
519    let lb = LfoBundle::from_params(p);
520    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
521    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
522    voiced
523        * stereo_gate_voiced(
524            p.gain.clone(),
525            p.mute.clone(),
526            p.pulse_depth.clone(),
527            g.bpm.clone(),
528            p.life_mod.clone(),
529            lb,
530        )
531}
532
533// ── BassPulse: sustained bass line with BPM groove ──
534// Fundamental + 2nd harmonic + sub, Moog-lowpassed; groove envelope
535// pumps amplitude on every beat so the bass pulses instead of droning.
536fn bass_pulse(p: &TrackParams, g: &GlobalParams) -> Net {
537    let lb = LfoBundle::from_params(p);
538    let f1 = p.freq.clone();
539    let f2 = p.freq.clone();
540    let f3 = p.freq.clone();
541    let cut = p.cutoff.clone();
542    let res_s = p.resonance.clone();
543    let (lb1, lb2, lb3, lb_c) = (lb.clone(), lb.clone(), lb.clone(), lb.clone());
544
545    let fundamental = lfo(move |t: f64| {
546        let b = f1.value() as f64;
547        lb1.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
548    }) >> (sine() * 0.55);
549    let second = lfo(move |t: f64| {
550        let b = f2.value() as f64 * 2.0;
551        lb2.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
552    }) >> (sine() * 0.22);
553    let sub = lfo(move |t: f64| {
554        let b = f3.value() as f64 * 0.5;
555        lb3.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
556    }) >> (sine() * 0.35);
557    let osc = fundamental + second + sub;
558
559    let cut_mod = lfo(move |t: f64| {
560        let b = cut.value().min(900.0) as f64;
561        lb_c.apply(b, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
562    }) >> follow(0.08);
563    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.08);
564    let filtered = (osc | cut_mod | res_mod) >> moog();
565
566    let bpm_groove = g.bpm.clone();
567    let groove = lfo(move |t: f64| {
568        let pump = pulse_decay(t, bpm_groove.value() as f64, 3.5);
569        0.45 + 0.55 * pump
570    });
571    let grooved = filtered * groove;
572
573    let stereo = grooved >> split::<U2>() >> reverb_stereo(14.0, 2.5, 0.88);
574
575    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
576    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
577    voiced
578        * stereo_gate_voiced(
579            p.gain.clone(),
580            p.mute.clone(),
581            p.pulse_depth.clone(),
582            g.bpm.clone(),
583            p.life_mod.clone(),
584            lb,
585        )
586}
587
588// ── Bell: two-operator FM tone (inharmonic ratio 2.76) ──
589// Modulator at freq·2.76 with depth = resonance·450 Hz frequency
590// modulates the carrier at freq. Dial `resonance` for metallic shimmer.
591// Named `bell_preset` to avoid collision with fundsp's `bell()` filter.
592fn bell_preset(p: &TrackParams, g: &GlobalParams) -> Net {
593    let lb = LfoBundle::from_params(p);
594    let fc = p.freq.clone();
595    let fm = p.freq.clone();
596    let fm_depth = p.resonance.clone();
597    let (lb_c, lb_m) = (lb.clone(), lb.clone());
598
599    let modulator_freq = lfo(move |t: f64| {
600        let b = fm.value() as f64 * 2.76;
601        lb_m.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
602    });
603    let modulator = modulator_freq >> sine();
604    let mod_scale = lfo(move |_t: f64| fm_depth.value().min(0.65) as f64 * 450.0);
605    let modulator_scaled = modulator * mod_scale;
606
607    let carrier_base = lfo(move |t: f64| {
608        let b = fc.value() as f64;
609        lb_c.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
610    });
611    let bell_sig = (carrier_base + modulator_scaled) >> sine();
612
613    let bpm_am = g.bpm.clone();
614    let am = lfo(move |t: f64| 0.85 + 0.15 * pulse_sine(t, bpm_am.value() as f64 * 0.25));
615    let body = bell_sig * am * 0.30;
616
617    let stereo = body >> split::<U2>() >> reverb_stereo(25.0, 8.0, 0.85);
618
619    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
620    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
621    voiced
622        * stereo_gate_voiced(
623            p.gain.clone(),
624            p.mute.clone(),
625            p.pulse_depth.clone(),
626            g.bpm.clone(),
627            p.life_mod.clone(),
628            lb,
629        )
630}
631
632// ── SuperSaw: Serum-style 7-voice detuned saw stack + sine sub ──
633// Seven saws spread symmetrically across ±|detune| cents. Classic
634// trance/lead texture — as `detune` grows the stack goes from clean
635// unison to lush chorus. Amplitude 1/(N+2) keeps the sum safe from clip.
636fn super_saw(p: &TrackParams, g: &GlobalParams) -> Net {
637    let lb = LfoBundle::from_params(p);
638    let cut = p.cutoff.clone();
639    let res_s = p.resonance.clone();
640
641    const OFFS: [f64; 7] = [-1.0, -0.66, -0.33, 0.0, 0.33, 0.66, 1.0];
642    // FunDSP scalar ops on WaveSynth take f32 (not f64).
643    let voice_amp: f32 = 0.55 / OFFS.len() as f32;
644
645    // Build the 7-voice saw stack by folding Net additions.
646    let mut stack: Option<Net> = None;
647    for &off in OFFS.iter() {
648        let f_c = p.freq.clone();
649        let d_c = p.detune.clone();
650        let lb_c = lb.clone();
651        let voice = lfo(move |t: f64| {
652            let width = (d_c.value().abs() as f64).max(1.0);
653            let cents = off * width;
654            let base = f_c.value() as f64 * 2.0_f64.powf(cents / 1200.0);
655            lb_c.apply(base, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
656        }) >> (saw() * voice_amp);
657        let wrapped = Net::wrap(Box::new(voice));
658        stack = Some(match stack {
659            Some(acc) => acc + wrapped,
660            None => wrapped,
661        });
662    }
663    let saw_stack = stack.expect("N > 0");
664
665    // Sub-octave sine for weight.
666    let f_sub = p.freq.clone();
667    let lb_sub = lb.clone();
668    let sub = lfo(move |t: f64| {
669        let b = f_sub.value() as f64 * 0.5;
670        lb_sub.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
671    }) >> (sine() * 0.22);
672    let sub_net = Net::wrap(Box::new(sub));
673
674    let mixed = saw_stack + sub_net;
675
676    let lb_cut = lb.clone();
677    let cut_mod = lfo(move |t: f64| {
678        let b = cut.value() as f64;
679        lb_cut.apply(b, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
680    }) >> follow(0.05);
681    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.08);
682
683    let filtered = (mixed | Net::wrap(Box::new(cut_mod)) | Net::wrap(Box::new(res_mod)))
684        >> Net::wrap(Box::new(moog()));
685
686    let stereo = filtered
687        >> Net::wrap(Box::new(split::<U2>()))
688        >> Net::wrap(Box::new(
689            chorus(0, 0.0, 0.012, 0.4) | chorus(1, 0.0, 0.014, 0.4),
690        ))
691        >> Net::wrap(Box::new(reverb_stereo(16.0, 3.0, 0.88)));
692
693    let with_super = stereo >> supermass_send(p.supermass.clone());
694    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
695    voiced
696        * stereo_gate_voiced(
697            p.gain.clone(),
698            p.mute.clone(),
699            p.pulse_depth.clone(),
700            g.bpm.clone(),
701            p.life_mod.clone(),
702            lb,
703        )
704}
705
706// ── PluckSaw: step-gated saw pluck with filter envelope ──
707// Fires on every active Euclidean step. Each hit opens the Moog from
708// 180 Hz up to the user cutoff and decays, making notes feel plucked.
709fn pluck_saw(p: &TrackParams, g: &GlobalParams) -> Net {
710    let lb = LfoBundle::from_params(p);
711
712    let f_a = p.freq.clone();
713    let lb_a = lb.clone();
714    let osc_a = lfo(move |t: f64| {
715        let b = f_a.value() as f64;
716        lb_a.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
717    }) >> (saw() * 0.35);
718
719    let f_b = p.freq.clone();
720    let det = p.detune.clone();
721    let lb_b = lb.clone();
722    let osc_b = lfo(move |t: f64| {
723        let cents = det.value() as f64 * 0.5;
724        let b = f_b.value() as f64 * 2.0_f64.powf(cents / 1200.0);
725        lb_b.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
726    }) >> (saw() * 0.35);
727    let osc = osc_a + osc_b;
728
729    // Filter envelope: on each active step, cutoff decays from user
730    // value down to 180 Hz across the step. Off-steps stay muffled.
731    let bpm_f = g.bpm.clone();
732    let pat_f = p.pattern_bits.clone();
733    let cut_shared = p.cutoff.clone();
734    let lb_c = lb.clone();
735    let cut_env = lfo(move |t: f64| {
736        let bpm = bpm_f.value() as f64;
737        let bits = pat_f.load(Ordering::Relaxed);
738        let (active, phi) = rhythm::step_is_active(bits, t, bpm);
739        let user_cut = cut_shared.value() as f64;
740        let base = if active {
741            180.0 + (user_cut - 180.0) * (-phi * 5.0).exp()
742        } else {
743            180.0
744        };
745        lb_c.apply(base, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
746    }) >> follow(0.01);
747
748    let res_s = p.resonance.clone();
749    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.05);
750
751    let filtered =
752        (osc | Net::wrap(Box::new(cut_env)) | Net::wrap(Box::new(res_mod))) >> Net::wrap(Box::new(moog()));
753
754    // Amplitude envelope — step-gated, fast decay.
755    let bpm_env = g.bpm.clone();
756    let pat_env = p.pattern_bits.clone();
757    let amp_env = lfo(move |t: f64| {
758        let bpm = bpm_env.value() as f64;
759        let bits = pat_env.load(Ordering::Relaxed);
760        let (active, phi) = rhythm::step_is_active(bits, t, bpm);
761        if active {
762            (-phi * 4.5).exp()
763        } else {
764            0.0
765        }
766    });
767    let plucked = filtered * Net::wrap(Box::new(amp_env));
768
769    let stereo = plucked
770        >> Net::wrap(Box::new(split::<U2>()))
771        >> Net::wrap(Box::new(
772            chorus(0, 0.0, 0.010, 0.5) | chorus(1, 0.0, 0.013, 0.5),
773        ))
774        >> Net::wrap(Box::new(reverb_stereo(18.0, 3.5, 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}