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