use crate::*; pub struct Metronome { // Audio playback click_samples: Arc, click_volume: f32, // Timing state frames_per_beat: usize, frames_since_last_beat: usize, // Where we are in the current beat cycle last_frame_time: Option, // For xrun detection } #[derive(Debug, Clone, PartialEq)] pub struct BufferTiming { /// Beat index within the current buffer (if any) pub beat_in_buffer: Option, /// Number of frames missed due to xrun (0 if no xrun) pub missed_frames: usize, /// Beat index within the missed frames (if any) pub beat_in_missed: Option, } #[derive(Debug, Clone, PartialEq)] pub struct PpqnTick { /// Sample offset within the buffer where this tick occurs pub sample_offset: usize, /// PPQN tick index (0-23) pub tick_index: usize, /// Position within the beat (0.0 - 1.0) pub position: f32, } impl Metronome { pub fn new(click_samples: Arc, state: &State) -> Self { Self { click_samples, click_volume: state.metronome.click_volume, frames_per_beat: state.metronome.frames_per_beat, frames_since_last_beat: 0, last_frame_time: None, } } /// Process audio for current buffer, writing to output slice pub fn process( &mut self, ps: &jack::ProcessScope, ports: &mut JackPorts, osc_controller: &OscController, ) -> Result { let buffer_size = ps.n_frames(); let current_frame_time = ps.last_frame_time(); // Process PPQN ticks for this buffer self.process_ppqn_ticks(ps, ports, osc_controller)?; // Calculate timing for this buffer let timing = self.calculate_timing(current_frame_time, buffer_size)?; // Get output buffer for click track let click_output = ports.click_track_out.as_mut_slice(ps); self.render_click(buffer_size, &timing, click_output); Ok(timing) } fn process_ppqn_ticks( &mut self, ps: &jack::ProcessScope, ports: &mut JackPorts, osc_controller: &OscController, ) -> Result<()> { let buffer_size = ps.n_frames() as usize; // Use the pure function to find tick (allocation-free) if let Some(tick) = Self::find_next_ppqn_tick_in_buffer( self.frames_since_last_beat, self.frames_per_beat, buffer_size, ) { // Send MIDI clock let midi_clock_message = [0xF8]; let mut midi_writer = ports.midi_out.writer(ps); midi_writer .write(&jack::RawMidi { time: tick.sample_offset as u32, bytes: &midi_clock_message, }) .map_err(|_| LooperError::Midi(std::panic::Location::caller()))?; // Send OSC position message osc_controller.metronome_position_changed(tick.position)?; } Ok(()) } fn render_click(&mut self, buffer_size: u32, timing: &BufferTiming, click_output: &mut [f32]) { let click_length = self.click_samples.sample_count; // Calculate our position at the START of this buffer (before calculate_timing updated it) // We need to go back by: buffer_size + any missed samples let total_advancement = buffer_size as usize + timing.missed_frames; let start_position = (self.frames_since_last_beat + self.frames_per_beat - total_advancement) % self.frames_per_beat; if let Some(beat_offset) = timing.beat_in_buffer { let beat_offset = beat_offset as usize; // Check if we're still playing a click from before this beat if start_position < click_length { // Continue click until beat offset let click_samples_remaining = click_length as usize - start_position as usize; let samples_to_write = beat_offset.min(click_samples_remaining); let dest = &mut click_output[0..samples_to_write]; if self .click_samples .copy_samples(dest, start_position as usize) .is_ok() { dest.iter_mut() .for_each(|sample| *sample *= self.click_volume); } // Fill gap between end of click and new beat with silence click_output[samples_to_write..beat_offset].fill(0.0); } else { // Write silence up to beat click_output[0..beat_offset].fill(0.0); } // Start new click at beat offset if beat_offset < buffer_size as usize { let remaining_buffer = buffer_size as usize - beat_offset; let samples_to_write = remaining_buffer.min(click_length as usize); let dest = &mut click_output[beat_offset..beat_offset + samples_to_write]; if self.click_samples.copy_samples(dest, 0).is_ok() { dest.iter_mut() .for_each(|sample| *sample *= self.click_volume); } // Fill any remaining buffer with silence click_output[(beat_offset + samples_to_write)..].fill(0.0); } } else { // No beat in this buffer - check if we're continuing a click if start_position < click_length { let click_offset = start_position as usize; let remaining_click = click_length as usize - click_offset; let samples_to_write = (buffer_size as usize).min(remaining_click); let dest = &mut click_output[0..samples_to_write]; if self.click_samples.copy_samples(dest, click_offset).is_ok() { dest.iter_mut() .for_each(|sample| *sample *= self.click_volume); } // Fill remaining with silence click_output[samples_to_write..].fill(0.0); } else { // No click playing - all silence click_output.fill(0.0); } } } pub fn calculate_timing( &mut self, current_frame_time: u32, buffer_size: u32, ) -> Result { // Detect xrun let (missed_frames, beat_in_missed) = if let Some(last) = self.last_frame_time { let expected = last.wrapping_add(buffer_size); // Handle u32 wrap if current_frame_time != expected { // We have a gap let missed = current_frame_time.wrapping_sub(expected) as usize; // Check if we missed multiple beats let total_samples = self.frames_since_last_beat + missed as usize + buffer_size as usize; if total_samples >= 2 * self.frames_per_beat { return Err(LooperError::Xrun(std::panic::Location::caller())); } // Check if a beat occurred in the missed section let beat_in_missed = if self.frames_since_last_beat + missed as usize >= self.frames_per_beat { Some((self.frames_per_beat - self.frames_since_last_beat) as u32) } else { None }; (missed, beat_in_missed) } else { (0, None) } } else { // First call (0, None) }; // Check for beat in current buffer // We need to account for any missed samples here too let start_position = (self.frames_since_last_beat + missed_frames) % self.frames_per_beat; let beat_in_buffer = if start_position + buffer_size as usize >= self.frames_per_beat { Some((self.frames_per_beat - start_position) as u32) } else { None }; // Update state - advance by total samples (missed + buffer) self.frames_since_last_beat = (self.frames_since_last_beat + missed_frames + buffer_size as usize) % self.frames_per_beat; self.last_frame_time = Some(current_frame_time); Ok(BufferTiming { beat_in_buffer, missed_frames: missed_frames, beat_in_missed, }) } /// Pure function to find the next PPQN tick in a buffer (allocation-free) /// Returns Some(tick) if there's a tick in this buffer, None otherwise pub fn find_next_ppqn_tick_in_buffer( frames_since_last_beat: usize, frames_per_beat: usize, buffer_size: usize, ) -> Option { const TICKS_PER_BEAT: usize = 24; let frames_per_tick = frames_per_beat / TICKS_PER_BEAT; // Find distance to next tick boundary let frames_since_last_tick = frames_since_last_beat % frames_per_tick; let frames_to_next_tick = if frames_since_last_tick == 0 { 0 // We're exactly on a tick } else { frames_per_tick - frames_since_last_tick }; // Check if this tick occurs within our buffer if frames_to_next_tick < buffer_size { let absolute_tick_position = frames_since_last_beat + frames_to_next_tick; let tick_index = (absolute_tick_position / frames_per_tick) % TICKS_PER_BEAT; let position = (tick_index as f32) / (TICKS_PER_BEAT as f32); Some(PpqnTick { sample_offset: frames_to_next_tick, tick_index, position, }) } else { None } } } #[cfg(test)] mod tests { use super::*; fn create_test_metronome(frames_per_beat: usize) -> Metronome { let beep_samples = Arc::new(AudioChunk { samples: vec![1.0; 100].into_boxed_slice(), sample_count: 100, next: None, }); Metronome { click_samples: beep_samples, click_volume: 1.0, frames_per_beat, frames_since_last_beat: 0, last_frame_time: None, } } #[test] fn test_first_call_initialization() { let mut metronome = create_test_metronome(1000); let result = metronome.calculate_timing(5000, 128).unwrap(); assert_eq!(metronome.frames_since_last_beat, 128); assert_eq!(metronome.last_frame_time, Some(5000)); assert_eq!(result.missed_frames, 0); assert_eq!(result.beat_in_missed, None); assert_eq!(result.beat_in_buffer, None); } #[test] fn test_normal_buffer_no_beat() { let mut metronome = create_test_metronome(1000); // Initialize at time 1000 metronome.calculate_timing(1000, 128).unwrap(); assert_eq!(metronome.frames_since_last_beat, 128); // Next buffer at 1128 - no beat expected let result = metronome.calculate_timing(1128, 128).unwrap(); assert_eq!(metronome.frames_since_last_beat, 256); assert_eq!(result.missed_frames, 0); assert_eq!(result.beat_in_missed, None); assert_eq!(result.beat_in_buffer, None); } #[test] fn test_beat_in_buffer() { let mut metronome = create_test_metronome(1000); // Initialize at time 0 metronome.calculate_timing(0, 512).unwrap(); assert_eq!(metronome.frames_since_last_beat, 512); // Next buffer: 512 -> 1024, beat at 1000 (offset 488) let result = metronome.calculate_timing(512, 512).unwrap(); assert_eq!(result.missed_frames, 0); assert_eq!(result.beat_in_missed, None); assert_eq!(result.beat_in_buffer, Some(488)); // 1000 - 512 assert_eq!(metronome.frames_since_last_beat, 24); // (512 + 512) % 1000 } #[test] fn test_xrun_no_missed_beat() { let mut metronome = create_test_metronome(1000); // Initialize at time 1000 metronome.calculate_timing(1000, 128).unwrap(); assert_eq!(metronome.frames_since_last_beat, 128); // Normal buffer at 1128 metronome.calculate_timing(1128, 128).unwrap(); assert_eq!(metronome.frames_since_last_beat, 256); // Xrun: expected 1256 but got 1428 (172 samples missed) let result = metronome.calculate_timing(1428, 128).unwrap(); assert_eq!(result.missed_frames, 172); assert_eq!(result.beat_in_missed, None); assert_eq!(result.beat_in_buffer, None); assert_eq!(metronome.frames_since_last_beat, 556); // 256 + 172 + 128 } #[test] fn test_xrun_with_missed_beat() { let mut metronome = create_test_metronome(1000); // Initialize at time 1000 metronome.calculate_timing(1000, 128).unwrap(); assert_eq!(metronome.frames_since_last_beat, 128); // Normal buffer at 1128 metronome.calculate_timing(1128, 128).unwrap(); assert_eq!(metronome.frames_since_last_beat, 256); // Xrun: expected 1256 but got 2228 (972 samples missed) // We're at position 256, miss 972 samples = 1228 total // Beat occurs at position 1000, so beat_in_missed = 1000 - 256 = 744 let result = metronome.calculate_timing(2228, 128).unwrap(); assert_eq!(result.missed_frames, 972); assert_eq!(result.beat_in_missed, Some(744)); // 1000 - 256 assert_eq!(result.beat_in_buffer, None); assert_eq!(metronome.frames_since_last_beat, 356); // (256 + 972 + 128) % 1000 } #[test] fn test_xrun_with_missed_beat_and_upcoming_beat() { let mut metronome = create_test_metronome(1000); // Initialize at time 1000 metronome.calculate_timing(1000, 128).unwrap(); // Normal buffer at 1128 metronome.calculate_timing(1128, 128).unwrap(); assert_eq!(metronome.frames_since_last_beat, 256); // Xrun: expected 1256 but got 2078 (822 samples missed) // We're at position 256, miss 822 samples = 1078 total // Beat occurs at position 1000, so beat_in_missed = 1000 - 256 = 744 // After missed: position = 78, buffer = 128, no beat in buffer let result = metronome.calculate_timing(2078, 128).unwrap(); assert_eq!(result.missed_frames, 822); assert_eq!(result.beat_in_missed, Some(744)); // 1000 - 256 assert_eq!(result.beat_in_buffer, None); // 78 + 128 < 1000 assert_eq!(metronome.frames_since_last_beat, 206); // (256 + 822 + 128) % 1000 } #[test] fn test_xrun_multiple_beats_error() { let mut metronome = create_test_metronome(1000); // Initialize at time 1000 metronome.calculate_timing(1000, 128).unwrap(); // Normal buffer at 1128 metronome.calculate_timing(1128, 128).unwrap(); // Xrun: expected 1256 but got 3328 (2072 samples missed) // Total advancement would be 256 + 2072 + 128 = 2456 samples // That's more than 2 beats (2000 samples), so error let result = metronome.calculate_timing(3328, 128); assert!(result.is_err()); } #[test] fn test_consecutive_buffers_with_beat() { let mut metronome = create_test_metronome(1000); // First buffer - initialization let result1 = metronome.calculate_timing(0, 512).unwrap(); assert_eq!(result1.beat_in_buffer, None); assert_eq!(metronome.frames_since_last_beat, 512); // Second buffer - beat should occur at position 1000 let result2 = metronome.calculate_timing(512, 512).unwrap(); assert_eq!(result2.beat_in_buffer, Some(488)); // 1000 - 512 assert_eq!(metronome.frames_since_last_beat, 24); // (512 + 512) % 1000 // Third buffer - no beat let result3 = metronome.calculate_timing(1024, 512).unwrap(); assert_eq!(result3.beat_in_buffer, None); assert_eq!(metronome.frames_since_last_beat, 536); // Fourth buffer - next beat at position 1000 again let result4 = metronome.calculate_timing(1536, 512).unwrap(); assert_eq!(result4.beat_in_buffer, Some(464)); // 1000 - 536 assert_eq!(metronome.frames_since_last_beat, 48); // (536 + 512) % 1000 } #[test] fn test_u32_wrapping() { let mut metronome = create_test_metronome(1000); // Initialize near u32::MAX let start_time = u32::MAX - 100; metronome.calculate_timing(start_time, 128).unwrap(); // Next buffer wraps around let next_time = start_time.wrapping_add(128); let result = metronome.calculate_timing(next_time, 128).unwrap(); assert_eq!(result.missed_frames, 0); assert_eq!(metronome.frames_since_last_beat, 256); } // Tests for the pure PPQN calculation function #[test] fn test_ppqn_no_tick_in_buffer() { let tick = Metronome::find_next_ppqn_tick_in_buffer(100, 960000, 128); assert_eq!(tick, None); } #[test] fn test_ppqn_tick_at_buffer_start() { let frames_per_beat = 960000; // Position exactly at tick boundary let tick = Metronome::find_next_ppqn_tick_in_buffer(0, frames_per_beat, 128).unwrap(); assert_eq!(tick.sample_offset, 0); assert_eq!(tick.tick_index, 0); assert_eq!(tick.position, 0.0); } #[test] fn test_ppqn_tick_in_middle_of_buffer() { let frames_per_beat = 960000; let frames_per_tick = frames_per_beat / 24; // 40000 // Position 64 frames before tick 1 let frames_since_last_beat = frames_per_tick - 64; let tick = Metronome::find_next_ppqn_tick_in_buffer(frames_since_last_beat, frames_per_beat, 128) .unwrap(); assert_eq!(tick.sample_offset, 64); assert_eq!(tick.tick_index, 1); assert_eq!(tick.position, 1.0 / 24.0); } #[test] fn test_ppqn_tick_wrapping() { let frames_per_beat = 960000; let frames_per_tick = frames_per_beat / 24; // 40000 // Position near end of beat cycle let frames_since_last_beat = 23 * frames_per_tick - 64; // 64 frames before tick 23 let tick = Metronome::find_next_ppqn_tick_in_buffer(frames_since_last_beat, frames_per_beat, 128) .unwrap(); assert_eq!(tick.sample_offset, 64); assert_eq!(tick.tick_index, 23); assert_eq!(tick.position, 23.0 / 24.0); } #[test] fn test_ppqn_real_world_scenario() { // Your actual values let frames_per_beat = 960000; let frames_since_last_beat = 39936; let buffer_size = 128; let tick = Metronome::find_next_ppqn_tick_in_buffer( frames_since_last_beat, frames_per_beat, buffer_size, ); if let Some(tick) = tick { println!( "Tick {} at offset {} (position {})", tick.tick_index, tick.sample_offset, tick.position ); assert_eq!(tick.sample_offset, 64); // 40000 - 39936 = 64 assert_eq!(tick.tick_index, 1); } } }