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#[cfg(test)]
31mod tests {
32    use super::*;
33
34    #[test]
35    fn beat_phase_at_zero() {
36        assert!(beat_phase(0.0, 120.0).abs() < 1e-12);
37    }
38
39    #[test]
40    fn pulse_decay_is_one_on_beat() {
41        let v = pulse_decay(0.0, 90.0, 8.0);
42        assert!((v - 1.0).abs() < 1e-12);
43    }
44
45    #[test]
46    fn pulse_decay_falls_within_beat() {
47        let beat = 60.0 / 90.0;
48        let start = pulse_decay(0.0, 90.0, 8.0);
49        let later = pulse_decay(beat * 0.5, 90.0, 8.0);
50        assert!(later < start);
51    }
52
53    #[test]
54    fn phase_stable_at_hour() {
55        // The whole reason we are in f64: phases must stay precise even
56        // after 3600 s (> 170 million samples at 48 kHz).
57        let t = 3600.0;
58        let p = beat_phase(t, 72.0);
59        assert!((0.0..1.0).contains(&p));
60    }
61}