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_INTERVAL_MS: u32 = 300;
9const MIN_BPM: u16 = 30;
10const MAX_BPM: u16 = 200;
11
12/// Tap tempo state. Tracks timestamps of recent taps and computes BPM.
13#[derive(Debug, Clone)]
14pub struct TapTempo {
15    /// Timestamps (ms) of the last N taps.
16    taps: [u32; MAX_TAPS],
17    /// Number of taps recorded (0 = idle).
18    count: u8,
19}
20
21impl Default for TapTempo {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl TapTempo {
28    pub fn new() -> Self {
29        Self {
30            taps: [0; MAX_TAPS],
31            count: 0,
32        }
33    }
34
35    /// Record a tap at `now_ms`. Returns computed BPM if enough taps (≥4).
36    /// Resets if more than 2 seconds since last tap.
37    pub fn tap(&mut self, now_ms: u32) -> Option<u16> {
38        // Reset if timed out since last tap
39        if self.count > 0 {
40            let last_idx = (self.count - 1).min(MAX_TAPS as u8 - 1) as usize;
41            let last = self.taps[last_idx];
42            let elapsed = now_ms.wrapping_sub(last);
43            if elapsed > TIMEOUT_MS {
44                self.count = 0;
45            } else if elapsed < MIN_INTERVAL_MS {
46                // Ignore bounce / double-tap (too fast to be a real tap)
47                return None;
48            }
49        }
50
51        // Store this tap
52        if (self.count as usize) < MAX_TAPS {
53            self.taps[self.count as usize] = now_ms;
54            self.count += 1;
55        } else {
56            // Shift left and append
57            self.taps.rotate_left(1);
58            self.taps[MAX_TAPS - 1] = now_ms;
59        }
60
61        // Need full window to compute stable BPM
62        if (self.count as usize) < MAX_TAPS {
63            return None;
64        }
65
66        // Average all intervals
67        let n = self.count as usize;
68        let total_interval = self.taps[n - 1].wrapping_sub(self.taps[0]);
69        let avg_interval = total_interval / (n as u32 - 1);
70
71        if avg_interval == 0 {
72            return None;
73        }
74
75        // BPM = 60000 / interval_ms
76        let bpm = (60_000 / avg_interval) as u16;
77        let bpm = bpm.clamp(MIN_BPM, MAX_BPM);
78
79        Some(bpm)
80    }
81
82    /// Reset tap state (e.g., on preset switch).
83    pub fn reset(&mut self) {
84        self.count = 0;
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn first_tap_returns_none() {
94        let mut tt = TapTempo::new();
95        assert_eq!(tt.tap(0), None);
96    }
97
98    #[test]
99    fn three_taps_returns_none() {
100        let mut tt = TapTempo::new();
101        tt.tap(0);
102        assert_eq!(tt.tap(500), None);
103        assert_eq!(tt.tap(1000), None);
104    }
105
106    #[test]
107    fn four_taps_at_120bpm() {
108        let mut tt = TapTempo::new();
109        tt.tap(0);
110        tt.tap(500);
111        tt.tap(1000);
112        // Fourth tap: total span = 1500ms, 3 intervals, avg = 500ms = 120 BPM
113        assert_eq!(tt.tap(1500), Some(120));
114    }
115
116    #[test]
117    fn uneven_taps_averages() {
118        let mut tt = TapTempo::new();
119        tt.tap(0);
120        tt.tap(400);
121        tt.tap(900);
122        // 4th tap at 1400: span = 1400ms, 3 intervals, avg = 466ms = 128 BPM
123        assert_eq!(tt.tap(1400), Some(128));
124    }
125
126    #[test]
127    fn timeout_resets() {
128        let mut tt = TapTempo::new();
129        tt.tap(0);
130        tt.tap(500);
131        tt.tap(1000);
132        tt.tap(1500); // stable at 120
133                      // 3 seconds later — should reset
134        assert_eq!(tt.tap(5500), None); // first tap after reset
135        assert_eq!(tt.tap(6000), None);
136        assert_eq!(tt.tap(6500), None);
137        assert_eq!(tt.tap(7000), Some(120)); // fourth tap after reset
138    }
139
140    #[test]
141    fn clamps_at_max_bpm() {
142        let mut tt = TapTempo::new();
143        tt.tap(0);
144        tt.tap(300);
145        tt.tap(600);
146        // 300ms interval = 200 BPM → clamped to 200
147        assert_eq!(tt.tap(900), Some(200));
148    }
149
150    #[test]
151    fn clamps_at_min_bpm() {
152        let mut tt = TapTempo::new();
153        tt.tap(0);
154        tt.tap(1900);
155        tt.tap(3800);
156        // ~1900ms intervals = 31 BPM → clamped to 30
157        assert_eq!(tt.tap(5700), Some(31));
158    }
159
160    #[test]
161    fn more_than_max_taps_shifts_window() {
162        let mut tt = TapTempo::new();
163        tt.tap(0);
164        tt.tap(500);
165        tt.tap(1000);
166        tt.tap(1500);
167        // 5th tap shifts window: [500, 1000, 1500, 2000], avg = 500ms
168        assert_eq!(tt.tap(2000), Some(120));
169    }
170
171    #[test]
172    fn reset_clears_state() {
173        let mut tt = TapTempo::new();
174        tt.tap(0);
175        tt.tap(500);
176        tt.tap(1000);
177        tt.tap(1500);
178        tt.reset();
179        assert_eq!(tt.tap(2000), None); // first tap after reset
180    }
181
182    #[test]
183    fn ignores_bounce_tap() {
184        let mut tt = TapTempo::new();
185        tt.tap(0);
186        // Bounce at 50ms — should be ignored
187        assert_eq!(tt.tap(50), None);
188        tt.tap(500);
189        tt.tap(1000);
190        // Fourth real tap at 1500ms
191        assert_eq!(tt.tap(1500), Some(120));
192    }
193
194    #[test]
195    fn ignores_bounce_mid_sequence() {
196        let mut tt = TapTempo::new();
197        tt.tap(0);
198        tt.tap(500);
199        tt.tap(1000);
200        // Bounce at 1050ms — ignored
201        assert_eq!(tt.tap(1050), None);
202        // Real 4th tap at 1500ms
203        assert_eq!(tt.tap(1500), Some(120));
204    }
205}