Skip to main content

phosphor_core/
metronome.rs

1//! Metronome — click track that follows the transport BPM.
2//!
3//! Generates short percussive pops. Beat 1 of each bar is a higher-pitched
4//! pop, other beats are lower. Sounds similar to an MPC 2000xl click.
5//!
6//! The metronome is mixed directly into the master output by the mixer.
7
8use crate::transport::Transport;
9
10/// Metronome click generator. Runs on the audio thread.
11pub struct Metronome {
12    sample_rate: f64,
13    click_phase: f64,
14    is_downbeat: bool,
15    clicking: bool,
16    /// Last beat index we triggered on (to avoid double-triggering).
17    last_beat: i64,
18}
19
20/// Duration of a click in seconds. Short pop.
21const CLICK_DURATION: f64 = 0.012;
22/// Volume of the click.
23///
24/// Tracks the instruments' headroom trims, so the click sits where it always
25/// did relative to the music. Those trims are around 0.18 on the output
26/// stage; at the original 0.35 the click would peak near 0.59, several times
27/// a chord, and playing along to it would be unpleasant at best.
28///
29/// This has to move whenever the trims do — it is not mixed through a track
30/// and has no fader of its own, so nothing else can compensate for it. See
31/// `OUTPUT_TRIM` in phosphor-dsp's dx7.rs.
32const 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    /// Generate metronome audio for one buffer and mix it into the output.
46    /// `output` is interleaved stereo [L, R, L, R, ...].
47    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; // 4/4 time
54        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            // Which beat are we on? (0-based within the bar)
63            let beat_in_bar = (frame_tick % ticks_per_bar) / ppq;
64            // Absolute beat number (monotonic)
65            let abs_beat = frame_tick / ppq;
66
67            // Trigger a new click when we cross a beat boundary
68            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            // Generate click sound
76            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    /// Generate one sample of the click sound.
94    /// MPC 2000xl style: short band-passed noise burst with fast exponential decay.
95    /// Downbeat is higher pitched and slightly louder.
96    fn generate_click(&self, t: f64) -> f32 {
97        let decay = (-t * 500.0).exp(); // fast exponential decay
98
99        let (freq, volume) = if self.is_downbeat {
100            (1800.0, CLICK_VOLUME * 1.3) // higher, louder pop for beat 1
101        } else {
102            (1200.0, CLICK_VOLUME) // lower pop for other beats
103        };
104
105        // Sine burst with noise — gives that percussive "pop" character
106        let sine = (t * freq * std::f64::consts::TAU).sin();
107        // Add a bit of filtered noise for texture
108        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    /// Reset state (e.g., on transport stop).
114    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        // metronome is off by default
131        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        // NOT playing
154        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        // The generate_click function uses different freq/volume for downbeat vs regular
177        // Test by calling the underlying math directly
178        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}