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