From fc8ca5ce864fcd7e2a734a6dd5c40a8f554d7baa Mon Sep 17 00:00:00 2001 From: Geens Date: Mon, 9 Jun 2025 19:55:56 +0200 Subject: [PATCH] Added audio_data and RecordingAutoStop --- src/audio_data.rs | 465 +++++++++++++++++++++++++++++++++++++ src/audio_engine.rs | 2 + src/metronome.rs | 4 + src/midi.rs | 6 +- src/post_record_handler.rs | 68 ++++-- src/process_handler.rs | 42 ++-- src/track.rs | 193 ++++++++------- 7 files changed, 644 insertions(+), 136 deletions(-) create mode 100644 src/audio_data.rs diff --git a/src/audio_data.rs b/src/audio_data.rs new file mode 100644 index 0000000..2e2c7de --- /dev/null +++ b/src/audio_data.rs @@ -0,0 +1,465 @@ +use crate::*; + +/// Audio data representation supporting sync offsets for column-based timing +#[derive(Debug)] +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 + }, + + /// Consolidated single buffer, reordered for optimal playback + Consolidated { + buffer: Box<[f32]>, // single optimized array + }, +} + +impl AudioData { + /// Create new empty audio data + pub fn new_empty() -> Self { + Self::Empty + } + + /// Create new unconsolidated audio data from initial chunk + pub fn new_unconsolidated( + chunk_factory: &mut F, + sync_offset: usize, + ) -> Result { + Ok(Self::Unconsolidated { + chunks: chunk_factory.create_chunk()?, + sync_offset, + length: 0, + }) + } + + /// Get total length in samples + pub fn len(&self) -> usize { + match self { + Self::Empty => 0, + Self::Unconsolidated { length, .. } => *length, + 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, + samples: &[f32], + chunk_factory: &mut F, + ) -> Result<()> { + match self { + Self::Unconsolidated { chunks, length, .. } => { + chunks.append_samples(samples, chunk_factory)?; + *length += samples.len(); + Ok(()) + } + _ => 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( + &self, + output_slice: &mut [f32], + logical_position: usize, + volume: f32, + ) -> Result<()> { + match self { + Self::Empty => { + 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::Consolidated { buffer } => { + 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)) + } + _ => 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, .. } => { + if buffer.len() != *old_length { + return Err(LooperError::OutOfBounds(std::panic::Location::caller())); + } + *self = Self::Consolidated { buffer }; + Ok(()) + } + _ => Err(LooperError::OutOfBounds(std::panic::Location::caller())), + } + } + + /// Clear all audio data + pub fn clear(&mut self) -> Result<()> { + *self = Self::Empty; + Ok(()) + } +} + +impl AudioData { + /// Copy samples from unconsolidated chunks with sync offset handling + fn copy_unconsolidated_samples( + &self, + chunks: &Arc, + sync_offset: usize, + length: usize, + output_slice: &mut [f32], + mut logical_position: usize, + volume: f32, + ) -> Result<()> { + if length == 0 { + 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, + buffer: &[f32], + output_slice: &mut [f32], + mut logical_position: usize, + volume: f32, + ) -> Result<()> { + let length = buffer.len(); + if length == 0 { + 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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chunk_factory::mock::MockFactory; + + #[test] + fn test_new_empty() { + let audio_data = AudioData::new_empty(); + + assert_eq!(audio_data.len(), 0); + assert!(audio_data.is_empty()); + } + + #[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, .. } => { + 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(); + + 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(); + + 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 + + // 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 + + 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(); + + // 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]; + 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(); + + // 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(); + + 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(); + + // Should now be Consolidated variant + match audio_data { + AudioData::Consolidated { buffer } => { + assert_eq!(buffer.len(), 4); + } + _ => 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 07cea1a..184b142 100644 --- a/src/audio_engine.rs +++ b/src/audio_engine.rs @@ -1,5 +1,6 @@ mod allocator; mod audio_chunk; +mod audio_data; mod chunk_factory; mod connection_manager; mod looper_error; @@ -16,6 +17,7 @@ use std::sync::Arc; use allocator::Allocator; use audio_chunk::AudioChunk; +use audio_data::AudioData; use chunk_factory::ChunkFactory; use connection_manager::ConnectionManager; use looper_error::LooperError; diff --git a/src/metronome.rs b/src/metronome.rs index a5fb7e6..0c821df 100644 --- a/src/metronome.rs +++ b/src/metronome.rs @@ -21,6 +21,10 @@ impl Metronome { } } + pub fn samples_per_beat(&self) -> u32 { + self.samples_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> { diff --git a/src/midi.rs b/src/midi.rs index 6dd25cf..461ea62 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -45,6 +45,10 @@ pub fn process_events( // Button 2: Play/Mute process_handler.play_toggle()?; } + 22 => { + // Button 3: Auto-stop record + process_handler.record_auto_stop()?; + } 24 => { // Button 5: Clear track process_handler.clear_track()?; @@ -68,4 +72,4 @@ pub fn process_events( } Ok(()) -} +} \ No newline at end of file diff --git a/src/post_record_handler.rs b/src/post_record_handler.rs index 91d3b6c..97cd162 100644 --- a/src/post_record_handler.rs +++ b/src/post_record_handler.rs @@ -1,17 +1,18 @@ use crate::*; use std::path::PathBuf; -/// Request to process a recorded chunk chain +/// Request to process a recorded chunk chain with sync offset #[derive(Debug)] pub struct PostRecordRequest { pub chunk_chain: Arc, + pub sync_offset: u32, pub sample_rate: u32, } -/// Response containing the consolidated chunk +/// Response containing the consolidated buffer #[derive(Debug)] pub struct PostRecordResponse { - pub consolidated_chunk: Arc, + pub consolidated_buffer: Box<[f32]>, } /// RT-side interface for post-record operations @@ -23,8 +24,8 @@ pub struct PostRecordController { impl PostRecordController { /// Send a post-record processing request (RT-safe) - pub fn send_request(&self, chunk_chain: Arc, sample_rate: u32) -> Result<()> { - let request = PostRecordRequest { chunk_chain, 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 @@ -42,7 +43,7 @@ impl PostRecordController { } } -/// Handles post-record processing: consolidation and saving +/// Handles post-record processing: consolidation with sync offset and saving pub struct PostRecordHandler { request_receiver: kanal::AsyncReceiver, response_sender: kanal::AsyncSender, @@ -89,24 +90,27 @@ impl PostRecordHandler { &self, request: PostRecordRequest, ) -> Result<()> { - log::debug!("Processing post-record request for {} samples", - request.chunk_chain.len()); + log::debug!("Processing post-record request for {} samples with sync_offset {}", + request.chunk_chain.len(), request.sync_offset); - // Step 1: Consolidate chunk chain (CPU intensive) - let consolidated_chunk = AudioChunk::consolidate(&request.chunk_chain); + // 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 {} samples", consolidated_chunk.sample_count); - - // Step 2: Send consolidated chunk back to RT thread immediately - let response = PostRecordResponse { - consolidated_chunk: consolidated_chunk.clone(), - }; + log::debug!("Consolidated and reordered {} samples", consolidated_buffer.len()); + // Step 2: Send consolidated buffer back to RT thread immediately + let response = PostRecordResponse { consolidated_buffer }; + if let Err(_) = self.response_sender.send(response).await { - log::warn!("Failed to send consolidated chunk to RT thread"); + log::warn!("Failed to send consolidated buffer to RT thread"); } // Step 3: Save WAV file in background (I/O intensive) + // Use original chunk chain for saving (not reordered) + 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 { @@ -117,6 +121,36 @@ impl PostRecordHandler { Ok(()) } + /// Consolidate chunk chain and reorder samples based on sync offset + 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()); + } + + // Step 1: Extract all samples from the chunk chain into a temporary buffer + let mut all_samples = vec![0.0; total_length]; + chunk_chain.copy_samples(&mut all_samples, 0)?; + + // Step 2: Reorder samples based on sync offset + // The goal is to put the sample that represents "logical beat 1" at index 0 + if sync_offset == 0 { + // No offset - return samples as-is + return Ok(all_samples.into_boxed_slice()); + } + + 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; + reordered.push(all_samples[source_index]); + } + + Ok(reordered.into_boxed_slice()) + } + /// Save consolidated chunk as WAV file async fn save_wav_file( &self, diff --git a/src/process_handler.rs b/src/process_handler.rs index 98a23bb..4cfb959 100644 --- a/src/process_handler.rs +++ b/src/process_handler.rs @@ -1,5 +1,9 @@ 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 + pub struct ProcessHandler { track: Track, playback_position: usize, @@ -12,13 +16,13 @@ pub struct ProcessHandler { impl ProcessHandler { pub fn new( ports: JackPorts, - mut chunk_factory: F, + chunk_factory: F, beep_samples: Arc, state: &State, post_record_controller: PostRecordController, ) -> Result { Ok(Self { - track: Track::new(&mut chunk_factory)?, + track: Track::new(), playback_position: 0, ports, chunk_factory, @@ -39,6 +43,16 @@ impl ProcessHandler { Ok(()) } + /// 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 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); + Ok(()) + } + /// Handle clear button (Button 5) pub fn clear_track(&mut self) -> Result<()> { self.track.queue_clear(); @@ -97,26 +111,18 @@ impl jack::ProcessHandler for ProcessHandler { } impl ProcessHandler { - /// Handle post-record processing: send requests and swap chunks + /// Handle post-record processing: send requests and swap buffers fn handle_post_record_processing(&mut self, should_consolidate: bool, sample_rate: u32) -> Result<()> { - // Send chunk chain for processing if track indicates consolidation needed + // Send audio data for processing if track indicates consolidation needed if should_consolidate { - let chunk_chain = self.track.get_audio_chunk(); - log::debug!("Sending chunk chain with {} samples for post-record processing", - chunk_chain.len()); - - if let Err(e) = self.post_record_controller.send_request(chunk_chain, sample_rate) { - log::warn!("Failed to send post-record request: {}", e); + 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)?; } } // Check for consolidation response if let Some(response) = self.post_record_controller.try_recv_response() { - log::debug!("Received consolidated chunk with {} samples, swapping in track", - response.consolidated_chunk.len()); - - // Swap the consolidated chunk into the track - self.track.set_audio_chunk(response.consolidated_chunk)?; + self.track.set_consolidated_buffer(response.consolidated_buffer)?; } Ok(()) @@ -154,7 +160,7 @@ impl ProcessHandler { let state_after = self.track.next_state(); // Use next_state since transition hasn't happened yet match (state_before, state_after) { - (_, TrackState::Playing) if *state_before != TrackState::Playing => { + (_, TrackState::Playing) if !matches!(state_before, TrackState::Playing) => { // Just started playing - start from beginning // Note: In future Column implementation, this will be: // column.get_sync_position() to sync with other playing tracks @@ -197,7 +203,7 @@ impl ProcessHandler { } // Check if state transition at beat affects position - if state_after == TrackState::Playing && *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); } @@ -225,4 +231,4 @@ impl ProcessHandler { self.playback_position -= self.track.len(); } } -} +} \ No newline at end of file diff --git a/src/track.rs b/src/track.rs index 4bfe0c5..a6b78cc 100644 --- a/src/track.rs +++ b/src/track.rs @@ -5,7 +5,11 @@ pub enum TrackState { Empty, // No audio data (---) Idle, // Has data, not playing (READY) Playing, // Currently playing (PLAY) - Recording, // Currently recording (REC) + 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 + }, } #[derive(Debug)] @@ -21,22 +25,20 @@ pub enum TrackTiming { } pub struct Track { - audio_chunk: Arc, - length: usize, + audio_data: AudioData, current_state: TrackState, next_state: TrackState, volume: f32, } impl Track { - pub fn new(chunk_factory: &mut F) -> Result { - Ok(Self { - audio_chunk: chunk_factory.create_chunk()?, - length: 0, + pub fn new() -> Self { + Self { + audio_data: AudioData::new_empty(), current_state: TrackState::Empty, next_state: TrackState::Empty, volume: 1.0, - }) + } } /// Main audio processing method called from ProcessHandler @@ -119,25 +121,51 @@ impl Track { return Ok(()); } - match self.current_state { + match &mut self.current_state { TrackState::Empty | TrackState::Idle => { // Output silence for this range output_buffer[start_index..end_index].fill(0.0); } TrackState::Recording => { - // Record input samples + // Record input samples (manual recording) let samples_to_record = &input_buffer[start_index..end_index]; - self.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); + } + TrackState::RecordingAutoStop { target_samples, .. } => { + // 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 + )?; + } + + // 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); } TrackState::Playing => { // Playback with looping - self.copy_samples_to_output( + self.audio_data.copy_samples_to_output( &mut output_buffer[start_index..end_index], - sample_count, playback_position, + self.volume, )?; } } @@ -151,28 +179,34 @@ impl Track { &mut self, chunk_factory: &mut F ) -> Result { - // Check if this is a Recording → Playing transition (consolidation trigger) - let should_consolidate = self.current_state == TrackState::Recording - && self.next_state == TrackState::Playing; + // 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) + ); // Handle transitions that require setup match (&self.current_state, &self.next_state) { - (current_state, TrackState::Recording) if *current_state != TrackState::Recording => { - // Starting to record - clear previous data and create new chunk - self.clear_audio_data(chunk_factory)?; + (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 { .. }) => { + // Starting auto-stop recording - clear previous data and create new unconsolidated data with offset + self.audio_data = AudioData::new_unconsolidated(chunk_factory, *sync_offset)?; } (_, TrackState::Playing) => { // Starting playback - check if we have audio data - if self.length == 0 { + if self.audio_data.is_empty() { // No audio data - transition to Idle instead self.next_state = TrackState::Idle; } } (_, TrackState::Empty) => { // Clear operation - remove audio data - // Note: Actual deallocation will happen later via IO thread - self.audio_chunk = chunk_factory.create_chunk()?; - self.length = 0; + self.audio_data.clear()?; } _ => { // Other transitions don't require special handling @@ -185,80 +219,14 @@ impl Track { Ok(should_consolidate) } - /// Copy samples from track audio to output buffer with looping - fn copy_samples_to_output( - &self, - output_slice: &mut [f32], - sample_count: usize, - mut playback_position: usize, - ) -> Result<()> { - if self.length == 0 { - output_slice.fill(0.0); - return Ok(()); - } - - let mut samples_written = 0; - - while samples_written < sample_count { - let samples_remaining = sample_count - samples_written; - let samples_until_loop = self.length - playback_position; - let samples_to_copy = samples_remaining.min(samples_until_loop); - - // Copy directly from audio chunk to output slice - self.audio_chunk.copy_samples( - &mut output_slice[samples_written..samples_written + samples_to_copy], - playback_position, - )?; - - // Apply volume scaling in-place - for sample in &mut output_slice[samples_written..samples_written + samples_to_copy] { - *sample *= self.volume; - } - - samples_written += samples_to_copy; - playback_position += samples_to_copy; - - // Handle looping - if playback_position >= self.length { - playback_position = 0; - } - } - Ok(()) + /// Get audio data for post-record processing (returns chunk and sync offset) + pub fn get_audio_data_for_processing(&self) -> Option<(Arc, usize)> { + self.audio_data.get_chunk_for_processing() } - /// Append samples to the track's audio data - pub fn append_samples( - &mut self, - samples: &[f32], - chunk_factory: &mut F, - ) -> Result<()> { - self.audio_chunk.append_samples(samples, chunk_factory)?; - self.length += samples.len(); - Ok(()) - } - - /// Clear all audio data from the track - fn clear_audio_data( - &mut self, - chunk_factory: &mut F, - ) -> Result<()> { - self.audio_chunk = chunk_factory.create_chunk()?; - self.length = 0; - Ok(()) - } - - /// Get audio chunk for post-record processing (read-only access) - pub fn get_audio_chunk(&self) -> Arc { - self.audio_chunk.clone() - } - - /// Set audio chunk (for swapping in consolidated chunk) - pub fn set_audio_chunk(&mut self, chunk: Arc) -> Result<()> { - if chunk.len() != self.length { - return Err(LooperError::OutOfBounds(std::panic::Location::caller())); - } - self.audio_chunk = chunk; - Ok(()) + /// Set consolidated buffer (for swapping in consolidated audio data) + pub fn set_consolidated_buffer(&mut self, buffer: Box<[f32]>) -> Result<()> { + self.audio_data.set_consolidated_buffer(buffer) } // Public accessors and commands for MIDI handling @@ -271,7 +239,7 @@ impl Track { } pub fn len(&self) -> usize { - self.length + self.audio_data.len() } pub fn volume(&self) -> f32 { @@ -291,12 +259,37 @@ impl Track { TrackState::Recording => { self.next_state = TrackState::Playing; } + TrackState::RecordingAutoStop { .. } => { + // Auto-stop recording - can't manually stop, wait for auto-transition + self.next_state = self.current_state.clone(); + } TrackState::Playing => { self.next_state = TrackState::Idle; } } } + /// Handle auto-stop record command (sets next_state) + 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 }; + } + TrackState::Recording => { + // Switch from manual to auto-stop recording + 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 }; + } + TrackState::Playing => { + // Stop playing and start auto-recording + self.next_state = TrackState::RecordingAutoStop { target_samples, sync_offset }; + } + } + } + /// Handle play/mute toggle command (sets next_state) pub fn queue_play_toggle(&mut self) { match self.current_state { @@ -305,15 +298,15 @@ impl Track { self.next_state = TrackState::Empty; } TrackState::Idle => { - if self.length > 0 { + if !self.audio_data.is_empty() { self.next_state = TrackState::Playing; } else { self.next_state = TrackState::Idle; } } - TrackState::Recording => { + TrackState::Recording | TrackState::RecordingAutoStop { .. } => { // Don't change state while recording - self.next_state = TrackState::Recording; + self.next_state = self.current_state.clone(); } TrackState::Playing => { self.next_state = TrackState::Idle; @@ -325,4 +318,4 @@ impl Track { pub fn queue_clear(&mut self) { self.next_state = TrackState::Empty; } -} +} \ No newline at end of file