Skip to main content

midi_controller/
tap_tempo.rs

1//! Tap tempo state machine: computes BPM from press intervals.
2//!
3//! Call `tap(now_ms)` on each button press. Returns `Some(bpm)` once enough
4//! taps are received. Resets automatically after a 2-second idle timeout.
5
6const MAX_TAPS: usize = 4;
7const TIMEOUT_MS: u32 = 2000;
8const MIN_BPM: u16 = 30;
9const MAX_BPM: u16 = 300;
10
11/// Tap tempo state. Tracks timestamps of recent taps and computes BPM.
12#[derive(Debug, Clone)]
13pub struct TapTempo {
14    /// Timestamps (ms) of the last N taps.
15    taps: [u32; MAX_TAPS],
16    /// Number of taps recorded (0 = idle).
17    count: u8,
18}
19
20impl Default for TapTempo {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl TapTempo {
27    pub fn new() -> Self {
28        Self {
29            taps: [0; MAX_TAPS],
30            count: 0,
31        }
32    }
33
34    /// Record a tap at `now_ms`. Returns computed BPM if enough taps (≥2).
35    /// Resets if more than 2 seconds since last tap.
36    pub fn tap(&mut self, now_ms: u32) -> Option<u16> {
37        // Reset if timed out since last tap
38        if self.count > 0 {
39            let last = self.taps[(self.count - 1) as usize];
40            if now_ms.wrapping_sub(last) > TIMEOUT_MS {
41                self.count = 0;
42            }
43        }
44
45        // Store this tap
46        if (self.count as usize) < MAX_TAPS {
47            self.taps[self.count as usize] = now_ms;
48            self.count += 1;
49        } else {
50            // Shift left and append
51            self.taps.rotate_left(1);
52            self.taps[MAX_TAPS - 1] = now_ms;
53        }
54
55        // Need at least 2 taps to compute an interval
56        if self.count < 2 {
57            return None;
58        }
59
60        // Average all intervals
61        let n = self.count as usize;
62        let total_interval = self.taps[n - 1].wrapping_sub(self.taps[0]);
63        let avg_interval = total_interval / (n as u32 - 1);
64
65        if avg_interval == 0 {
66            return None;
67        }
68
69        // BPM = 60000 / interval_ms
70        let bpm = (60_000 / avg_interval) as u16;
71        let bpm = bpm.clamp(MIN_BPM, MAX_BPM);
72
73        Some(bpm)
74    }
75
76    /// Reset tap state (e.g., on preset switch).
77    pub fn reset(&mut self) {
78        self.count = 0;
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn first_tap_returns_none() {
88        let mut tt = TapTempo::new();
89        assert_eq!(tt.tap(0), None);
90    }
91
92    #[test]
93    fn two_taps_at_120bpm() {
94        let mut tt = TapTempo::new();
95        tt.tap(0);
96        // 500ms interval = 120 BPM
97        assert_eq!(tt.tap(500), Some(120));
98    }
99
100    #[test]
101    fn four_taps_averages_intervals() {
102        let mut tt = TapTempo::new();
103        tt.tap(0);
104        tt.tap(500); // 120 BPM
105        tt.tap(1000); // 120 BPM
106                      // Fourth tap: total span = 1500ms, 3 intervals, avg = 500ms = 120 BPM
107        assert_eq!(tt.tap(1500), Some(120));
108    }
109
110    #[test]
111    fn uneven_taps_averages() {
112        let mut tt = TapTempo::new();
113        tt.tap(0);
114        tt.tap(400);
115        // 3rd tap at 900: span = 900ms, 2 intervals, avg = 450ms = 133 BPM
116        assert_eq!(tt.tap(900), Some(133));
117    }
118
119    #[test]
120    fn timeout_resets() {
121        let mut tt = TapTempo::new();
122        tt.tap(0);
123        tt.tap(500); // 120 BPM
124                     // 3 seconds later — should reset
125        assert_eq!(tt.tap(5500), None); // first tap after reset
126        assert_eq!(tt.tap(6000), Some(120)); // second tap, 500ms interval
127    }
128
129    #[test]
130    fn clamps_at_max_bpm() {
131        let mut tt = TapTempo::new();
132        tt.tap(0);
133        // 100ms interval = 600 BPM → clamped to 300
134        assert_eq!(tt.tap(100), Some(300));
135    }
136
137    #[test]
138    fn clamps_at_min_bpm() {
139        let mut tt = TapTempo::new();
140        tt.tap(0);
141        // 1999ms interval (just under timeout) = 30 BPM
142        assert_eq!(tt.tap(1999), Some(30));
143    }
144
145    #[test]
146    fn more_than_max_taps_shifts_window() {
147        let mut tt = TapTempo::new();
148        tt.tap(0);
149        tt.tap(500);
150        tt.tap(1000);
151        tt.tap(1500);
152        // 5th tap shifts window: [500, 1000, 1500, 2000], avg = 500ms
153        assert_eq!(tt.tap(2000), Some(120));
154    }
155
156    #[test]
157    fn reset_clears_state() {
158        let mut tt = TapTempo::new();
159        tt.tap(0);
160        tt.tap(500);
161        tt.reset();
162        assert_eq!(tt.tap(1000), None); // first tap after reset
163    }
164}