Skip to main content

rust_synth/math/
pulse.rs

1//! Tempo-synced pulse envelopes.
2//!
3//! `f64` throughout — critical for long-running playback, because FunDSP's
4//! `hacker` (f64) internal time counter advances by ~2e-5 per sample at
5//! 48 kHz, which f32 can't represent past ~5 minutes. Keeping these
6//! functions in f64 guarantees stable phase for hours.
7
8#[inline]
9pub fn beat_phase(t: f64, bpm: f64) -> f64 {
10    let period = 60.0 / bpm.max(1.0);
11    t.rem_euclid(period) / period
12}
13
14#[inline]
15pub fn pulse_decay(t: f64, bpm: f64, decay: f64) -> f64 {
16    (-beat_phase(t, bpm) * decay).exp()
17}
18
19#[inline]
20pub fn pulse_sine(t: f64, bpm: f64) -> f64 {
21    0.5 - 0.5 * (std::f64::consts::TAU * beat_phase(t, bpm)).cos()
22}
23
24#[inline]
25pub fn phrase_phase(t: f64, bpm: f64, beats: f64) -> f64 {
26    let period = beats * 60.0 / bpm.max(1.0);
27    t.rem_euclid(period) / period
28}
29
30/// Deterministic pentatonic arpeggiator step in semitones.
31///
32/// Every `beats_per_step` beats the pitch jumps to a new scale note
33/// drawn pseudo-randomly from the major pentatonic [0, 2, 4, 7, 9]
34/// keyed on `seed + step_index`. `depth` in [0, 1] scales the result —
35/// 0 returns 0, 1 returns the full chosen semitone offset, so you can
36/// dial from static pitch to full melodic range without a click.
37///
38/// Combine with a `follow(0.08)` on the freq control to glide between
39/// steps (portamento) instead of stepping discretely.
40/// Scale mode for the arpeggiator. Higher numbers = more exotic.
41///   0 Major pent  [0, 2, 4, 7, 9]    — optimistic, default
42///   1 Minor pent  [0, 3, 5, 7, 10]   — melancholic, Blade-Runner-ish
43///   2 Bhairavi    [0, 1, 4, 5, 7]    — raga, exotic
44pub const SCALE_MAJOR_PENT: [f64; 5] = [0.0, 2.0, 4.0, 7.0, 9.0];
45pub const SCALE_MINOR_PENT: [f64; 5] = [0.0, 3.0, 5.0, 7.0, 10.0];
46pub const SCALE_BHAIRAVI: [f64; 5] = [0.0, 1.0, 4.0, 5.0, 7.0];
47
48#[inline]
49pub fn scale_for(mode: u32) -> [f64; 5] {
50    match mode {
51        1 => SCALE_MINOR_PENT,
52        2 => SCALE_BHAIRAVI,
53        _ => SCALE_MAJOR_PENT,
54    }
55}
56
57#[inline]
58pub fn arp_offset_semitones(t: f64, bpm: f64, depth: f64, seed: u64, scale_mode: u32) -> f64 {
59    let d = depth.clamp(0.0, 1.0);
60    if d < 1.0e-4 {
61        return 0.0;
62    }
63    let beats_per_step = 2.0;
64    let step = (t * bpm.max(1.0) / 60.0 / beats_per_step) as u64;
65    let scale = scale_for(scale_mode);
66    let mut h = seed ^ step.wrapping_mul(0x9E37_79B9_7F4A_7C15);
67    h ^= h >> 30;
68    h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
69    h ^= h >> 27;
70    let idx = (h >> 32) as usize % scale.len();
71    scale[idx] * d
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn beat_phase_at_zero() {
80        assert!(beat_phase(0.0, 120.0).abs() < 1e-12);
81    }
82
83    #[test]
84    fn pulse_decay_is_one_on_beat() {
85        let v = pulse_decay(0.0, 90.0, 8.0);
86        assert!((v - 1.0).abs() < 1e-12);
87    }
88
89    #[test]
90    fn pulse_decay_falls_within_beat() {
91        let beat = 60.0 / 90.0;
92        let start = pulse_decay(0.0, 90.0, 8.0);
93        let later = pulse_decay(beat * 0.5, 90.0, 8.0);
94        assert!(later < start);
95    }
96
97    #[test]
98    fn phase_stable_at_hour() {
99        // The whole reason we are in f64: phases must stay precise even
100        // after 3600 s (> 170 million samples at 48 kHz).
101        let t = 3600.0;
102        let p = beat_phase(t, 72.0);
103        assert!((0.0..1.0).contains(&p));
104    }
105}