diff --git a/audio_engine/src/column.rs b/audio_engine/src/column.rs index cc2180a..00c62cc 100644 --- a/audio_engine/src/column.rs +++ b/audio_engine/src/column.rs @@ -118,14 +118,17 @@ impl Column { chunk_factory: &mut impl ChunkFactory, controllers: &ColumnControllers, ) -> Result<()> { - let len = self.len(); + let old_len = self.len(); + if self.idle() { if let Some(beat_index) = timing.beat_in_buffer { - let idle_time = input_buffer.len() - beat_index as usize; - if len == 0 { - self.playback_position = self.frames_per_beat - idle_time; + // When starting from idle, always start at beat 0 (beginning of first beat) + // regardless of where in the buffer the beat occurs + if old_len == 0 { + self.playback_position = 0; // Start at beat 0 for first recording } else { - self.playback_position = len - idle_time; + let idle_time = input_buffer.len() - beat_index as usize; + self.playback_position = old_len - idle_time; } } } @@ -147,13 +150,38 @@ impl Column { } } - let len = self.len(); - if len > 0 { - self.playback_position = (self.playback_position + input_buffer.len()) % self.len(); - } else { - self.playback_position = - (self.playback_position + input_buffer.len()) % self.frames_per_beat; + let new_len = self.len(); + + if old_len == 0 && new_len > 0 { + let beats = new_len / self.frames_per_beat; + controllers.send_column_beats_changed(beats)?; } + + // Update playback position + if new_len > 0 { + self.playback_position = (self.playback_position + input_buffer.len()) % new_len; + } else { + // During recording of first track, don't use modulo - let position grow + // so that beat calculation works correctly + self.playback_position += input_buffer.len(); + } + + // Send beat change message if a beat occurred in this buffer + if timing.beat_in_buffer.is_some() { + // Calculate which beat we're entering (after the beat that just occurred) + let current_beat = if new_len > 0 { + // When column has defined length, calculate beat within that loop + let column_beats = new_len / self.frames_per_beat; + (self.playback_position / self.frames_per_beat) % column_beats + } else { + // During recording of first track, calculate beat based on absolute position + // The beat that just occurred is the beat we're now in + self.playback_position / self.frames_per_beat + }; + + controllers.send_column_beat_changed(current_beat)?; + } + Ok(()) } } diff --git a/audio_engine/src/controllers.rs b/audio_engine/src/controllers.rs index 4709853..3dbba3c 100644 --- a/audio_engine/src/controllers.rs +++ b/audio_engine/src/controllers.rs @@ -65,6 +65,18 @@ impl<'a> ColumnControllers<'a> { row, } } + + pub fn send_column_beats_changed(&self, beats: usize) -> Result<()> { + self.osc.column_beats_changed(self.column, beats) + } + + pub fn send_column_beat_changed(&self, beat: usize) -> Result<()> { + self.osc.column_beat_changed(self.column, beat) + } + + pub fn column(&self) -> usize { + self.column + } } impl<'a> TrackControllers<'a> { diff --git a/audio_engine/src/main.rs b/audio_engine/src/main.rs index 3419609..02e3d96 100644 --- a/audio_engine/src/main.rs +++ b/audio_engine/src/main.rs @@ -84,10 +84,10 @@ async fn main() { let state = persistence_manager.state(); // Calculate tempo from state and jack client - let tempo_bpm = (jack_client.sample_rate() as f32 * 60.0) - / state.borrow().metronome.frames_per_beat as f32; + let tempo_bpm = + (jack_client.sample_rate() as f32 * 60.0) / state.borrow().metronome.frames_per_beat as f32; - let (mut osc, osc_controller) = Osc::new(&args.socket, COLS, ROWS, tempo_bpm) + let (mut osc, osc_controller) = Osc::::new(&args.socket, tempo_bpm) .await .expect("Could not create OSC server"); @@ -190,4 +190,4 @@ fn setup_jack() -> (jack::Client, JackPorts) { }; (jack_client, ports) -} \ No newline at end of file +} diff --git a/audio_engine/src/osc_server/controller.rs b/audio_engine/src/osc_server/controller.rs index d25ad6a..4937df0 100644 --- a/audio_engine/src/osc_server/controller.rs +++ b/audio_engine/src/osc_server/controller.rs @@ -62,4 +62,22 @@ impl OscController { Err(_) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel closed } } + + pub fn column_beats_changed(&self, column: usize, beats: usize) -> Result<()> { + let message = osc::Message::ColumnBeatsChanged { column, beats }; + match self.sender.try_send(message) { + Ok(true) => Ok(()), + Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full + Err(_) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel closed + } + } + + pub fn column_beat_changed(&self, column: usize, beat: usize) -> Result<()> { + let message = osc::Message::ColumnBeatChanged { column, beat }; + match self.sender.try_send(message) { + Ok(true) => Ok(()), + Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full + Err(_) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel closed + } + } } diff --git a/audio_engine/src/osc_server/server.rs b/audio_engine/src/osc_server/server.rs index 53e84c7..cc5518c 100644 --- a/audio_engine/src/osc_server/server.rs +++ b/audio_engine/src/osc_server/server.rs @@ -3,21 +3,16 @@ use futures::SinkExt; const CLIENT_BUFFER_SIZE: usize = 32; -pub struct Osc { +pub struct Osc { receiver: kanal::AsyncReceiver, listener: osc_server::platform::PlatformListener, broadcaster: tokio::sync::broadcast::Sender, - shadow_state: osc::State, + shadow_state: osc::State, } -impl Osc { - /// Create new OSC server and controller with configurable matrix size and tempo - pub async fn new( - socket_path: &str, - columns: usize, - rows: usize, - tempo: f32, - ) -> Result<(Self, OscController)> { +impl Osc { + /// Create new OSC server and controller with configurable tempo + pub async fn new(socket_path: &str, tempo: f32) -> Result<(Self, OscController)> { // Create platform listener (server) let listener = osc_server::platform::create_listener(socket_path).await?; @@ -32,7 +27,7 @@ impl Osc { receiver, listener, broadcaster, - shadow_state: osc::State::new(columns, rows, tempo), + shadow_state: osc::State::new(tempo), }; Ok((server, controller)) @@ -110,4 +105,4 @@ impl Osc { Ok(()) } -} \ No newline at end of file +} diff --git a/audio_engine/src/process_handler.rs b/audio_engine/src/process_handler.rs index f10fa7d..63c5d83 100644 --- a/audio_engine/src/process_handler.rs +++ b/audio_engine/src/process_handler.rs @@ -150,7 +150,9 @@ impl ProcessHandler jack::ProcessHandler for ProcessHandler { +impl jack::ProcessHandler + for ProcessHandler +{ fn process(&mut self, _client: &jack::Client, ps: &jack::ProcessScope) -> jack::Control { if let Err(e) = self.process_with_error_handling(ps) { log::error!("Error processing audio: {}", e); @@ -181,4 +183,4 @@ impl ProcessHandler Interpolated { + pub fn update( + &mut self, + state: &osc::State, + current_time: Instant, + ) -> Interpolated { // The OSC state is the source of truth for the base position and tempo. let base_position = state.metronome_position; let base_timestamp = state.metronome_timestamp; @@ -80,22 +84,22 @@ mod interpolation_without_state_update_tests { fn test_simple_forward_interpolation() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.2; state.metronome_timestamp = start_time; - + // First update at start time - should return the received position let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.2).abs() < 0.001); assert!(!result1.beat_boundary); - + // Second update 100ms later with same OSC state (no new OSC update) let elapsed_duration = Duration::from_millis(100); let later_time = start_time + elapsed_duration; let result2 = interpolation.update(&state, later_time); - + // Calculate expected position // At 120 BPM: beat_duration = 60.0 / 120.0 = 0.5 seconds // elapsed = 0.1 seconds = 0.1 / 0.5 = 0.2 beat progress @@ -109,31 +113,31 @@ mod interpolation_without_state_update_tests { fn test_interpolation_with_incremented_time_no_beat() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.2; state.metronome_timestamp = start_time; - + // First update at start time let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.2).abs() < 0.001); assert!(!result1.beat_boundary); - + // Second update 100ms later with same OSC state (no new OSC update) let elapsed_duration = Duration::from_millis(100); let later_time = start_time + elapsed_duration; let result2 = interpolation.update(&state, later_time); - + // Expected position: 0.2 + (0.1 / 0.5) = 0.4 let expected_position = 0.4; assert!((result2.position_in_beat - expected_position).abs() < 0.001); assert!(!result2.beat_boundary); - + // Third update with small time increment (1ms later) - should be nearly identical let slightly_later_time = later_time + Duration::from_millis(1); let result3 = interpolation.update(&state, slightly_later_time); - + // Position should advance only slightly: 1ms / 500ms = 0.002 let expected_position_3 = 0.4 + 0.002; assert!((result3.position_in_beat - expected_position_3).abs() < 0.001); @@ -144,27 +148,27 @@ mod interpolation_without_state_update_tests { fn test_interpolation_with_identical_timestamps_no_beat() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.2; state.metronome_timestamp = start_time; - + // First update at start time let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.2).abs() < 0.001); assert!(!result1.beat_boundary); - + // Second update 100ms later with same OSC state (no new OSC update) let elapsed_duration = Duration::from_millis(100); let later_time = start_time + elapsed_duration; let result2 = interpolation.update(&state, later_time); - + // Expected position: 0.2 + (0.1 / 0.5) = 0.4 let expected_position = 0.4; assert!((result2.position_in_beat - expected_position).abs() < 0.001); assert!(!result2.beat_boundary); - + // Third update with identical parameters as result2 - should be identical let result3 = interpolation.update(&state, later_time); assert!((result3.position_in_beat - result2.position_in_beat).abs() < 0.001); @@ -175,17 +179,17 @@ mod interpolation_without_state_update_tests { fn test_beat_boundary_detected_on_position_wraparound() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM, starting near end of beat - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.8; state.metronome_timestamp = start_time; - + // First update at start time let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.8).abs() < 0.001); assert!(!result1.beat_boundary); - + // Second update 150ms later - should cross beat boundary // At 120 BPM: beat_duration = 0.5 seconds // Time to reach boundary from 0.8: (1.0 - 0.8) * 0.5 = 0.1 seconds = 100ms @@ -194,7 +198,7 @@ mod interpolation_without_state_update_tests { let elapsed_duration = Duration::from_millis(150); let later_time = start_time + elapsed_duration; let result2 = interpolation.update(&state, later_time); - + assert!(result2.beat_boundary); // Should detect boundary crossing assert!((result2.position_in_beat - 0.1).abs() < 0.001); // Wrapped around } @@ -203,31 +207,31 @@ mod interpolation_without_state_update_tests { fn test_interpolation_with_incremented_time_with_beat() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM, starting near end of beat - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.8; state.metronome_timestamp = start_time; - + // First update at start time let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.8).abs() < 0.001); assert!(!result1.beat_boundary); - + // Second update 150ms later - should cross beat boundary let elapsed_duration = Duration::from_millis(150); let later_time = start_time + elapsed_duration; let result2 = interpolation.update(&state, later_time); - + assert!(result2.beat_boundary); // Should detect boundary crossing assert!((result2.position_in_beat - 0.1).abs() < 0.001); // Wrapped around - + // Third update with small time increment (1ms later) - should NOT detect boundary again let slightly_later_time = later_time + Duration::from_millis(1); let result3 = interpolation.update(&state, slightly_later_time); - + assert!(!result3.beat_boundary); // Boundary already detected, don't fire again - // Position should advance only slightly: 1ms / 500ms = 0.002 + // Position should advance only slightly: 1ms / 500ms = 0.002 let expected_position_3 = 0.1 + 0.002; assert!((result3.position_in_beat - expected_position_3).abs() < 0.001); } @@ -236,30 +240,31 @@ mod interpolation_without_state_update_tests { fn test_interpolation_with_identical_timestamps_with_beat() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM, starting near end of beat - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.8; state.metronome_timestamp = start_time; - + // First update at start time let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.8).abs() < 0.001); assert!(!result1.beat_boundary); - + // Second update 150ms later - should cross beat boundary let elapsed_duration = Duration::from_millis(150); let later_time = start_time + elapsed_duration; let result2 = interpolation.update(&state, later_time); - + assert!(result2.beat_boundary); // Should detect boundary crossing assert!((result2.position_in_beat - 0.1).abs() < 0.001); // Wrapped around - + // Third update with identical parameters - should NOT detect boundary again let result3 = interpolation.update(&state, later_time); - + assert!(!result3.beat_boundary); // Boundary already detected, don't fire again - assert!((result3.position_in_beat - result2.position_in_beat).abs() < 0.001); // Same position + assert!((result3.position_in_beat - result2.position_in_beat).abs() < 0.001); + // Same position } } @@ -272,42 +277,42 @@ mod state_update_tests { fn test_osc_update_matches_interpolated_position_exactly() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.2; state.metronome_timestamp = start_time; - + // First update at start time - establish baseline let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.2).abs() < 0.001); assert!(!result1.beat_boundary); - + // Second update 100ms later with same OSC state (interpolation only) let elapsed_duration = Duration::from_millis(100); let later_time = start_time + elapsed_duration; let result2 = interpolation.update(&state, later_time); - + // At 120 BPM: beat_duration = 0.5 seconds // elapsed = 0.1 seconds = 0.1 / 0.5 = 0.2 beat progress // expected_position = 0.2 + 0.2 = 0.4 let expected_position = 0.4; assert!((result2.position_in_beat - expected_position).abs() < 0.001); assert!(!result2.beat_boundary); - + // Third update: OSC update arrives at same time with exact matching position state.metronome_position = 0.4; // Matches our interpolated position state.metronome_timestamp = later_time; // Update timestamp to current time let result3 = interpolation.update(&state, later_time); - + // Should maintain the same position smoothly, no beat boundary assert!((result3.position_in_beat - 0.4).abs() < 0.001); assert!(!result3.beat_boundary); - + // Fourth update: Small time increment after OSC sync let slightly_later_time = later_time + Duration::from_millis(10); let result4 = interpolation.update(&state, slightly_later_time); - + // Should continue interpolating from the OSC-synchronized position // 10ms = 0.01 seconds = 0.01 / 0.5 = 0.02 beat progress let expected_position_4 = 0.4 + 0.02; @@ -319,41 +324,41 @@ mod state_update_tests { fn test_osc_update_arrives_slightly_late_no_beat() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.3; state.metronome_timestamp = start_time; - + // First update at start time - establish baseline let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.3).abs() < 0.001); assert!(!result1.beat_boundary); - + // We expect to interpolate for 100ms to reach position 0.5 // But OSC update arrives 20ms late (timestamp from 80ms ago) with position 0.46 let late_osc_time = start_time + Duration::from_millis(80); let current_time = start_time + Duration::from_millis(100); - + // At 120 BPM: beat_duration = 0.5 seconds // At 80ms: expected position = 0.3 + (0.08 / 0.5) = 0.3 + 0.16 = 0.46 // OSC reports 0.46 (matches expectation but arrives late) state.metronome_position = 0.46; state.metronome_timestamp = late_osc_time; // OSC timestamp is 80ms (20ms ago) - + // Update called at 100ms with OSC data from 80ms let result2 = interpolation.update(&state, current_time); - + // Should adjust to OSC truth plus interpolation for the 20ms difference // From OSC position 0.46 at 80ms, advance 20ms: 0.46 + (0.02 / 0.5) = 0.46 + 0.04 = 0.50 let expected_position = 0.50; assert!((result2.position_in_beat - expected_position).abs() < 0.001); assert!(!result2.beat_boundary); // No beat boundary in this scenario - + // Third update: Continue normally from corrected baseline let later_time = current_time + Duration::from_millis(60); let result3 = interpolation.update(&state, later_time); - + // Should continue interpolating from the corrected position // 60ms = 0.06 seconds = 0.06 / 0.5 = 0.12 beat progress let expected_position_3 = 0.50 + 0.12; // = 0.62 @@ -365,39 +370,39 @@ mod state_update_tests { fn test_osc_update_arrives_late_confirms_detected_boundary() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM, starting near end of beat - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.9; state.metronome_timestamp = start_time; - + // First update at start time - establish baseline let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.9).abs() < 0.001); assert!(!result1.beat_boundary); - + // Second update: Interpolate past beat boundary (should detect it) let boundary_cross_time = start_time + Duration::from_millis(80); let result2 = interpolation.update(&state, boundary_cross_time); - + // At 120 BPM: beat_duration = 0.5 seconds // At 80ms: position = 0.9 + (0.08 / 0.5) = 0.9 + 0.16 = 1.06 → wraps to 0.06 assert!(result2.beat_boundary); // Should detect boundary crossing assert!((result2.position_in_beat - 0.06).abs() < 0.001); - + // Third update: Late OSC arrives confirming we're in next beat let late_osc_time = start_time + Duration::from_millis(70); // 10ms ago let current_time = start_time + Duration::from_millis(90); - + // OSC confirms we're in next beat with position 0.04 at 70ms state.metronome_position = 0.04; state.metronome_timestamp = late_osc_time; - + let result3 = interpolation.update(&state, current_time); - + // Should NOT detect beat boundary again (already detected) assert!(!result3.beat_boundary); - + // Should show current position: OSC 0.04 at 70ms + 20ms interpolation // 20ms = 0.02 seconds = 0.02 / 0.5 = 0.04 beat progress let expected_position = 0.04 + 0.04; // = 0.08 @@ -408,39 +413,39 @@ mod state_update_tests { fn test_osc_update_arrives_late_shows_missed_boundary() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM, starting near end of beat - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.85; state.metronome_timestamp = start_time; - + // First update at start time - establish baseline let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.85).abs() < 0.001); assert!(!result1.beat_boundary); - + // Second update: Interpolate but don't cross boundary yet let no_boundary_time = start_time + Duration::from_millis(60); let result2 = interpolation.update(&state, no_boundary_time); - + // At 120 BPM: beat_duration = 0.5 seconds // At 60ms: position = 0.85 + (0.06 / 0.5) = 0.85 + 0.12 = 0.97 assert!(!result2.beat_boundary); // Should NOT detect boundary yet assert!((result2.position_in_beat - 0.97).abs() < 0.001); - + // Third update: Late OSC arrives showing we're actually in next beat - let late_osc_time = start_time + Duration::from_millis(70); // 20ms ago + let late_osc_time = start_time + Duration::from_millis(70); // 20ms ago let current_time = start_time + Duration::from_millis(90); - + // OSC shows we're at position 0.1 in next beat, meaning boundary was crossed state.metronome_position = 0.1; state.metronome_timestamp = late_osc_time; - + let result3 = interpolation.update(&state, current_time); - + // Should retroactively detect the missed beat boundary assert!(result3.beat_boundary); - + // Should show current position: OSC 0.1 at 70ms + 20ms interpolation // 20ms = 0.02 seconds = 0.02 / 0.5 = 0.04 beat progress let expected_position = 0.1 + 0.04; // = 0.14 @@ -451,44 +456,48 @@ mod state_update_tests { fn test_osc_update_arrives_late_contradicts_detected_boundary() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM, starting near end of beat - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.92; state.metronome_timestamp = start_time; - + // First update at start time - establish baseline let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.92).abs() < 0.001); assert!(!result1.beat_boundary); - + // Second update: Interpolate past beat boundary (should detect it) let boundary_cross_time = start_time + Duration::from_millis(90); let result2 = interpolation.update(&state, boundary_cross_time); - + // At 120 BPM: beat_duration = 0.5 seconds // At 90ms: position = 0.92 + (0.09 / 0.5) = 0.92 + 0.18 = 1.10 → wraps to 0.10 assert!(result2.beat_boundary); // Should detect boundary crossing assert!((result2.position_in_beat - 0.10).abs() < 0.001); - + // Third update: Late OSC arrives contradicting our boundary detection let late_osc_time = start_time + Duration::from_millis(80); // 20ms ago let current_time = start_time + Duration::from_millis(100); - + // OSC shows we're still at position 0.98 in same beat (boundary not yet crossed) state.metronome_position = 0.98; state.metronome_timestamp = late_osc_time; - + let result3 = interpolation.update(&state, current_time); - + // Should trust OSC data and NOT report beat boundary for this update // (The previous boundary detection was premature/incorrect) assert!(!result3.beat_boundary); - + // Should show current position: OSC 0.98 at 80ms + 20ms interpolation // 20ms = 0.02 seconds = 0.02 / 0.5 = 0.04 beat progress let expected_position = 0.98 + 0.04; // = 1.02 → wraps to 0.02 - let wrapped_position = if expected_position >= 1.0 { expected_position - 1.0 } else { expected_position }; + let wrapped_position = if expected_position >= 1.0 { + expected_position - 1.0 + } else { + expected_position + }; assert!((result3.position_in_beat - wrapped_position).abs() < 0.001); } @@ -496,42 +505,42 @@ mod state_update_tests { fn test_osc_update_arrives_very_late() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.2; state.metronome_timestamp = start_time; - + // First update at start time - establish baseline let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.2).abs() < 0.001); assert!(!result1.beat_boundary); - + // OSC update arrives extremely late (600ms = more than one beat duration of 500ms) let very_late_osc_time = start_time + Duration::from_millis(200); // OSC timestamp let current_time = start_time + Duration::from_millis(800); // Current time (600ms later) - + // At 120 BPM: beat_duration = 0.5 seconds = 500ms // Gap of 600ms is more than one full beat, should trigger resync state.metronome_position = 0.6; state.metronome_timestamp = very_late_osc_time; - + let result2 = interpolation.update(&state, current_time); - + // For very late updates, should do complete resync rather than huge interpolation // Should base position on OSC data and the time difference, but handle gracefully // 600ms gap = 600ms / 500ms = 1.2 beats = 1 full beat + 0.2 beat // From OSC position 0.6, advance 0.2 beats: 0.6 + 0.2 = 0.8 let expected_position = 0.8; assert!((result2.position_in_beat - expected_position).abs() < 0.1); // Looser tolerance for edge case - + // May or may not detect beat boundary depending on implementation strategy // The key is that it shouldn't crash or produce wildly incorrect results - + // Third update: Should continue normally after resync let later_time = current_time + Duration::from_millis(50); let result3 = interpolation.update(&state, later_time); - + // Should continue interpolating smoothly from the resynced position // 50ms = 0.05 seconds = 0.05 / 0.5 = 0.1 beat progress let expected_position_3 = result2.position_in_beat + 0.1; @@ -547,41 +556,41 @@ mod state_update_tests { fn test_osc_update_arrives_slightly_early_no_beat() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.2; state.metronome_timestamp = start_time; - + // First update at start time - establish baseline let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.2).abs() < 0.001); assert!(!result1.beat_boundary); - + // We expect to interpolate for 100ms to reach position 0.4 // But OSC update arrives 10ms early (at 90ms) with position 0.36 let early_time = start_time + Duration::from_millis(90); let current_time = start_time + Duration::from_millis(100); - + // At 120 BPM: beat_duration = 0.5 seconds // At 90ms: expected position = 0.2 + (0.09 / 0.5) = 0.2 + 0.18 = 0.38 // But let's say OSC reports 0.36 (slightly behind due to processing delays) state.metronome_position = 0.36; state.metronome_timestamp = early_time; // OSC timestamp is 90ms - + // Update called at 100ms with OSC data from 90ms let result2 = interpolation.update(&state, current_time); - + // Should adjust to OSC truth plus interpolation for the 10ms difference // From OSC position 0.36 at 90ms, advance 10ms: 0.36 + (0.01 / 0.5) = 0.36 + 0.02 = 0.38 let expected_position = 0.38; assert!((result2.position_in_beat - expected_position).abs() < 0.001); assert!(!result2.beat_boundary); // No beat boundary in this scenario - + // Third update: Continue normally from corrected baseline let later_time = current_time + Duration::from_millis(50); let result3 = interpolation.update(&state, later_time); - + // Should continue interpolating from the corrected position // 50ms = 0.05 seconds = 0.05 / 0.5 = 0.1 beat progress let expected_position_3 = 0.38 + 0.1; // = 0.48 @@ -593,44 +602,44 @@ mod state_update_tests { fn test_osc_update_arrives_slightly_early_with_past_beat() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM, starting near end of beat - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.8; state.metronome_timestamp = start_time; - + // First update at start time - establish baseline let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.8).abs() < 0.001); assert!(!result1.beat_boundary); - + // We're going to interpolate for 80ms, which should put us at position 0.96 // But OSC update arrives showing we're actually at position 0.05 in the next beat // This means the beat boundary was crossed somewhere in between let update_time = start_time + Duration::from_millis(80); let current_time = start_time + Duration::from_millis(90); - + // At 120 BPM: beat_duration = 0.5 seconds // Expected interpolated position at 80ms: 0.8 + (0.08 / 0.5) = 0.8 + 0.16 = 0.96 // But OSC shows position 0.05, meaning we crossed beat boundary and are 0.05 into next beat state.metronome_position = 0.05; state.metronome_timestamp = update_time; // OSC timestamp is 80ms - + // Update called at 90ms with OSC data from 80ms let result2 = interpolation.update(&state, current_time); - + // Should detect that a beat boundary was crossed (based on OSC position being < previous) assert!(result2.beat_boundary); - + // Should show current position: OSC position 0.05 at 80ms + 10ms interpolation // 10ms = 0.01 seconds = 0.01 / 0.5 = 0.02 beat progress let expected_position = 0.05 + 0.02; // = 0.07 assert!((result2.position_in_beat - expected_position).abs() < 0.001); - + // Third update: Continue normally, should NOT detect beat boundary again let later_time = current_time + Duration::from_millis(50); let result3 = interpolation.update(&state, later_time); - + // Should continue interpolating from the corrected position // 50ms = 0.05 seconds = 0.05 / 0.5 = 0.1 beat progress let expected_position_3 = 0.07 + 0.1; // = 0.17 @@ -642,53 +651,53 @@ mod state_update_tests { fn test_osc_update_arrives_slightly_early_with_beat_now() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM, starting near end of beat - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.85; state.metronome_timestamp = start_time; - + // First update at start time - establish baseline let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.85).abs() < 0.001); assert!(!result1.beat_boundary); - + // We're going to interpolate for 60ms, which should put us at position 0.97 // But OSC update arrives 10ms early showing position 0.0 (beat boundary at OSC time) let osc_time = start_time + Duration::from_millis(60); let current_time = start_time + Duration::from_millis(70); - + // At 120 BPM: beat_duration = 0.5 seconds // Expected interpolated position at 60ms: 0.85 + (0.06 / 0.5) = 0.85 + 0.12 = 0.97 // But OSC shows position 0.0, meaning beat boundary occurred at the OSC timestamp state.metronome_position = 0.0; state.metronome_timestamp = osc_time; // OSC timestamp is 60ms (10ms ago) - + // Update called at 70ms with OSC data from 60ms let result2 = interpolation.update(&state, current_time); - + // Should detect beat boundary (OSC shows we hit 0.0 at its timestamp) assert!(result2.beat_boundary); - + // Should show current position: OSC position 0.0 at 60ms + 10ms interpolation // 10ms = 0.01 seconds = 0.01 / 0.5 = 0.02 beat progress let expected_position = 0.0 + 0.02; // = 0.02 assert!((result2.position_in_beat - expected_position).abs() < 0.001); - + // Third update: Continue normally, should NOT detect beat boundary again let later_time = current_time + Duration::from_millis(40); let result3 = interpolation.update(&state, later_time); - + // Should continue interpolating from the corrected position // 40ms = 0.04 seconds = 0.04 / 0.5 = 0.08 beat progress let expected_position_3 = 0.02 + 0.08; // = 0.10 assert!((result3.position_in_beat - expected_position_3).abs() < 0.001); assert!(!result3.beat_boundary); // No boundary on this update - + // Fourth update: Verify smooth continuation let even_later_time = later_time + Duration::from_millis(25); let result4 = interpolation.update(&state, even_later_time); - + // 25ms = 0.025 seconds = 0.025 / 0.5 = 0.05 beat progress let expected_position_4 = 0.10 + 0.05; // = 0.15 assert!((result4.position_in_beat - expected_position_4).abs() < 0.001); @@ -699,47 +708,47 @@ mod state_update_tests { fn test_osc_update_at_beat_boundary_exact_match() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - + // Create initial state with 120 BPM, starting near end of beat - let mut state = osc::State::new(5, 5, 120.0); + let mut state = osc::State::<5, 5>::new(120.0); state.metronome_position = 0.9; state.metronome_timestamp = start_time; - + // First update at start time - establish baseline let result1 = interpolation.update(&state, start_time); assert!((result1.position_in_beat - 0.9).abs() < 0.001); assert!(!result1.beat_boundary); - + // Calculate when boundary should occur // At 120 BPM: beat_duration = 0.5 seconds // Time to reach boundary from 0.9: (1.0 - 0.9) * 0.5 = 0.05 seconds = 50ms let boundary_time = start_time + Duration::from_millis(50); - + // Second update: OSC update arrives exactly at calculated boundary time state.metronome_position = 0.0; // Confirms we've crossed to new beat state.metronome_timestamp = boundary_time; let result2 = interpolation.update(&state, boundary_time); - + // Should detect beat boundary and show position 0.0 assert!(result2.beat_boundary); assert!((result2.position_in_beat - 0.0).abs() < 0.001); - + // Third update: Continue 50ms later (should be at position 0.1 in new beat) let later_time = boundary_time + Duration::from_millis(50); let result3 = interpolation.update(&state, later_time); - + // Should NOT detect beat boundary again, and should be at position 0.1 assert!(!result3.beat_boundary); let expected_position = 0.1; // 50ms / 500ms = 0.1 assert!((result3.position_in_beat - expected_position).abs() < 0.001); - + // Fourth update: Small increment to verify smooth continuation let slightly_later_time = later_time + Duration::from_millis(10); let result4 = interpolation.update(&state, slightly_later_time); - + // Should continue smoothly without boundary detection assert!(!result4.beat_boundary); let expected_position_4 = 0.1 + 0.02; // 10ms = 0.02 beat progress assert!((result4.position_in_beat - expected_position_4).abs() < 0.001); } -} \ No newline at end of file +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 2231940..104b4be 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -6,6 +6,9 @@ mod ui; use anyhow::anyhow; use anyhow::Result; +const COLUMNS: usize = 5; +const ROWS: usize = 5; + trait DisplayStr { fn display_str(&self) -> &'static str; } @@ -25,18 +28,17 @@ impl DisplayStr for osc::TrackState { async fn main() -> Result<()> { // Configuration let socket_path = "fcb_looper.sock"; - let columns = 5; - let rows = 5; // Logger simple_logger::SimpleLogger::new() .with_level(log::LevelFilter::Info) .with_module_level("gui::osc_client", log::LevelFilter::Off) + .with_module_level("osc", log::LevelFilter::Debug) .init() .expect("Could not initialize logger"); // Create OSC client - let osc_client = osc_client::Client::new(socket_path, columns, rows)?; + let osc_client = osc_client::Client::::new(socket_path)?; let state_receiver = osc_client.state_receiver(); // Spawn OSC receiver thread on tokio runtime @@ -58,7 +60,7 @@ async fn main() -> Result<()> { eframe::run_native( "FCB Looper", options, - Box::new(|_cc| Ok(Box::new(ui::App::new(state_receiver)))), + Box::new(|_cc| Ok(Box::new(ui::App::::new(state_receiver)))), ) .map_err(|e| anyhow::anyhow!("Failed to run egui: {}", e))?; diff --git a/gui/src/osc_client.rs b/gui/src/osc_client.rs index 650d2db..18df91d 100644 --- a/gui/src/osc_client.rs +++ b/gui/src/osc_client.rs @@ -15,32 +15,33 @@ type PlatformStream = UnixStream; #[cfg(windows)] type PlatformStream = NamedPipeClient; -pub struct Client { +pub struct Client { socket_path: String, - state_sender: watch::Sender, - state_receiver: watch::Receiver, + state_sender: watch::Sender>, + state_receiver: watch::Receiver>, } -impl Client { - pub fn new(socket_path: &str, columns: usize, rows: usize) -> Result { - let initial_state = osc::State::new(columns, rows, 120.0); +impl Client { + pub fn new(socket_path: &str) -> Result { + let initial_state = osc::State::new(120.0); // Test data let initial_state = { let mut initial_state = initial_state; - initial_state.selected_column = 2; - initial_state.selected_row = 3; - initial_state.cells[0][0].state = osc::TrackState::Playing; - initial_state.cells[0][0].volume = 0.3; - initial_state.cells[0][1].state = osc::TrackState::Idle; - initial_state.cells[0][1].volume = 1.0; - initial_state.cells[1][0].state = osc::TrackState::Idle; - initial_state.cells[1][0].volume = 0.9; - initial_state.cells[1][1].state = osc::TrackState::Playing; - initial_state.cells[1][1].volume = 0.8; - initial_state.cells[2][0].state = osc::TrackState::Idle; - initial_state.cells[2][0].volume = 0.7; - initial_state.cells[2][3].state = osc::TrackState::Recording; - initial_state.cells[2][3].volume = 0.6; + initial_state.selected_column = if COLS > 2 { 2 } else { 0 }; + initial_state.selected_row = if ROWS > 3 { 3 } else { 0 }; + + initial_state.columns[0].tracks[0].state = osc::TrackState::Playing; + initial_state.columns[0].tracks[0].volume = 0.3; + initial_state.columns[0].tracks[1].state = osc::TrackState::Idle; + initial_state.columns[0].tracks[1].volume = 1.0; + initial_state.columns[1].tracks[0].state = osc::TrackState::Idle; + initial_state.columns[1].tracks[0].volume = 0.9; + initial_state.columns[1].tracks[1].state = osc::TrackState::Playing; + initial_state.columns[1].tracks[1].volume = 0.8; + initial_state.columns[2].tracks[0].state = osc::TrackState::Idle; + initial_state.columns[2].tracks[0].volume = 0.7; + initial_state.columns[2].tracks[3].state = osc::TrackState::Recording; + initial_state.columns[2].tracks[3].volume = 0.6; initial_state }; @@ -53,7 +54,7 @@ impl Client { }) } - pub fn state_receiver(&self) -> watch::Receiver { + pub fn state_receiver(&self) -> watch::Receiver> { self.state_receiver.clone() } @@ -86,12 +87,16 @@ impl Client { Ok(()) } - async fn handle_osc_data(&self, data: Bytes, current_state: &mut osc::State) -> Result<()> { + async fn handle_osc_data( + &self, + data: Bytes, + current_state: &mut osc::State, + ) -> Result<()> { let (_, osc_packet) = rosc::decoder::decode_udp(&data) .map_err(|e| anyhow!("Failed to decode OSC packet: {}", e))?; if let Some(update) = osc::Message::from_osc_packet(osc_packet)? { - update.apply_to_state(current_state); + current_state.update(&update); // Send updated state through watch channel if let Err(_) = self.state_sender.send(current_state.clone()) { diff --git a/gui/src/pendulum.rs b/gui/src/pendulum.rs index 4dd3921..f179650 100644 --- a/gui/src/pendulum.rs +++ b/gui/src/pendulum.rs @@ -14,7 +14,7 @@ impl Pendulum { } /// Update pendulum with interpolated timing data and return current position - /// + /// /// Returns position on rail where 0.0 = left side, 1.0 = right side pub fn update(&mut self, interpolated: &Interpolated) -> f32 { // Change direction on beat boundary @@ -37,4 +37,4 @@ impl Default for Pendulum { fn default() -> Self { Self::new() } -} \ No newline at end of file +} diff --git a/gui/src/ui/app.rs b/gui/src/ui/app.rs index 930e4fd..c68725f 100644 --- a/gui/src/ui/app.rs +++ b/gui/src/ui/app.rs @@ -1,11 +1,11 @@ -pub struct App { - state_receiver: tokio::sync::watch::Receiver, +pub struct App { + state_receiver: tokio::sync::watch::Receiver>, interpolation: crate::interpolation::Interpolation, pendulum: crate::pendulum::Pendulum, } -impl App { - pub fn new(state_receiver: tokio::sync::watch::Receiver) -> Self { +impl App { + pub fn new(state_receiver: tokio::sync::watch::Receiver>) -> Self { let interpolation = crate::interpolation::Interpolation::new(std::time::Instant::now()); let pendulum = crate::pendulum::Pendulum::new(); Self { @@ -16,7 +16,7 @@ impl App { } } -impl eframe::App for App { +impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let state = self.state_receiver.borrow_and_update().clone(); let interpolated = self.interpolation.update(&state, std::time::Instant::now()); @@ -31,8 +31,8 @@ impl eframe::App for App { egui::CentralPanel::default().show(ctx, |ui| { ui.vertical(|ui| { ui.add(super::Metronome::new(pendulum_position)); - ui.add(super::Matrix::new( - &state.cells, + ui.add(super::Matrix::::new( + &state.columns, state.selected_column, state.selected_row, )); diff --git a/gui/src/ui/column.rs b/gui/src/ui/column.rs index c897054..9ed9df8 100644 --- a/gui/src/ui/column.rs +++ b/gui/src/ui/column.rs @@ -1,22 +1,23 @@ use super::ui_rows_ext::UiRowsExt; -pub struct Column<'a> { - state: &'a Vec, + +pub struct Column<'a, const ROWS: usize> { + tracks: &'a [osc::Track; ROWS], selected_row: Option, } -impl<'a> Column<'a> { - pub fn new(state: &'a Vec, selected_row: Option) -> Self { +impl<'a, const ROWS: usize> Column<'a, ROWS> { + pub fn new(tracks: &'a [osc::Track; ROWS], selected_row: Option) -> Self { Self { - state, + tracks, selected_row, } } } -impl<'a> egui::Widget for Column<'a> { +impl<'a, const ROWS: usize> egui::Widget for Column<'a, ROWS> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { - ui.rows(self.state.len(), |ui| { - for (i, track) in self.state.iter().enumerate() { + ui.rows(self.tracks.len(), |ui| { + for (i, track) in self.tracks.iter().enumerate() { ui[i].add(super::Track::new(track, Some(i) == self.selected_row)); } }); diff --git a/gui/src/ui/matrix.rs b/gui/src/ui/matrix.rs index 9ae7915..b8851bc 100644 --- a/gui/src/ui/matrix.rs +++ b/gui/src/ui/matrix.rs @@ -1,33 +1,33 @@ -pub struct Matrix<'a> { - cells: &'a Vec>, +pub struct Matrix<'a, const COLS: usize, const ROWS: usize> { + columns: &'a [osc::Column; COLS], selected_column: usize, selected_row: usize, } -impl<'a> Matrix<'a> { +impl<'a, const COLS: usize, const ROWS: usize> Matrix<'a, COLS, ROWS> { pub fn new( - cells: &'a Vec>, + columns: &'a [osc::Column; COLS], selected_column: usize, selected_row: usize, ) -> Self { Self { - cells, + columns, selected_column, selected_row, } } } -impl<'a> egui::Widget for Matrix<'a> { +impl<'a, const COLS: usize, const ROWS: usize> egui::Widget for Matrix<'a, COLS, ROWS> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { - ui.columns(self.cells.len(), |ui| { - for (i, column) in self.cells.iter().enumerate() { + ui.columns(self.columns.len(), |ui| { + for (i, column) in self.columns.iter().enumerate() { let selected_row = if i == self.selected_column { Some(self.selected_row) } else { None }; - ui[i].add(super::Column::new(column, selected_row)); + ui[i].add(super::Column::new(&column.tracks, selected_row)); } }); ui.response() diff --git a/osc/src/lib.rs b/osc/src/lib.rs index b16a13f..3e970a3 100644 --- a/osc/src/lib.rs +++ b/osc/src/lib.rs @@ -6,6 +6,7 @@ use error::AddressParseResult; pub use error::Error; pub use error::Result; pub use message::Message; +pub use state::Column; pub use state::State; pub use state::Track; pub use state::TrackState; diff --git a/osc/src/message.rs b/osc/src/message.rs index 6b84b24..0e1724c 100644 --- a/osc/src/message.rs +++ b/osc/src/message.rs @@ -24,6 +24,14 @@ pub enum Message { Tempo { bpm: f32, }, + ColumnBeatsChanged { + column: usize, + beats: usize, + }, + ColumnBeatChanged { + column: usize, + beat: usize, + }, } impl Message { @@ -37,7 +45,10 @@ impl Message { } } - pub fn apply_to_state(&self, state: &mut State) { + pub fn apply_to_state( + &self, + state: &mut State, + ) { match self { Message::TrackStateChanged { column, @@ -65,6 +76,12 @@ impl Message { Message::Tempo { bpm } => { state.set_tempo(*bpm); } + Message::ColumnBeatsChanged { column, beats } => { + state.set_column_beats(*column, *beats); + } + Message::ColumnBeatChanged { column, beat } => { + state.set_column_beat(*column, *beat); + } } } @@ -83,7 +100,7 @@ impl Message { if let Some(rosc::OscType::String(state_str)) = args.first() { let state = state_str.parse::().unwrap_or(TrackState::Empty); - log::trace!("TrackStateChanged: column={column}, row={row}, state={state}"); + log::debug!("TrackStateChanged: column={column}, row={row}, state={state}"); return Ok(Some(Message::TrackStateChanged { column, row, state })); } } else if addr.starts_with("/looper/cell/") && addr.ends_with("/volume") { @@ -97,23 +114,53 @@ impl Message { let row: usize = parts[4].parse::().address_part_result("row")? - 1; // Convert to 0-based if let Some(rosc::OscType::Float(volume)) = args.first() { - log::trace!("TrackVolumeChanged: column={column}, row={row}, volume={volume}"); + log::debug!("TrackVolumeChanged: column={column}, row={row}, volume={volume}"); return Ok(Some(Message::TrackVolumeChanged { column, row, volume: *volume, })); } + } else if addr.starts_with("/looper/column/") && addr.ends_with("/beats") { + // Parse: /looper/column/{column}/beats + let parts: Vec<&str> = addr.split('/').collect(); + if parts.len() != 5 { + return Err(Error::AddressParseError(addr)); + } + + let column: usize = parts[3].parse::().address_part_result("column")? - 1; // Convert to 0-based + + if let Some(rosc::OscType::Int(beats)) = args.first() { + log::debug!("ColumnBeatsChanged: column={column}, beats={beats}"); + return Ok(Some(Message::ColumnBeatsChanged { + column, + beats: *beats as usize, + })); + } + } else if addr.starts_with("/looper/column/") && addr.ends_with("/beat") { + // Parse: /looper/column/{column}/beat + let parts: Vec<&str> = addr.split('/').collect(); + if parts.len() != 5 { + return Err(Error::AddressParseError(addr)); + } + + let column: usize = parts[3].parse::().address_part_result("column")? - 1; // Convert to 0-based + + if let Some(rosc::OscType::Int(beat)) = args.first() { + let beat = (*beat as usize).saturating_sub(1); // Convert to 0-based + log::debug!("ColumnBeatChanged: column={column}, beat={beat}"); + return Ok(Some(Message::ColumnBeatChanged { column, beat })); + } } else if addr == "/looper/selected/column" { if let Some(rosc::OscType::Int(column_1based)) = args.first() { - log::trace!("SelectedColumnChanged: column={column_1based}"); let column = (*column_1based as usize).saturating_sub(1); // Convert to 0-based + log::debug!("SelectedColumnChanged: column={column}"); return Ok(Some(Message::SelectedColumnChanged { column })); } } else if addr == "/looper/selected/row" { if let Some(rosc::OscType::Int(row_1based)) = args.first() { - log::trace!("SelectedRowChanged: row={row_1based}"); let row = (*row_1based as usize).saturating_sub(1); // Convert to 0-based + log::debug!("SelectedRowChanged: row={row}"); return Ok(Some(Message::SelectedRowChanged { row })); } } else if addr == "/looper/metronome/position" { @@ -125,7 +172,7 @@ impl Message { } } else if addr == "/looper/tempo" { if let Some(rosc::OscType::Float(bpm)) = args.first() { - log::trace!("Tempo: bpm={bpm}"); + log::debug!("Tempo: bpm={bpm}"); return Ok(Some(Message::Tempo { bpm: *bpm })); } } @@ -187,6 +234,22 @@ impl Message { args: vec![rosc::OscType::Float(bpm)], }) } + Message::ColumnBeatsChanged { column, beats } => { + let address = format!("/looper/column/{}/beats", column + 1); // 1-based indexing + + rosc::OscPacket::Message(rosc::OscMessage { + addr: address, + args: vec![rosc::OscType::Int(beats as i32)], + }) + } + Message::ColumnBeatChanged { column, beat } => { + let address = format!("/looper/column/{}/beat", column + 1); // 1-based indexing + + rosc::OscPacket::Message(rosc::OscMessage { + addr: address, + args: vec![rosc::OscType::Int((beat + 1) as i32)], // Convert to 1-based + }) + } } } -} \ No newline at end of file +} diff --git a/osc/src/state.rs b/osc/src/state.rs index b3a964e..8f6b87d 100644 --- a/osc/src/state.rs +++ b/osc/src/state.rs @@ -15,37 +15,41 @@ pub struct Track { } #[derive(Debug, Clone)] -pub struct State { - pub selected_column: usize, // 0-based (converted to 1-based for OSC) - pub selected_row: usize, // 0-based (converted to 1-based for OSC) - pub cells: Vec>, - pub columns: usize, - pub rows: usize, - pub metronome_position: f32, // 0.0 - 1.0 position within current beat - pub metronome_timestamp: std::time::Instant, // When position was last updated - pub tempo: f32, // BPM +pub struct Column { + pub tracks: [Track; ROWS], + pub beat_count: usize, // total beats in column (0 = no beats set) + pub current_beat: usize, // current beat position (0-based internally) } -impl State { - pub fn new(columns: usize, rows: usize, tempo: f32) -> Self { - let cells = (0..columns) - .map(|_| { - vec![ - Track { - state: TrackState::Empty, - volume: 1.0 - }; - rows - ] - }) - .collect(); +#[derive(Debug, Clone)] +pub struct State { + pub selected_column: usize, // 0-based (converted to 1-based for OSC) + pub selected_row: usize, // 0-based (converted to 1-based for OSC) + pub columns: [Column; COLS], + pub metronome_position: f32, // 0.0 - 1.0 position within current beat + pub metronome_timestamp: std::time::Instant, // When position was last updated + pub tempo: f32, // BPM +} +impl Column { + pub fn new() -> Self { + Self { + tracks: std::array::from_fn(|_| Track { + state: TrackState::Empty, + volume: 1.0, + }), + beat_count: 0, + current_beat: 0, + } + } +} + +impl State { + pub fn new(tempo: f32) -> Self { Self { selected_column: 0, selected_row: 0, - cells, - columns, - rows, + columns: std::array::from_fn(|_| Column::new()), metronome_position: 0.0, metronome_timestamp: std::time::Instant::now(), tempo, @@ -55,8 +59,8 @@ impl State { pub fn update(&mut self, message: &Message) { match message { Message::TrackStateChanged { column, row, state } => { - if *column < self.columns && *row < self.rows { - self.cells[*column][*row].state = state.clone(); + if *column < COLS && *row < ROWS { + self.columns[*column].tracks[*row].state = state.clone(); } } Message::TrackVolumeChanged { @@ -64,17 +68,17 @@ impl State { row, volume, } => { - if *column < self.columns && *row < self.rows { - self.cells[*column][*row].volume = *volume; + if *column < COLS && *row < ROWS { + self.columns[*column].tracks[*row].volume = *volume; } } Message::SelectedColumnChanged { column } => { - if *column < self.columns { + if *column < COLS { self.selected_column = *column; } } Message::SelectedRowChanged { row } => { - if *row < self.rows { + if *row < ROWS { self.selected_row = *row; } } @@ -84,6 +88,12 @@ impl State { Message::Tempo { bpm } => { self.set_tempo(*bpm); } + Message::ColumnBeatsChanged { column, beats } => { + self.set_column_beats(*column, *beats); + } + Message::ColumnBeatChanged { column, beat } => { + self.set_column_beat(*column, *beat); + } } } @@ -101,9 +111,20 @@ impl State { row: self.selected_row, }); - // Send all cell states and volumes - for (column, column_cells) in self.cells.iter().enumerate() { - for (row, track) in column_cells.iter().enumerate() { + // Send column beat information and cell states/volumes + for (column, column_data) in self.columns.iter().enumerate() { + // Send column beat info + messages.push(Message::ColumnBeatsChanged { + column, + beats: column_data.beat_count, + }); + messages.push(Message::ColumnBeatChanged { + column, + beat: column_data.current_beat, + }); + + // Send all cell states and volumes for this column + for (row, track) in column_data.tracks.iter().enumerate() { messages.push(Message::TrackStateChanged { column, row, @@ -121,26 +142,26 @@ impl State { } pub fn set_track_state(&mut self, column: usize, row: usize, state: TrackState) { - if column < self.columns && row < self.rows { - self.cells[column][row].state = state; + if column < COLS && row < ROWS { + self.columns[column].tracks[row].state = state; } } pub fn set_selected_column(&mut self, column: usize) { - if column < self.columns { + if column < COLS { self.selected_column = column; } } pub fn set_selected_row(&mut self, row: usize) { - if row < self.rows { + if row < ROWS { self.selected_row = row; } } pub fn set_track_volume(&mut self, column: usize, row: usize, volume: f32) { - if column < self.columns && row < self.rows { - self.cells[column][row].volume = volume; + if column < COLS && row < ROWS { + self.columns[column].tracks[row].volume = volume; } } @@ -152,4 +173,16 @@ impl State { pub fn set_tempo(&mut self, bpm: f32) { self.tempo = bpm; } -} \ No newline at end of file + + pub fn set_column_beats(&mut self, column: usize, beats: usize) { + if column < COLS { + self.columns[column].beat_count = beats; + } + } + + pub fn set_column_beat(&mut self, column: usize, beat: usize) { + if column < COLS { + self.columns[column].current_beat = beat; + } + } +}