phosphor_core/
metronome.rs1use crate::transport::Transport;
9
10pub struct Metronome {
12 sample_rate: f64,
13 click_phase: f64,
14 is_downbeat: bool,
15 clicking: bool,
16 last_beat: i64,
18}
19
20const CLICK_DURATION: f64 = 0.012;
22const CLICK_VOLUME: f32 = 0.0634;
33
34impl Metronome {
35 pub fn new(sample_rate: f64) -> Self {
36 Self {
37 sample_rate,
38 click_phase: 0.0,
39 is_downbeat: false,
40 clicking: false,
41 last_beat: -1,
42 }
43 }
44
45 pub fn process(&mut self, output: &mut [f32], transport: &Transport) {
48 if !transport.is_metronome_on() || !transport.is_playing() {
49 return;
50 }
51
52 let ppq = Transport::PPQ;
53 let ticks_per_bar = ppq * 4; let current_tick = transport.position_ticks();
55 let bpm = transport.tempo_bpm();
56 let ticks_per_sample = (bpm * ppq as f64) / (60.0 * self.sample_rate);
57 let num_frames = output.len() / 2;
58
59 for i in 0..num_frames {
60 let frame_tick = current_tick + (i as f64 * ticks_per_sample) as i64;
61
62 let beat_in_bar = (frame_tick % ticks_per_bar) / ppq;
64 let abs_beat = frame_tick / ppq;
66
67 if abs_beat != self.last_beat && frame_tick >= 0 {
69 self.last_beat = abs_beat;
70 self.clicking = true;
71 self.click_phase = 0.0;
72 self.is_downbeat = beat_in_bar == 0;
73 }
74
75 if self.clicking {
77 let t = self.click_phase / self.sample_rate;
78
79 if t > CLICK_DURATION {
80 self.clicking = false;
81 } else {
82 let sample = self.generate_click(t);
83 let idx = i * 2;
84 output[idx] += sample;
85 output[idx + 1] += sample;
86 }
87
88 self.click_phase += 1.0;
89 }
90 }
91 }
92
93 fn generate_click(&self, t: f64) -> f32 {
97 let decay = (-t * 500.0).exp(); let (freq, volume) = if self.is_downbeat {
100 (1800.0, CLICK_VOLUME * 1.3) } else {
102 (1200.0, CLICK_VOLUME) };
104
105 let sine = (t * freq * std::f64::consts::TAU).sin();
107 let noise = ((t * 7919.0).sin() * (t * 3571.0).cos()) * 0.3;
109
110 ((sine + noise) * decay * volume as f64) as f32
111 }
112
113 pub fn reset(&mut self) {
115 self.clicking = false;
116 self.click_phase = 0.0;
117 self.last_beat = -1;
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use std::sync::Arc;
125
126 #[test]
127 fn metronome_silent_when_off() {
128 let transport = Arc::new(Transport::new(120.0));
129 transport.play();
130 let mut met = Metronome::new(44100.0);
132 let mut output = vec![0.0f32; 512];
133 met.process(&mut output, &transport);
134 assert!(output.iter().all(|&s| s == 0.0));
135 }
136
137 #[test]
138 fn metronome_produces_sound_when_on() {
139 let transport = Arc::new(Transport::new(120.0));
140 transport.play();
141 transport.toggle_metronome();
142 let mut met = Metronome::new(44100.0);
143 let mut output = vec![0.0f32; 512];
144 met.process(&mut output, &transport);
145 let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
146 assert!(peak > 0.01, "Metronome should produce sound, peak={peak}");
147 }
148
149 #[test]
150 fn metronome_silent_when_not_playing() {
151 let transport = Arc::new(Transport::new(120.0));
152 transport.toggle_metronome();
153 let mut met = Metronome::new(44100.0);
155 let mut output = vec![0.0f32; 512];
156 met.process(&mut output, &transport);
157 assert!(output.iter().all(|&s| s == 0.0));
158 }
159
160 #[test]
161 fn metronome_output_is_finite() {
162 let transport = Arc::new(Transport::new(120.0));
163 transport.play();
164 transport.toggle_metronome();
165 let mut met = Metronome::new(44100.0);
166 for _ in 0..1000 {
167 let mut output = vec![0.0f32; 512];
168 met.process(&mut output, &transport);
169 assert!(output.iter().all(|s| s.is_finite()), "Output must be finite");
170 transport.advance(256, 44100);
171 }
172 }
173
174 #[test]
175 fn click_sounds_differ_by_beat_type() {
176 let t: f64 = 0.002;
179 let decay = (-t * 500.0_f64).exp();
180 let sine_down = (t * 1800.0 * std::f64::consts::TAU).sin();
181 let sine_reg = (t * 1200.0 * std::f64::consts::TAU).sin();
182 let down_sample = sine_down * decay * 0.35 * 1.3;
183 let reg_sample = sine_reg * decay * 0.35;
184 assert!((down_sample - reg_sample).abs() > 0.01,
185 "Downbeat and regular click should differ: down={down_sample} reg={reg_sample}");
186 }
187}