From 7a78c6b9e6d18a7f4507a9fc96eb5f9d6f7a9021 Mon Sep 17 00:00:00 2001 From: Geens Date: Tue, 10 Jun 2025 19:28:11 +0200 Subject: [PATCH] Improved metronome for better xrun handling --- src/audio_chunk.rs | 4 +- src/audio_data.rs | 255 +++++++++++---------- src/audio_engine.rs | 25 ++- src/beep.rs | 25 +++ src/metronome.rs | 435 ++++++++++++++++++++++++++++-------- src/midi.rs | 2 +- src/notification_handler.rs | 7 +- src/post_record_handler.rs | 78 ++++--- src/process_handler.rs | 94 ++++---- src/state.rs | 11 +- src/track.rs | 81 ++++--- 11 files changed, 680 insertions(+), 337 deletions(-) create mode 100644 src/beep.rs diff --git a/src/audio_chunk.rs b/src/audio_chunk.rs index b889bd6..37f0b28 100644 --- a/src/audio_chunk.rs +++ b/src/audio_chunk.rs @@ -89,12 +89,12 @@ impl AudioChunk { pub fn len(&self) -> usize { let mut total = self.sample_count; let mut current = &self.next; - + while let Some(chunk) = current { total += chunk.sample_count; current = &chunk.next; } - + total } diff --git a/src/audio_data.rs b/src/audio_data.rs index 2e2c7de..12cff54 100644 --- a/src/audio_data.rs +++ b/src/audio_data.rs @@ -5,17 +5,17 @@ use crate::*; pub enum AudioData { /// No audio data present Empty, - + /// Unconsolidated linked chunks with sync offset (used during/after recording) Unconsolidated { chunks: Arc, - sync_offset: usize, // samples from column start to recording start - length: usize, // total samples in recording + sync_offset: usize, // samples from column start to recording start + length: usize, // total samples in recording }, - + /// Consolidated single buffer, reordered for optimal playback Consolidated { - buffer: Box<[f32]>, // single optimized array + buffer: Box<[f32]>, // single optimized array }, } @@ -24,7 +24,7 @@ impl AudioData { pub fn new_empty() -> Self { Self::Empty } - + /// Create new unconsolidated audio data from initial chunk pub fn new_unconsolidated( chunk_factory: &mut F, @@ -36,7 +36,7 @@ impl AudioData { length: 0, }) } - + /// Get total length in samples pub fn len(&self) -> usize { match self { @@ -45,12 +45,12 @@ impl AudioData { Self::Consolidated { buffer } => buffer.len(), } } - + /// Check if audio data is empty pub fn is_empty(&self) -> bool { self.len() == 0 } - + /// Append samples during recording (only valid for Unconsolidated state) pub fn append_samples( &mut self, @@ -66,7 +66,7 @@ impl AudioData { _ => Err(LooperError::OutOfBounds(std::panic::Location::caller())), } } - + /// Copy samples to output buffer with volume scaling and looping /// logical_position is the position within the column cycle (0 = beat 1) pub fn copy_samples_to_output( @@ -80,41 +80,42 @@ impl AudioData { output_slice.fill(0.0); Ok(()) } - Self::Unconsolidated { chunks, sync_offset, length } => { - self.copy_unconsolidated_samples( - chunks, - *sync_offset, - *length, - output_slice, - logical_position, - volume - ) - } + Self::Unconsolidated { + chunks, + sync_offset, + length, + } => self.copy_unconsolidated_samples( + chunks, + *sync_offset, + *length, + output_slice, + logical_position, + volume, + ), Self::Consolidated { buffer } => { - self.copy_consolidated_samples( - buffer, - output_slice, - logical_position, - volume - ) + self.copy_consolidated_samples(buffer, output_slice, logical_position, volume) } } } - + /// Get underlying chunk for post-record processing pub fn get_chunk_for_processing(&self) -> Option<(Arc, usize)> { match self { - Self::Unconsolidated { chunks, sync_offset, .. } => { - Some((chunks.clone(), *sync_offset)) - } + Self::Unconsolidated { + chunks, + sync_offset, + .. + } => Some((chunks.clone(), *sync_offset)), _ => None, } } - + /// Replace with consolidated buffer from post-record processing pub fn set_consolidated_buffer(&mut self, buffer: Box<[f32]>) -> Result<()> { match self { - Self::Unconsolidated { length: old_length, .. } => { + Self::Unconsolidated { + length: old_length, .. + } => { if buffer.len() != *old_length { return Err(LooperError::OutOfBounds(std::panic::Location::caller())); } @@ -124,7 +125,7 @@ impl AudioData { _ => Err(LooperError::OutOfBounds(std::panic::Location::caller())), } } - + /// Clear all audio data pub fn clear(&mut self) -> Result<()> { *self = Self::Empty; @@ -147,41 +148,41 @@ impl AudioData { output_slice.fill(0.0); return Ok(()); } - + let mut samples_written = 0; let samples_needed = output_slice.len(); - + while samples_written < samples_needed { let samples_remaining = samples_needed - samples_written; let samples_until_loop = length - logical_position; let samples_to_copy = samples_remaining.min(samples_until_loop); - + // Map logical position to buffer position using sync offset let buffer_position = (logical_position + sync_offset) % length; - + // Copy from chunks to output slice chunks.copy_samples( &mut output_slice[samples_written..samples_written + samples_to_copy], buffer_position, )?; - + // Apply volume scaling in-place for sample in &mut output_slice[samples_written..samples_written + samples_to_copy] { *sample *= volume; } - + samples_written += samples_to_copy; logical_position += samples_to_copy; - + // Handle looping if logical_position >= length { logical_position = 0; } } - + Ok(()) } - + /// Copy samples from consolidated buffer (already reordered) fn copy_consolidated_samples( &self, @@ -195,34 +196,34 @@ impl AudioData { output_slice.fill(0.0); return Ok(()); } - + let mut samples_written = 0; let samples_needed = output_slice.len(); - + while samples_written < samples_needed { let samples_remaining = samples_needed - samples_written; let samples_until_loop = length - logical_position; let samples_to_copy = samples_remaining.min(samples_until_loop); - + // Direct copy since consolidated buffer is already reordered let src = &buffer[logical_position..logical_position + samples_to_copy]; let dest = &mut output_slice[samples_written..samples_written + samples_to_copy]; dest.copy_from_slice(src); - + // Apply volume scaling in-place for sample in dest { *sample *= volume; } - + samples_written += samples_to_copy; logical_position += samples_to_copy; - + // Handle looping if logical_position >= length { logical_position = 0; } } - + Ok(()) } } @@ -235,190 +236,210 @@ mod tests { #[test] fn test_new_empty() { let audio_data = AudioData::new_empty(); - + assert_eq!(audio_data.len(), 0); assert!(audio_data.is_empty()); } - - #[test] + + #[test] fn test_new_unconsolidated() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(1024)]); let sync_offset = 100; - + let audio_data = AudioData::new_unconsolidated(&mut factory, sync_offset).unwrap(); - + match audio_data { - AudioData::Unconsolidated { sync_offset: offset, length, .. } => { + AudioData::Unconsolidated { + sync_offset: offset, + length, + .. + } => { assert_eq!(offset, 100); assert_eq!(length, 0); } _ => panic!("Expected Unconsolidated variant"), } } - + #[test] fn test_append_samples_unconsolidated() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(1024)]); let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap(); - + let samples = vec![1.0, 2.0, 3.0, 4.0]; audio_data.append_samples(&samples, &mut factory).unwrap(); - + assert_eq!(audio_data.len(), 4); assert!(!audio_data.is_empty()); } - + #[test] fn test_append_samples_invalid_state() { let mut factory = MockFactory::new(vec![]); let mut audio_data = AudioData::new_empty(); - + let samples = vec![1.0, 2.0]; let result = audio_data.append_samples(&samples, &mut factory); - + assert!(result.is_err()); } - + #[test] fn test_copy_samples_empty() { let audio_data = AudioData::new_empty(); let mut output = vec![99.0; 4]; // Fill with non-zero to verify silence - - audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap(); - + + audio_data + .copy_samples_to_output(&mut output, 0, 1.0) + .unwrap(); + assert_eq!(output, vec![0.0, 0.0, 0.0, 0.0]); } - + #[test] fn test_copy_samples_unconsolidated_no_offset() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]); let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap(); - + // Add some test samples let samples = vec![1.0, 2.0, 3.0, 4.0]; audio_data.append_samples(&samples, &mut factory).unwrap(); - + let mut output = vec![0.0; 4]; - audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap(); - + audio_data + .copy_samples_to_output(&mut output, 0, 1.0) + .unwrap(); + assert_eq!(output, vec![1.0, 2.0, 3.0, 4.0]); } - + #[test] fn test_copy_samples_unconsolidated_with_offset() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]); let sync_offset = 2; let mut audio_data = AudioData::new_unconsolidated(&mut factory, sync_offset).unwrap(); - + // Recording order: [A, B, C, D] but recorded starting at beat 3 (sync_offset=2) // So logical mapping should be: beat1=C, beat2=D, beat3=A, beat4=B let samples = vec![10.0, 20.0, 30.0, 40.0]; // A, B, C, D audio_data.append_samples(&samples, &mut factory).unwrap(); - + let mut output = vec![0.0; 4]; - audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap(); // Start at logical beat 1 - + audio_data + .copy_samples_to_output(&mut output, 0, 1.0) + .unwrap(); // Start at logical beat 1 + // Should get: [C, D, A, B] = [30.0, 40.0, 10.0, 20.0] assert_eq!(output, vec![30.0, 40.0, 10.0, 20.0]); } - + #[test] fn test_copy_samples_unconsolidated_with_volume() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]); let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap(); - + let samples = vec![1.0, 2.0, 3.0, 4.0]; audio_data.append_samples(&samples, &mut factory).unwrap(); - + let mut output = vec![0.0; 4]; - audio_data.copy_samples_to_output(&mut output, 0, 0.5).unwrap(); // 50% volume - + audio_data + .copy_samples_to_output(&mut output, 0, 0.5) + .unwrap(); // 50% volume + assert_eq!(output, vec![0.5, 1.0, 1.5, 2.0]); } - + #[test] fn test_copy_samples_unconsolidated_looping() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]); let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap(); - + let samples = vec![1.0, 2.0]; audio_data.append_samples(&samples, &mut factory).unwrap(); - + let mut output = vec![0.0; 6]; // Request more samples than available - audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap(); - + audio_data + .copy_samples_to_output(&mut output, 0, 1.0) + .unwrap(); + // Should loop: [1.0, 2.0, 1.0, 2.0, 1.0, 2.0] assert_eq!(output, vec![1.0, 2.0, 1.0, 2.0, 1.0, 2.0]); } - + #[test] fn test_copy_samples_unconsolidated_offset_with_looping() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]); let sync_offset = 1; let mut audio_data = AudioData::new_unconsolidated(&mut factory, sync_offset).unwrap(); - + // Recording: [A, B, C] starting at beat 2 (sync_offset=1) // Logical mapping: beat1=B, beat2=C, beat3=A - let samples = vec![10.0, 20.0, 30.0]; + let samples = vec![10.0, 20.0, 30.0]; audio_data.append_samples(&samples, &mut factory).unwrap(); - + let mut output = vec![0.0; 6]; // Request 2 full loops - audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap(); - + audio_data + .copy_samples_to_output(&mut output, 0, 1.0) + .unwrap(); + // Should get: [B, C, A, B, C, A] = [20.0, 30.0, 10.0, 20.0, 30.0, 10.0] assert_eq!(output, vec![20.0, 30.0, 10.0, 20.0, 30.0, 10.0]); } - + #[test] fn test_copy_samples_consolidated() { // Create consolidated data directly let buffer = vec![1.0, 2.0, 3.0, 4.0].into_boxed_slice(); - + let audio_data = AudioData::Consolidated { buffer }; - + let mut output = vec![0.0; 4]; - audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap(); - + audio_data + .copy_samples_to_output(&mut output, 0, 1.0) + .unwrap(); + assert_eq!(output, vec![1.0, 2.0, 3.0, 4.0]); } - + #[test] fn test_get_chunk_for_processing() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]); let sync_offset = 42; let mut audio_data = AudioData::new_unconsolidated(&mut factory, sync_offset).unwrap(); - + let samples = vec![1.0, 2.0, 3.0]; audio_data.append_samples(&samples, &mut factory).unwrap(); - + let result = audio_data.get_chunk_for_processing(); assert!(result.is_some()); - + let (chunk, offset) = result.unwrap(); assert_eq!(offset, 42); assert_eq!(chunk.len(), 3); } - + #[test] fn test_get_chunk_for_processing_wrong_state() { let audio_data = AudioData::new_empty(); let result = audio_data.get_chunk_for_processing(); assert!(result.is_none()); } - + #[test] fn test_set_consolidated_buffer() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]); let mut audio_data = AudioData::new_unconsolidated(&mut factory, 100).unwrap(); - + let samples = vec![1.0, 2.0, 3.0, 4.0]; audio_data.append_samples(&samples, &mut factory).unwrap(); - + // Create consolidated buffer with same length let consolidated_buffer = vec![10.0, 20.0, 30.0, 40.0].into_boxed_slice(); - - audio_data.set_consolidated_buffer(consolidated_buffer).unwrap(); - + + audio_data + .set_consolidated_buffer(consolidated_buffer) + .unwrap(); + // Should now be Consolidated variant match audio_data { AudioData::Consolidated { buffer } => { @@ -427,39 +448,39 @@ mod tests { _ => panic!("Expected Consolidated variant"), } } - + #[test] fn test_set_consolidated_buffer_length_mismatch() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]); let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap(); - + let samples = vec![1.0, 2.0]; // Length 2 audio_data.append_samples(&samples, &mut factory).unwrap(); - + // Try to set buffer with different length let wrong_length_buffer = vec![10.0, 20.0, 30.0, 40.0].into_boxed_slice(); // Length 4 - + let result = audio_data.set_consolidated_buffer(wrong_length_buffer); assert!(result.is_err()); } - + #[test] fn test_clear() { let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]); let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap(); - + let samples = vec![1.0, 2.0, 3.0]; audio_data.append_samples(&samples, &mut factory).unwrap(); assert_eq!(audio_data.len(), 3); - + audio_data.clear().unwrap(); - + assert_eq!(audio_data.len(), 0); assert!(audio_data.is_empty()); - + match audio_data { AudioData::Empty => (), // Expected _ => panic!("Expected Empty variant after clear"), } } -} \ No newline at end of file +} diff --git a/src/audio_engine.rs b/src/audio_engine.rs index 184b142..e2db543 100644 --- a/src/audio_engine.rs +++ b/src/audio_engine.rs @@ -1,16 +1,17 @@ mod allocator; mod audio_chunk; mod audio_data; +mod beep; mod chunk_factory; mod connection_manager; mod looper_error; mod metronome; mod midi; mod notification_handler; +mod persistence_manager; mod post_record_handler; mod process_handler; mod state; -mod persistence_manager; mod track; use std::sync::Arc; @@ -18,19 +19,19 @@ use std::sync::Arc; use allocator::Allocator; use audio_chunk::AudioChunk; use audio_data::AudioData; +use beep::generate_beep; use chunk_factory::ChunkFactory; use connection_manager::ConnectionManager; use looper_error::LooperError; use looper_error::Result; use metronome::Metronome; -use metronome::generate_beep; use notification_handler::JackNotification; use notification_handler::NotificationHandler; -use post_record_handler::PostRecordHandler; +use persistence_manager::PersistenceManager; use post_record_handler::PostRecordController; +use post_record_handler::PostRecordHandler; use process_handler::ProcessHandler; use state::State; -use persistence_manager::PersistenceManager; use track::Track; use track::TrackState; use track::TrackTiming; @@ -58,22 +59,24 @@ async fn main() { let notification_handler = NotificationHandler::new(); let mut notification_channel = notification_handler.subscribe(); - let (mut persistence_manager, state_watch) = PersistenceManager::new(notification_handler.subscribe()); + let (mut persistence_manager, state_watch) = + PersistenceManager::new(notification_handler.subscribe()); // Load state values for metronome configuration let initial_state = state_watch.borrow().clone(); // Create post-record handler and get controller for ProcessHandler - let (mut post_record_handler, post_record_controller) = PostRecordHandler::new() - .expect("Could not create post-record handler"); + let (mut post_record_handler, post_record_controller) = + PostRecordHandler::new().expect("Could not create post-record handler"); let process_handler = ProcessHandler::new( - ports, - allocator, - beep_samples, + ports, + allocator, + beep_samples, &initial_state, post_record_controller, - ).expect("Could not create process handler"); + ) + .expect("Could not create process handler"); let mut connection_manager = ConnectionManager::new( state_watch, diff --git a/src/beep.rs b/src/beep.rs new file mode 100644 index 0000000..b1f129f --- /dev/null +++ b/src/beep.rs @@ -0,0 +1,25 @@ +use crate::*; + +/// Generate a 100ms sine wave beep at 1000Hz +pub fn generate_beep( + sample_rate: u32, + chunk_factory: &mut F, +) -> Result> { + const FREQUENCY_HZ: f32 = 1000.0; + const DURATION_MS: f32 = 100.0; + + let sample_count = ((sample_rate as f32) * (DURATION_MS / 1000.0)) as usize; + let mut samples = Vec::with_capacity(sample_count); + + for i in 0..sample_count { + let t = i as f32 / sample_rate as f32; + let sample = (std::f32::consts::TAU * FREQUENCY_HZ * t).sin(); + samples.push(sample); + } + + // Create AudioChunk and fill it with samples + let mut chunk = chunk_factory.create_chunk()?; + chunk.append_samples(&samples, chunk_factory)?; + + Ok(chunk) +} diff --git a/src/metronome.rs b/src/metronome.rs index 0c821df..2df18d3 100644 --- a/src/metronome.rs +++ b/src/metronome.rs @@ -1,123 +1,368 @@ use crate::*; -use std::f32::consts::TAU; pub struct Metronome { // Audio playback - beep_samples: Arc, - click_volume: f32, // 0.0 to 1.0 - - // Timing state - samples_per_beat: u32, - beat_time: u32, + click_samples: Arc, + click_volume: f32, + + // Timing state + frames_per_beat: u32, + frames_since_last_beat: u32, // 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: u32, + + /// Beat index within the missed frames (if any) + pub beat_in_missed: Option, } impl Metronome { - pub fn new(beep_samples: Arc, state: &State) -> Self { + pub fn new(click_samples: Arc, state: &State) -> Self { Self { - beep_samples, + click_samples, click_volume: state.metronome.click_volume, - samples_per_beat: state.metronome.samples_per_beat, - beat_time: 0, + frames_per_beat: state.metronome.frames_per_beat, + frames_since_last_beat: 0, + last_frame_time: None, } } - pub fn samples_per_beat(&self) -> u32 { - self.samples_per_beat + pub fn frames_per_beat(&self) -> u32 { + self.frames_per_beat } /// Process audio for current buffer, writing to output slice - /// Returns the sample index where a beat occurred, if any - pub fn process(&mut self, ps: &jack::ProcessScope, ports: &mut JackPorts) -> Result> { - if 0 == self.beat_time { - self.beat_time = ps.last_frame_time(); + pub fn process( + &mut self, + ps: &jack::ProcessScope, + ports: &mut JackPorts, + ) -> Result { + let buffer_size = ps.n_frames(); + let current_frame_time = ps.last_frame_time(); + + // 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, current_frame_time, &timing, click_output); + + Ok(timing) + } + + fn render_click( + &mut self, + buffer_size: u32, + current_frame_time: u32, + timing: &BufferTiming, + click_output: &mut [f32], + ) { + // Calculate current position within the beep (frames since last beat started) + let frames_since_beat_start = current_frame_time - self.frames_since_last_beat; + let click_length = self.click_samples.sample_count as u32; + + if let Some(beat_offset) = timing.beat_in_buffer { + // Write silence up to beat boundary + let silence_end = beat_offset.min(buffer_size); + click_output[0..silence_end as _].fill(0.0); + + // Write click samples from boundary onward + if beat_offset < buffer_size { + let remaining_buffer = buffer_size - beat_offset; + let samples_to_write = remaining_buffer.min(click_length); + + // Copy click samples in bulk + let dest = &mut click_output + [beat_offset as usize..beat_offset as usize + samples_to_write as usize]; + if self.click_samples.copy_samples(dest, 0).is_ok() { + // Apply volume scaling with iterators + dest.iter_mut() + .for_each(|sample| *sample *= self.click_volume); + } + + // Fill remaining buffer with silence + click_output[(beat_offset as usize + samples_to_write as usize)..].fill(0.0); + } + } else if frames_since_beat_start < click_length { + // Continue playing click from previous beat if still within beep duration + let click_start_offset = frames_since_beat_start; + let remaining_click_samples = click_length - click_start_offset; + let samples_to_write = buffer_size.min(remaining_click_samples); + + // Copy remaining beep samples in bulk + let dest = &mut click_output[0..samples_to_write as _]; + if self + .click_samples + .copy_samples(dest, click_start_offset as _) + .is_ok() + { + // Apply volume scaling with iterators + dest.iter_mut() + .for_each(|sample| *sample *= self.click_volume); + } + + // Fill remaining buffer with silence + click_output[samples_to_write as _..].fill(0.0); + } else { + click_output.fill(0.0); } + } - let time_since_last_beat = ps.last_frame_time() - self.beat_time; + pub fn calculate_timing( + &mut self, + current_frame_time: u32, + buffer_size: u32, + ) -> Result { + // Detect xrun + let (missed_samples, 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); - if time_since_last_beat >= (2 * self.samples_per_beat) && self.beat_time != 0 { - return Err(LooperError::Xrun(std::panic::Location::caller())); - } + // Check if we missed multiple beats + let total_samples = self.frames_since_last_beat + missed + buffer_size; + if total_samples >= 2 * self.frames_per_beat { + return Err(LooperError::Xrun(std::panic::Location::caller())); + } - let beat_sample_index = if time_since_last_beat >= self.samples_per_beat { - self.beat_time += self.samples_per_beat; - Some(self.beat_time - ps.last_frame_time()) + // Check if a beat occurred in the missed section + let beat_in_missed = if self.frames_since_last_beat + missed >= self.frames_per_beat + { + Some(self.frames_per_beat - self.frames_since_last_beat) + } 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_samples) % self.frames_per_beat; + let beat_in_buffer = if start_position + buffer_size >= self.frames_per_beat { + Some(self.frames_per_beat - start_position) } else { None }; - // Get output buffer for click track - let click_output = ports.click_track_out.as_mut_slice(ps); - let buffer_size = click_output.len(); - - // Calculate current position within the beep (frames since last beat started) - let frames_since_beat_start = ps.last_frame_time() - self.beat_time; - let beep_length = self.beep_samples.sample_count as u32; + // Update state - advance by total samples (missed + buffer) + self.frames_since_last_beat = + (self.frames_since_last_beat + missed_samples + buffer_size) % self.frames_per_beat; + self.last_frame_time = Some(current_frame_time); - if let Some(beat_offset) = beat_sample_index { - let beat_offset = beat_offset as usize; - - // Write silence up to beat boundary - let silence_end = beat_offset.min(buffer_size); - click_output[0..silence_end].fill(0.0); - - // Write beep samples from boundary onward - if beat_offset < buffer_size { - let remaining_buffer = buffer_size - beat_offset; - let samples_to_write = remaining_buffer.min(beep_length as usize); - - // Copy beep samples in bulk - let dest = &mut click_output[beat_offset..beat_offset + samples_to_write]; - if self.beep_samples.copy_samples(dest, 0).is_ok() { - // Apply volume scaling with iterators - dest.iter_mut().for_each(|sample| *sample *= self.click_volume); - } - - // Fill remaining buffer with silence - click_output[(beat_offset + samples_to_write)..].fill(0.0); - } - } else if frames_since_beat_start < beep_length { - // Continue playing beep from previous beat if still within beep duration - let beep_start_offset = frames_since_beat_start as usize; - let remaining_beep_samples = beep_length as usize - beep_start_offset; - let samples_to_write = buffer_size.min(remaining_beep_samples); - - // Copy remaining beep samples in bulk - let dest = &mut click_output[0..samples_to_write]; - if self.beep_samples.copy_samples(dest, beep_start_offset).is_ok() { - // Apply volume scaling with iterators - dest.iter_mut().for_each(|sample| *sample *= self.click_volume); - } - - // Fill remaining buffer with silence - click_output[samples_to_write..].fill(0.0); - } else { - click_output.fill(0.0); + Ok(BufferTiming { + beat_in_buffer, + missed_frames: missed_samples, + beat_in_missed, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_metronome(samples_per_beat: u32) -> 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: samples_per_beat, + frames_since_last_beat: 0, + last_frame_time: None, } + } - Ok(beat_sample_index) + #[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); } } - -/// Generate a 100ms sine wave beep at 1000Hz -pub fn generate_beep( - sample_rate: u32, - chunk_factory: &mut F, -) -> Result> { - const FREQUENCY_HZ: f32 = 1000.0; - const DURATION_MS: f32 = 100.0; - - let sample_count = ((sample_rate as f32) * (DURATION_MS / 1000.0)) as usize; - let mut samples = Vec::with_capacity(sample_count); - - for i in 0..sample_count { - let t = i as f32 / sample_rate as f32; - let sample = (TAU * FREQUENCY_HZ * t).sin(); - samples.push(sample); - } - - // Create AudioChunk and fill it with samples - let mut chunk = chunk_factory.create_chunk()?; - chunk.append_samples(&samples, chunk_factory)?; - - Ok(chunk) -} diff --git a/src/midi.rs b/src/midi.rs index 461ea62..f7fb7eb 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -72,4 +72,4 @@ pub fn process_events( } Ok(()) -} \ No newline at end of file +} diff --git a/src/notification_handler.rs b/src/notification_handler.rs index 6059242..d7b3ba8 100644 --- a/src/notification_handler.rs +++ b/src/notification_handler.rs @@ -63,7 +63,12 @@ impl jack::NotificationHandler for NotificationHandler { .expect("Could not send port connection notification"); } - fn port_registration(&mut self, _client: &jack::Client, _port_id: jack::PortId, register: bool) { + fn port_registration( + &mut self, + _client: &jack::Client, + _port_id: jack::PortId, + register: bool, + ) { if register { let notification = JackNotification::PortRegistered {}; self.channel diff --git a/src/post_record_handler.rs b/src/post_record_handler.rs index 97cd162..b13caf0 100644 --- a/src/post_record_handler.rs +++ b/src/post_record_handler.rs @@ -24,11 +24,20 @@ pub struct PostRecordController { impl PostRecordController { /// Send a post-record processing request (RT-safe) - pub fn send_request(&self, chunk_chain: Arc, sync_offset: u32, sample_rate: u32) -> Result<()> { - let request = PostRecordRequest { chunk_chain, sync_offset, sample_rate }; - + pub fn send_request( + &self, + chunk_chain: Arc, + sync_offset: u32, + sample_rate: u32, + ) -> Result<()> { + let request = PostRecordRequest { + chunk_chain, + sync_offset, + sample_rate, + }; + match self.request_sender.try_send(request) { - Ok(true) => Ok(()), // Successfully sent + Ok(true) => Ok(()), // Successfully sent Ok(false) => Err(LooperError::ChunkAllocation(std::panic::Location::caller())), // Channel full Err(_) => Err(LooperError::ChunkAllocation(std::panic::Location::caller())), // Channel closed } @@ -38,7 +47,7 @@ impl PostRecordController { pub fn try_recv_response(&self) -> Option { match self.response_receiver.try_recv() { Ok(Some(response)) => Some(response), - Ok(None) | Err(_) => None, // No message or channel closed = None + Ok(None) | Err(_) => None, // No message or channel closed = None } } } @@ -86,24 +95,27 @@ impl PostRecordHandler { } /// Process a single post-record request - async fn process_request( - &self, - request: PostRecordRequest, - ) -> Result<()> { - log::debug!("Processing post-record request for {} samples with sync_offset {}", - request.chunk_chain.len(), request.sync_offset); + async fn process_request(&self, request: PostRecordRequest) -> Result<()> { + log::debug!( + "Processing post-record request for {} samples with sync_offset {}", + request.chunk_chain.len(), + request.sync_offset + ); // Step 1: Consolidate and reorder chunk chain based on sync offset - let consolidated_buffer = self.consolidate_with_sync_offset( - &request.chunk_chain, - request.sync_offset as usize - )?; - - log::debug!("Consolidated and reordered {} samples", consolidated_buffer.len()); + let consolidated_buffer = + self.consolidate_with_sync_offset(&request.chunk_chain, request.sync_offset as usize)?; + + log::debug!( + "Consolidated and reordered {} samples", + consolidated_buffer.len() + ); // Step 2: Send consolidated buffer back to RT thread immediately - let response = PostRecordResponse { consolidated_buffer }; - + let response = PostRecordResponse { + consolidated_buffer, + }; + if let Err(_) = self.response_sender.send(response).await { log::warn!("Failed to send consolidated buffer to RT thread"); } @@ -113,7 +125,10 @@ impl PostRecordHandler { let consolidated_chunk = AudioChunk::consolidate(&request.chunk_chain); let file_path = self.get_file_path(); - match self.save_wav_file(&consolidated_chunk, request.sample_rate, &file_path).await { + match self + .save_wav_file(&consolidated_chunk, request.sample_rate, &file_path) + .await + { Ok(_) => log::info!("Saved recording to {:?}", file_path), Err(e) => log::error!("Failed to save recording to {:?}: {}", file_path, e), } @@ -122,9 +137,13 @@ impl PostRecordHandler { } /// Consolidate chunk chain and reorder samples based on sync offset - fn consolidate_with_sync_offset(&self, chunk_chain: &Arc, sync_offset: usize) -> Result> { + fn consolidate_with_sync_offset( + &self, + chunk_chain: &Arc, + sync_offset: usize, + ) -> Result> { let total_length = chunk_chain.len(); - + if total_length == 0 { return Ok(Vec::new().into_boxed_slice()); } @@ -141,7 +160,7 @@ impl PostRecordHandler { } let mut reordered = Vec::with_capacity(total_length); - + // For each output position, calculate which input position to take from for i in 0..total_length { let source_index = (i + sync_offset) % total_length; @@ -161,7 +180,7 @@ impl PostRecordHandler { // Run WAV writing in blocking task to avoid blocking async runtime let chunk_samples: Vec = chunk.samples[..chunk.sample_count].to_vec(); let file_path_clone = file_path.clone(); - + tokio::task::spawn_blocking(move || { let spec = hound::WavSpec { channels: 1, @@ -175,11 +194,13 @@ impl PostRecordHandler { // Write all samples from the chunk for sample in chunk_samples { - writer.write_sample(sample) + writer + .write_sample(sample) .map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?; } - writer.finalize() + writer + .finalize() .map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?; Ok::<(), LooperError>(()) @@ -190,8 +211,7 @@ impl PostRecordHandler { /// Create save directory and return path fn create_directory() -> Result { - let mut path = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")); + let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); path.push(".fcb_looper"); std::fs::create_dir_all(&path) @@ -204,4 +224,4 @@ impl PostRecordHandler { fn get_file_path(&self) -> PathBuf { self.directory.join("track.wav") } -} \ No newline at end of file +} diff --git a/src/process_handler.rs b/src/process_handler.rs index 4cfb959..9dab0eb 100644 --- a/src/process_handler.rs +++ b/src/process_handler.rs @@ -1,8 +1,8 @@ use crate::*; // Testing constants for sync offset functionality -const SYNC_OFFSET_BEATS: u32 = 2; // Start recording at beat 3 (0-indexed) -const AUTO_STOP_BEATS: u32 = 4; // Record for 4 beats total +const SYNC_OFFSET_BEATS: u32 = 2; // Start recording at beat 3 (0-indexed) +const AUTO_STOP_BEATS: u32 = 4; // Record for 4 beats total pub struct ProcessHandler { track: Track, @@ -15,9 +15,9 @@ pub struct ProcessHandler { impl ProcessHandler { pub fn new( - ports: JackPorts, - chunk_factory: F, - beep_samples: Arc, + ports: JackPorts, + chunk_factory: F, + beep_samples: Arc, state: &State, post_record_controller: PostRecordController, ) -> Result { @@ -43,13 +43,14 @@ impl ProcessHandler { Ok(()) } - /// Handle auto-stop record button (Button 3) + /// Handle auto-stop record button (Button 3) pub fn record_auto_stop(&mut self) -> Result<()> { - let samples_per_beat = self.metronome.samples_per_beat(); + let samples_per_beat = self.metronome.frames_per_beat(); let sync_offset = SYNC_OFFSET_BEATS * samples_per_beat; let target_samples = AUTO_STOP_BEATS * samples_per_beat; - - self.track.queue_record_auto_stop(target_samples as usize, sync_offset as usize); + + self.track + .queue_record_auto_stop(target_samples as usize, sync_offset as usize); Ok(()) } @@ -75,30 +76,32 @@ impl jack::ProcessHandler for ProcessHandler { log::error!("Error processing metronome: {}", e); return jack::Control::Quit; } - }; + } + .beat_in_buffer; let buffer_size = client.buffer_size() as usize; let state_before = self.track.current_state().clone(); - + // Calculate timing information for track processing let timing = self.calculate_track_timing(beat_sample_index, &state_before); - + // Process track audio with calculated timing - let should_consolidate = match self.track.process( - ps, - &mut self.ports, - timing, - &mut self.chunk_factory - ) { - Ok(consolidate) => consolidate, - Err(e) => { - log::error!("Error processing track: {}", e); - return jack::Control::Quit; - } - }; + let should_consolidate = + match self + .track + .process(ps, &mut self.ports, timing, &mut self.chunk_factory) + { + Ok(consolidate) => consolidate, + Err(e) => { + log::error!("Error processing track: {}", e); + return jack::Control::Quit; + } + }; // Handle post-record processing - if let Err(e) = self.handle_post_record_processing(should_consolidate, client.sample_rate() as u32) { + if let Err(e) = + self.handle_post_record_processing(should_consolidate, client.sample_rate() as u32) + { log::error!("Error handling post-record processing: {}", e); return jack::Control::Quit; } @@ -112,17 +115,26 @@ impl jack::ProcessHandler for ProcessHandler { impl ProcessHandler { /// Handle post-record processing: send requests and swap buffers - fn handle_post_record_processing(&mut self, should_consolidate: bool, sample_rate: u32) -> Result<()> { + fn handle_post_record_processing( + &mut self, + should_consolidate: bool, + sample_rate: u32, + ) -> Result<()> { // Send audio data for processing if track indicates consolidation needed if should_consolidate { if let Some((chunk_chain, sync_offset)) = self.track.get_audio_data_for_processing() { - self.post_record_controller.send_request(chunk_chain, sync_offset as u32, sample_rate)?; + self.post_record_controller.send_request( + chunk_chain, + sync_offset as u32, + sample_rate, + )?; } } // Check for consolidation response if let Some(response) = self.post_record_controller.try_recv_response() { - self.track.set_consolidated_buffer(response.consolidated_buffer)?; + self.track + .set_consolidated_buffer(response.consolidated_buffer)?; } Ok(()) @@ -145,7 +157,7 @@ impl ProcessHandler { let beat_index = beat_index as usize; let pre_beat_position = self.playback_position; let post_beat_position = self.calculate_post_beat_position(state_before); - + TrackTiming::Beat { pre_beat_position, post_beat_position, @@ -154,11 +166,11 @@ impl ProcessHandler { } } } - + /// Calculate the correct playback position after a beat transition fn calculate_post_beat_position(&self, state_before: &TrackState) -> usize { let state_after = self.track.next_state(); // Use next_state since transition hasn't happened yet - + match (state_before, state_after) { (_, TrackState::Playing) if !matches!(state_before, TrackState::Playing) => { // Just started playing - start from beginning @@ -185,7 +197,7 @@ impl ProcessHandler { state_before: &TrackState, ) { let state_after = self.track.current_state().clone(); - + match beat_sample_index { None => { // No beat - simple position update @@ -195,19 +207,21 @@ impl ProcessHandler { } Some(beat_index) => { let beat_index = beat_index as usize; - + // Handle position updates around beat boundary if beat_index > 0 && *state_before == TrackState::Playing { // Advance position for samples before beat self.advance_playback_position(beat_index); } - + // Check if state transition at beat affects position - if state_after == TrackState::Playing && !matches!(state_before, TrackState::Playing) { + if state_after == TrackState::Playing + && !matches!(state_before, TrackState::Playing) + { // Started playing at beat - reset position to post-beat calculation self.playback_position = self.calculate_post_beat_position(state_before); } - + // Advance position for samples after beat if playing if beat_index < buffer_size && state_after == TrackState::Playing { let samples_after_beat = buffer_size - beat_index; @@ -216,19 +230,19 @@ impl ProcessHandler { } } } - + /// Advance playback position with looping fn advance_playback_position(&mut self, samples: usize) { if self.track.len() == 0 { self.playback_position = 0; return; } - + self.playback_position += samples; - + // Handle looping while self.playback_position >= self.track.len() { self.playback_position -= self.track.len(); } } -} \ No newline at end of file +} diff --git a/src/state.rs b/src/state.rs index bc67b4c..2a33d77 100644 --- a/src/state.rs +++ b/src/state.rs @@ -16,8 +16,8 @@ pub struct ConnectionState { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MetronomeState { - pub samples_per_beat: u32, - pub click_volume: f32, // 0.0 to 1.0 + pub frames_per_beat: u32, + pub click_volume: f32, // 0.0 to 1.0 } impl Default for State { @@ -30,12 +30,9 @@ impl Default for State { click_track_out: Vec::new(), }, metronome: MetronomeState { - samples_per_beat: 96000, // 120 BPM at 192kHz sample rate - click_volume: 0.5, // Default 50% volume + frames_per_beat: 96000, // 120 BPM at 192kHz sample rate + click_volume: 0.5, // Default 50% volume }, } } } - - - diff --git a/src/track.rs b/src/track.rs index a6b78cc..9affaa3 100644 --- a/src/track.rs +++ b/src/track.rs @@ -6,22 +6,22 @@ pub enum TrackState { Idle, // Has data, not playing (READY) Playing, // Currently playing (PLAY) Recording, // Currently recording (REC) - manual stop - RecordingAutoStop { - target_samples: usize, // Auto-stop when this many samples recorded - sync_offset: usize, // Offset in samples from column start + RecordingAutoStop { + target_samples: usize, // Auto-stop when this many samples recorded + sync_offset: usize, // Offset in samples from column start }, } #[derive(Debug)] pub enum TrackTiming { - NoBeat { - position: usize + NoBeat { + position: usize, }, - Beat { + Beat { pre_beat_position: usize, post_beat_position: usize, beat_sample_index: usize, - } + }, } pub struct Track { @@ -67,10 +67,10 @@ impl Track { )?; false // No state transition possible without beat } - TrackTiming::Beat { - pre_beat_position, - post_beat_position, - beat_sample_index + TrackTiming::Beat { + pre_beat_position, + post_beat_position, + beat_sample_index, } => { if beat_sample_index > 0 { // Process samples before beat with current state @@ -129,8 +129,9 @@ impl Track { TrackState::Recording => { // Record input samples (manual recording) let samples_to_record = &input_buffer[start_index..end_index]; - self.audio_data.append_samples(samples_to_record, chunk_factory)?; - + self.audio_data + .append_samples(samples_to_record, chunk_factory)?; + // Output silence during recording output_buffer[start_index..end_index].fill(0.0); } @@ -138,25 +139,25 @@ impl Track { // Record input samples with auto-stop logic let samples_to_record = &input_buffer[start_index..end_index]; let current_length = self.audio_data.len(); - + if current_length < *target_samples { // Still recording - determine how many samples to actually record let samples_needed = *target_samples - current_length; let samples_to_append = samples_to_record.len().min(samples_needed); - + if samples_to_append > 0 { self.audio_data.append_samples( - &samples_to_record[..samples_to_append], - chunk_factory + &samples_to_record[..samples_to_append], + chunk_factory, )?; } - + // Check if we've reached target and should auto-transition if self.audio_data.len() >= *target_samples { self.next_state = TrackState::Playing; } } - + // Output silence during recording output_buffer[start_index..end_index].fill(0.0); } @@ -175,25 +176,25 @@ impl Track { /// Apply state transition from next_state to current_state /// Returns true if track should be consolidated and saved - fn apply_state_transition( - &mut self, - chunk_factory: &mut F - ) -> Result { + fn apply_state_transition(&mut self, chunk_factory: &mut F) -> Result { // Check if this is a recording → playing transition (consolidation trigger) let should_consolidate = matches!( (&self.current_state, &self.next_state), - (TrackState::Recording, TrackState::Playing) | - (TrackState::RecordingAutoStop { .. }, TrackState::Playing) + (TrackState::Recording, TrackState::Playing) + | (TrackState::RecordingAutoStop { .. }, TrackState::Playing) ); // Handle transitions that require setup match (&self.current_state, &self.next_state) { - (current_state, TrackState::Recording) if !matches!(current_state, TrackState::Recording) => { + (current_state, TrackState::Recording) + if !matches!(current_state, TrackState::Recording) => + { // Starting manual recording - clear previous data and create new unconsolidated data self.audio_data = AudioData::new_unconsolidated(chunk_factory, 0)?; } - (current_state, TrackState::RecordingAutoStop { sync_offset, .. }) - if !matches!(current_state, TrackState::RecordingAutoStop { .. }) => { + (current_state, TrackState::RecordingAutoStop { sync_offset, .. }) + if !matches!(current_state, TrackState::RecordingAutoStop { .. }) => + { // Starting auto-stop recording - clear previous data and create new unconsolidated data with offset self.audio_data = AudioData::new_unconsolidated(chunk_factory, *sync_offset)?; } @@ -215,7 +216,7 @@ impl Track { // Apply the state transition self.current_state = self.next_state.clone(); - + Ok(should_consolidate) } @@ -273,19 +274,31 @@ impl Track { pub fn queue_record_auto_stop(&mut self, target_samples: usize, sync_offset: usize) { match self.current_state { TrackState::Empty | TrackState::Idle => { - self.next_state = TrackState::RecordingAutoStop { target_samples, sync_offset }; + self.next_state = TrackState::RecordingAutoStop { + target_samples, + sync_offset, + }; } TrackState::Recording => { // Switch from manual to auto-stop recording - self.next_state = TrackState::RecordingAutoStop { target_samples, sync_offset }; + self.next_state = TrackState::RecordingAutoStop { + target_samples, + sync_offset, + }; } TrackState::RecordingAutoStop { .. } => { // Already auto-recording - update parameters - self.next_state = TrackState::RecordingAutoStop { target_samples, sync_offset }; + self.next_state = TrackState::RecordingAutoStop { + target_samples, + sync_offset, + }; } TrackState::Playing => { // Stop playing and start auto-recording - self.next_state = TrackState::RecordingAutoStop { target_samples, sync_offset }; + self.next_state = TrackState::RecordingAutoStop { + target_samples, + sync_offset, + }; } } } @@ -318,4 +331,4 @@ impl Track { pub fn queue_clear(&mut self) { self.next_state = TrackState::Empty; } -} \ No newline at end of file +}