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}
25
26/// All preset kinds in cycle order. Used by the TUI `t` / `T` keys.
27pub const ALL_KINDS: [PresetKind; 6] = [
28    PresetKind::PadZimmer,
29    PresetKind::BassPulse,
30    PresetKind::Heartbeat,
31    PresetKind::DroneSub,
32    PresetKind::Shimmer,
33    PresetKind::Bell,
34];
35
36impl PresetKind {
37    pub fn label(self) -> &'static str {
38        match self {
39            PresetKind::PadZimmer => "Pad",
40            PresetKind::DroneSub => "Drone",
41            PresetKind::Shimmer => "Shimmer",
42            PresetKind::Heartbeat => "Heartbeat",
43            PresetKind::BassPulse => "Bass",
44            PresetKind::Bell => "Bell",
45        }
46    }
47
48    pub fn next(self) -> Self {
49        let i = ALL_KINDS.iter().position(|&k| k == self).unwrap_or(0);
50        ALL_KINDS[(i + 1) % ALL_KINDS.len()]
51    }
52
53    pub fn prev(self) -> Self {
54        let i = ALL_KINDS.iter().position(|&k| k == self).unwrap_or(0);
55        ALL_KINDS[(i + ALL_KINDS.len() - 1) % ALL_KINDS.len()]
56    }
57}
58
59#[derive(Clone)]
60pub struct GlobalParams {
61    pub bpm: Shared,
62    pub master_gain: Shared,
63    /// Master high-shelf amount in [0.0, 1.0] — shelf centre fixed at
64    /// 3.5 kHz, q 0.7. Maps linearly to shelf *amplitude*:
65    ///   0.0 → 0.2  (−14 dB, dark)
66    ///   0.5 → 0.6  (−4.4 dB)
67    ///   0.6 → 0.68 (−3.3 dB, default)
68    ///   1.0 → 1.0  (0 dB, bypass)
69    /// A shelf keeps the mids full, so lowering it removes harshness
70    /// without sounding like a volume drop.
71    pub brightness: Shared,
72}
73
74impl Default for GlobalParams {
75    fn default() -> Self {
76        Self {
77            bpm: shared(72.0),
78            master_gain: shared(0.7),
79            brightness: shared(0.6),
80        }
81    }
82}
83
84pub const MASTER_SHELF_HZ: f64 = 3500.0;
85pub const MIN_SHELF_GAIN: f64 = 0.2;
86
87/// Map brightness [0..1] → shelf amplitude gain [MIN..1.0] linearly.
88#[inline]
89pub fn brightness_to_shelf_gain(b: f64) -> f64 {
90    MIN_SHELF_GAIN + (1.0 - MIN_SHELF_GAIN) * b.clamp(0.0, 1.0)
91}
92
93/// Amplitude → dB for header readout.
94#[inline]
95pub fn shelf_gain_db(g: f64) -> f64 {
96    20.0 * g.max(1e-6).log10()
97}
98
99/// Map brightness to the master lowpass cutoff (Hz).
100/// 0 → 3000 Hz (hard cut of reverb HF resonances)
101/// 0.6 → ~8.6 kHz (default — mellow)
102/// 1.0 → 18 kHz (effective bypass)
103#[inline]
104pub fn brightness_to_lp_cutoff(b: f64) -> f64 {
105    3000.0 * 6.0_f64.powf(b.clamp(0.0, 1.0))
106}
107
108/// Stereo master bus: per-channel **high-shelf** (tilt) → **lowpass**
109/// (hard cut) → limiter. Both EQ stages driven by the same `brightness`.
110///
111/// Why two stages? Shelf gives the tonal character ("dark vs bright")
112/// while keeping mids full. Lowpass actually *removes* the 3–8 kHz
113/// reverb/chorus resonance buildup that otherwise still leaks through
114/// a shelf. Turn brightness fully up and both become passthrough.
115pub fn master_bus(brightness: Shared) -> Net {
116    let b_shelf_l = brightness.clone();
117    let b_shelf_r = brightness.clone();
118    let b_lp_l = brightness.clone();
119    let b_lp_r = brightness;
120
121    // ── Shelf stage ──
122    let sh_f_l = lfo(|_t: f64| MASTER_SHELF_HZ);
123    let sh_f_r = lfo(|_t: f64| MASTER_SHELF_HZ);
124    let sh_q_l = lfo(|_t: f64| 0.7_f64);
125    let sh_q_r = lfo(|_t: f64| 0.7_f64);
126    let sh_g_l = lfo(move |_t: f64| brightness_to_shelf_gain(b_shelf_l.value() as f64));
127    let sh_g_r = lfo(move |_t: f64| brightness_to_shelf_gain(b_shelf_r.value() as f64));
128    let shelf_l = (pass() | sh_f_l | sh_q_l | sh_g_l) >> highshelf();
129    let shelf_r = (pass() | sh_f_r | sh_q_r | sh_g_r) >> highshelf();
130
131    // ── Lowpass stage ──
132    let lp_c_l = lfo(move |_t: f64| brightness_to_lp_cutoff(b_lp_l.value() as f64));
133    let lp_c_r = lfo(move |_t: f64| brightness_to_lp_cutoff(b_lp_r.value() as f64));
134    let lp_q_l = lfo(|_t: f64| 0.5_f64);
135    let lp_q_r = lfo(|_t: f64| 0.5_f64);
136
137    let left = shelf_l >> (pass() | lp_c_l | lp_q_l) >> lowpass();
138    let right = shelf_r >> (pass() | lp_c_r | lp_q_r) >> lowpass();
139    let stereo = left | right;
140
141    let chain = stereo >> limiter_stereo(0.001, 0.3);
142    Net::wrap(Box::new(chain))
143}
144
145pub struct Preset;
146
147impl Preset {
148    pub fn build(kind: PresetKind, p: &TrackParams, g: &GlobalParams) -> Net {
149        match kind {
150            PresetKind::PadZimmer => pad_zimmer(p, g),
151            PresetKind::DroneSub => drone_sub(p, g),
152            PresetKind::Shimmer => shimmer(p, g),
153            PresetKind::Heartbeat => heartbeat(p, g),
154            PresetKind::BassPulse => bass_pulse(p, g),
155            PresetKind::Bell => bell_preset(p, g),
156        }
157    }
158}
159
160// ── LFO targets ─────────────────────────────────────────────────────────
161
162pub const LFO_OFF: u32 = 0;
163pub const LFO_CUTOFF: u32 = 1;
164pub const LFO_GAIN: u32 = 2;
165pub const LFO_FREQ: u32 = 3;
166pub const LFO_REVERB: u32 = 4;
167pub const LFO_TARGETS: u32 = 5;
168
169pub fn lfo_target_name(idx: u32) -> &'static str {
170    match idx {
171        LFO_OFF => "OFF",
172        LFO_CUTOFF => "CUT",
173        LFO_GAIN => "GAIN",
174        LFO_FREQ => "FREQ",
175        LFO_REVERB => "REV",
176        _ => "?",
177    }
178}
179
180/// Lightweight bundle of the three Shared atomics that drive a track's
181/// per-voice LFO. Clone is ~3× Arc-clone (refcount bumps) — cheap.
182#[derive(Clone)]
183pub struct LfoBundle {
184    pub rate: Shared,
185    pub depth: Shared,
186    pub target: Shared,
187}
188
189impl LfoBundle {
190    pub fn from_params(p: &TrackParams) -> Self {
191        Self {
192            rate: p.lfo_rate.clone(),
193            depth: p.lfo_depth.clone(),
194            target: p.lfo_target.clone(),
195        }
196    }
197
198    /// Apply this LFO to `base` only if `this_target` matches the user
199    /// selection *and* depth is audible. Otherwise `base` is returned
200    /// unchanged — the LFO adds zero cost when it's off.
201    #[inline]
202    pub fn apply(
203        &self,
204        base: f64,
205        this_target: u32,
206        t: f64,
207        scaler: impl Fn(f64, f64) -> f64,
208    ) -> f64 {
209        let tgt = self.target.value().round() as u32;
210        if tgt != this_target {
211            return base;
212        }
213        let depth = self.depth.value() as f64;
214        if depth < 1.0e-4 {
215            return base;
216        }
217        let rate = self.rate.value() as f64;
218        let lv = (std::f64::consts::TAU * rate * t).sin();
219        scaler(base, lv * depth)
220    }
221}
222
223// ── Helpers ─────────────────────────────────────────────────────────────
224
225#[allow(dead_code)]
226fn stereo_from_shared(s: Shared) -> Net {
227    Net::wrap(Box::new(lfo(move |_t: f64| s.value() as f64) >> split::<U2>()))
228}
229
230/// Reverb-mix signal that respects LFO when target = REV.
231/// Additive ±0.4 at depth=1, clamped to [0, 1].
232fn stereo_reverb_mix(base: Shared, lb: LfoBundle) -> Net {
233    let mono = lfo(move |t: f64| {
234        let v = base.value() as f64;
235        lb.apply(v, LFO_REVERB, t, |b, m| (b + m * 0.4).clamp(0.0, 1.0))
236    });
237    Net::wrap(Box::new(mono >> split::<U2>()))
238}
239
240fn supermass_send(amount: Shared) -> Net {
241    let a1 = amount.clone();
242    let a2 = amount;
243    let amount_l = lfo(move |_t: f64| a1.value() as f64);
244    let amount_r = lfo(move |_t: f64| a2.value() as f64);
245    let amount_stereo = Net::wrap(Box::new(amount_l | amount_r));
246
247    // 2nd reverb damping bumped 0.72 → 0.90 so a 28-second T60 does not
248    // accumulate endless 4–8 kHz resonances in the tail.
249    let effect = reverb_stereo(35.0, 15.0, 0.88)
250        >> (chorus(3, 0.0, 0.022, 0.28) | chorus(4, 0.0, 0.026, 0.28))
251        >> reverb_stereo(50.0, 28.0, 0.90);
252
253    let wet_scaled = Net::wrap(Box::new(effect)) * amount_stereo;
254    let dry = Net::wrap(Box::new(multipass::<U2>()));
255    dry & wet_scaled
256}
257
258fn stereo_gate_voiced(
259    gain: Shared,
260    mute: Shared,
261    pulse_depth: Shared,
262    bpm: Shared,
263    life_mod: Shared,
264    lb: LfoBundle,
265) -> Net {
266    let raw = lfo(move |t: f64| {
267        let g_raw = (gain.value() * (1.0 - mute.value())) as f64;
268        // Tremolo — ±60 % at depth=1, additive around base.
269        let g = lb.apply(g_raw, LFO_GAIN, t, |b, m| (b * (1.0 + m * 0.6)).max(0.0));
270        let depth = pulse_depth.value().clamp(0.0, 1.0) as f64;
271        let pulse = pulse_sine(t, bpm.value() as f64);
272        let life = life_mod.value().clamp(0.0, 1.0) as f64;
273        let life_scaled = 0.4 + 0.9 * life;
274        g * (1.0 - depth + depth * pulse) * life_scaled
275    });
276    Net::wrap(Box::new(raw >> follow(0.4) >> split::<U2>()))
277}
278
279// ── Pad ──
280fn pad_zimmer(p: &TrackParams, g: &GlobalParams) -> Net {
281    let cut = p.cutoff.clone();
282    let res_s = p.resonance.clone();
283    let det = p.detune.clone();
284
285    let lb = LfoBundle::from_params(p);
286    let f0 = p.freq.clone();
287    let f1 = p.freq.clone();
288    let f2 = p.freq.clone();
289    let f3 = p.freq.clone();
290    let d1 = det.clone();
291    let d2 = det.clone();
292    let (lb0, lb1, lb2, lb3, lb_c) = (
293        lb.clone(),
294        lb.clone(),
295        lb.clone(),
296        lb.clone(),
297        lb.clone(),
298    );
299
300    let osc = ((lfo(move |t: f64| {
301            let b = f0.value() as f64;
302            lb0.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
303        }) >> (sine() * 0.30))
304        + (lfo(move |t: f64| {
305            let b = f1.value() as f64 * 1.501 * (1.0 + d1.value() as f64 * 0.000578);
306            lb1.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
307        }) >> (sine() * 0.20))
308        + (lfo(move |t: f64| {
309            let b = f2.value() as f64 * 2.013 * (1.0 + d2.value() as f64 * 0.000578);
310            lb2.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
311        }) >> (sine() * 0.14))
312        + (lfo(move |t: f64| {
313            let b = f3.value() as f64 * 3.007;
314            lb3.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
315        }) >> (sine() * 0.08)))
316        * 0.9;
317
318    let cutoff_mod = lfo(move |t: f64| {
319        let wobble = 1.0 + 0.10 * (0.5 - 0.5 * (t * 0.08).sin());
320        let base = cut.value() as f64 * wobble;
321        lb_c.apply(base, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
322    }) >> follow(0.08);
323    // Hard cap at 0.65: above that the Moog self-oscillates into a
324    // sustained whistle at cutoff. We'd rather lose a tiny bit of range
325    // at the top than let auto-evolve park a track in squeal territory.
326    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.08);
327
328    // Tame pad whistle: fixed −3.5 dB shelf at 3 kHz before the reverb.
329    // This kills the resonance that builds between detuned partials
330    // × 3.007 and moog filter peak — the whistle user reported.
331    let filtered = (osc | cutoff_mod | res_mod) >> moog()
332        >> highshelf_hz(3000.0, 0.7, 0.67);
333
334    let stereo = filtered
335        >> split::<U2>()
336        >> (chorus(0, 0.0, 0.015, 0.35) | chorus(1, 0.0, 0.020, 0.35))
337        >> reverb_stereo(18.0, 4.0, 0.9);
338
339    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
340    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
341    voiced
342        * stereo_gate_voiced(
343            p.gain.clone(),
344            p.mute.clone(),
345            p.pulse_depth.clone(),
346            g.bpm.clone(),
347            p.life_mod.clone(),
348            lb,
349        )
350}
351
352// ── Drone ──
353fn drone_sub(p: &TrackParams, g: &GlobalParams) -> Net {
354    let lb = LfoBundle::from_params(p);
355    let cut = p.cutoff.clone();
356    let res_s = p.resonance.clone();
357
358    let f0 = p.freq.clone();
359    let f1 = p.freq.clone();
360    let (lb0, lb1, lb_c) = (lb.clone(), lb.clone(), lb.clone());
361
362    let sub = (lfo(move |t: f64| {
363            let b = f0.value() as f64 * 0.5;
364            lb0.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
365        }) >> (sine() * 0.45))
366        + (lfo(move |t: f64| {
367            let b = f1.value() as f64;
368            lb1.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
369        }) >> (sine() * 0.12));
370
371    let noise_cut = lfo(move |t: f64| {
372        let b = cut.value().clamp(40.0, 300.0) as f64;
373        lb_c.apply(b, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
374    }) >> follow(0.08);
375    let noise_q = lfo(move |_t: f64| res_s.value() as f64) >> follow(0.08);
376    let noise = (brown() | noise_cut | noise_q) >> moog();
377    let noise_body = noise * 0.28;
378
379    let bpm_am = g.bpm.clone();
380    let am = lfo(move |t: f64| 0.88 + 0.12 * pulse_sine(t, bpm_am.value() as f64));
381    let body = (sub + noise_body) * am;
382
383    let stereo = body >> split::<U2>() >> reverb_stereo(20.0, 5.0, 0.85);
384
385    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
386    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
387    voiced
388        * stereo_gate_voiced(
389            p.gain.clone(),
390            p.mute.clone(),
391            p.pulse_depth.clone(),
392            g.bpm.clone(),
393            p.life_mod.clone(),
394            lb,
395        )
396}
397
398// ── Shimmer ──
399fn shimmer(p: &TrackParams, g: &GlobalParams) -> Net {
400    let lb = LfoBundle::from_params(p);
401    let f0 = p.freq.clone();
402    let f1 = p.freq.clone();
403    let f2 = p.freq.clone();
404    let (lb0, lb1, lb2) = (lb.clone(), lb.clone(), lb.clone());
405
406    let osc = (lfo(move |t: f64| {
407            let b = f0.value() as f64 * 2.0;
408            lb0.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
409        }) >> (sine() * 0.18))
410        + (lfo(move |t: f64| {
411            let b = f1.value() as f64 * 3.0;
412            lb1.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
413        }) >> (sine() * 0.12))
414        + (lfo(move |t: f64| {
415            let b = f2.value() as f64 * 4.007;
416            lb2.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
417        }) >> (sine() * 0.08));
418
419    let bright = osc >> highpass_hz(400.0, 0.5);
420    let stereo = bright >> split::<U2>() >> reverb_stereo(22.0, 6.0, 0.85);
421
422    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
423    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
424    voiced
425        * stereo_gate_voiced(
426            p.gain.clone(),
427            p.mute.clone(),
428            p.pulse_depth.clone(),
429            g.bpm.clone(),
430            p.life_mod.clone(),
431            lb,
432        )
433}
434
435// ── Heartbeat: 3-layer kick drum with Euclidean 16-step pattern ──
436// Every layer fires only on active pattern steps (step resolution = 4
437// per beat). Envelopes are step-length (~1/4 beat). Pattern bitmask is
438// read with an atomic Relaxed load — lock-free, ~1 ns per sample.
439fn heartbeat(p: &TrackParams, g: &GlobalParams) -> Net {
440    let bpm = g.bpm.clone();
441
442    // Body — pitch-swept sine (pitch drop happens only within active steps).
443    let bpm_body_f = bpm.clone();
444    let freq_body = p.freq.clone();
445    let pat_body_f = p.pattern_bits.clone();
446    let body_osc = lfo(move |t: f64| {
447        let bpm_v = bpm_body_f.value() as f64;
448        let bits = pat_body_f.load(Ordering::Relaxed);
449        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
450        let base = freq_body.value() as f64;
451        if active {
452            let drop = (-phi * 40.0).exp();
453            base * (0.7 + 1.5 * drop)
454        } else {
455            // No hit — hold the osc at its base so there is no phase
456            // pop when the next step arrives.
457            base
458        }
459    }) >> sine();
460
461    let bpm_body_e = bpm.clone();
462    let pat_body_e = p.pattern_bits.clone();
463    let body_env = lfo(move |t: f64| {
464        let bpm_v = bpm_body_e.value() as f64;
465        let bits = pat_body_e.load(Ordering::Relaxed);
466        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
467        if active {
468            (-phi * 4.0).exp()
469        } else {
470            0.0
471        }
472    });
473    let body = body_osc * body_env * 0.85;
474
475    // Sub — low sine, slower decay bleeds across the step boundary.
476    let freq_sub = p.freq.clone();
477    let sub_osc = lfo(move |_t: f64| freq_sub.value() as f64 * 0.5) >> sine();
478    let bpm_sub_e = bpm.clone();
479    let pat_sub = p.pattern_bits.clone();
480    let sub_env = lfo(move |t: f64| {
481        let bpm_v = bpm_sub_e.value() as f64;
482        let bits = pat_sub.load(Ordering::Relaxed);
483        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
484        if active {
485            (-phi * 1.5).exp()
486        } else {
487            0.0
488        }
489    });
490    let sub = sub_osc * sub_env * 0.45;
491
492    // Click — very short burst on every active step.
493    let bpm_click = bpm.clone();
494    let pat_click = p.pattern_bits.clone();
495    let click_env = lfo(move |t: f64| {
496        let bpm_v = bpm_click.value() as f64;
497        let bits = pat_click.load(Ordering::Relaxed);
498        let (active, phi) = rhythm::step_is_active(bits, t, bpm_v);
499        if active {
500            (-phi * 40.0).exp()
501        } else {
502            0.0
503        }
504    });
505    let click = (brown() >> highpass_hz(1800.0, 0.5)) * click_env * 0.12;
506
507    let kick = body + sub + click;
508
509    let stereo = kick >> split::<U2>() >> reverb_stereo(10.0, 1.5, 0.88);
510
511    let lb = LfoBundle::from_params(p);
512    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
513    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
514    voiced
515        * stereo_gate_voiced(
516            p.gain.clone(),
517            p.mute.clone(),
518            p.pulse_depth.clone(),
519            g.bpm.clone(),
520            p.life_mod.clone(),
521            lb,
522        )
523}
524
525// ── BassPulse: sustained bass line with BPM groove ──
526// Fundamental + 2nd harmonic + sub, Moog-lowpassed; groove envelope
527// pumps amplitude on every beat so the bass pulses instead of droning.
528fn bass_pulse(p: &TrackParams, g: &GlobalParams) -> Net {
529    let lb = LfoBundle::from_params(p);
530    let f1 = p.freq.clone();
531    let f2 = p.freq.clone();
532    let f3 = p.freq.clone();
533    let cut = p.cutoff.clone();
534    let res_s = p.resonance.clone();
535    let (lb1, lb2, lb3, lb_c) = (lb.clone(), lb.clone(), lb.clone(), lb.clone());
536
537    let fundamental = lfo(move |t: f64| {
538        let b = f1.value() as f64;
539        lb1.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
540    }) >> (sine() * 0.55);
541    let second = lfo(move |t: f64| {
542        let b = f2.value() as f64 * 2.0;
543        lb2.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
544    }) >> (sine() * 0.22);
545    let sub = lfo(move |t: f64| {
546        let b = f3.value() as f64 * 0.5;
547        lb3.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
548    }) >> (sine() * 0.35);
549    let osc = fundamental + second + sub;
550
551    let cut_mod = lfo(move |t: f64| {
552        let b = cut.value().min(900.0) as f64;
553        lb_c.apply(b, LFO_CUTOFF, t, |b, m| b * 2.0_f64.powf(m))
554    }) >> follow(0.08);
555    let res_mod = lfo(move |_t: f64| res_s.value().min(0.65) as f64) >> follow(0.08);
556    let filtered = (osc | cut_mod | res_mod) >> moog();
557
558    let bpm_groove = g.bpm.clone();
559    let groove = lfo(move |t: f64| {
560        let pump = pulse_decay(t, bpm_groove.value() as f64, 3.5);
561        0.45 + 0.55 * pump
562    });
563    let grooved = filtered * groove;
564
565    let stereo = grooved >> split::<U2>() >> reverb_stereo(14.0, 2.5, 0.88);
566
567    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
568    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
569    voiced
570        * stereo_gate_voiced(
571            p.gain.clone(),
572            p.mute.clone(),
573            p.pulse_depth.clone(),
574            g.bpm.clone(),
575            p.life_mod.clone(),
576            lb,
577        )
578}
579
580// ── Bell: two-operator FM tone (inharmonic ratio 2.76) ──
581// Modulator at freq·2.76 with depth = resonance·450 Hz frequency
582// modulates the carrier at freq. Dial `resonance` for metallic shimmer.
583// Named `bell_preset` to avoid collision with fundsp's `bell()` filter.
584fn bell_preset(p: &TrackParams, g: &GlobalParams) -> Net {
585    let lb = LfoBundle::from_params(p);
586    let fc = p.freq.clone();
587    let fm = p.freq.clone();
588    let fm_depth = p.resonance.clone();
589    let (lb_c, lb_m) = (lb.clone(), lb.clone());
590
591    let modulator_freq = lfo(move |t: f64| {
592        let b = fm.value() as f64 * 2.76;
593        lb_m.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
594    });
595    let modulator = modulator_freq >> sine();
596    let mod_scale = lfo(move |_t: f64| fm_depth.value().min(0.65) as f64 * 450.0);
597    let modulator_scaled = modulator * mod_scale;
598
599    let carrier_base = lfo(move |t: f64| {
600        let b = fc.value() as f64;
601        lb_c.apply(b, LFO_FREQ, t, |b, m| b * 2.0_f64.powf(m / 12.0))
602    });
603    let bell_sig = (carrier_base + modulator_scaled) >> sine();
604
605    let bpm_am = g.bpm.clone();
606    let am = lfo(move |t: f64| 0.85 + 0.15 * pulse_sine(t, bpm_am.value() as f64 * 0.25));
607    let body = bell_sig * am * 0.30;
608
609    let stereo = body >> split::<U2>() >> reverb_stereo(25.0, 8.0, 0.85);
610
611    let with_super = Net::wrap(Box::new(stereo)) >> supermass_send(p.supermass.clone());
612    let voiced = with_super * stereo_reverb_mix(p.reverb_mix.clone(), lb.clone());
613    voiced
614        * stereo_gate_voiced(
615            p.gain.clone(),
616            p.mute.clone(),
617            p.pulse_depth.clone(),
618            g.bpm.clone(),
619            p.life_mod.clone(),
620            lb,
621        )
622}