From cfa2f1709ef0e026e598e9c96d61abafc07d9072 Mon Sep 17 00:00:00 2001 From: Geens Date: Sun, 22 Jun 2025 13:28:31 +0200 Subject: [PATCH] Interpolation by gemini --- gui/src/interpolation.rs | 1118 ++++++++++++++++++++++---------------- 1 file changed, 657 insertions(+), 461 deletions(-) diff --git a/gui/src/interpolation.rs b/gui/src/interpolation.rs index 39ad6d6..aa45f02 100644 --- a/gui/src/interpolation.rs +++ b/gui/src/interpolation.rs @@ -12,538 +12,734 @@ pub struct Interpolated { /// Handles interpolation of time-based values between OSC updates #[derive(Debug)] pub struct Interpolation { - /// Last received metronome position from OSC - last_osc_position: f32, - /// When the last OSC position was received - last_osc_timestamp: Instant, - /// Current tempo in BPM - tempo: f32, - /// When we last calculated interpolated values - last_frame_timestamp: Instant, - /// The position we calculated in the last frame - last_frame_position: f32, - /// Total number of beat boundaries we've reported (to avoid double-counting) - beats_reported: u64, - /// Whether we've received any OSC data yet - initialized: bool, + /// The calculated, wrapped position from the previous frame. + /// Used to detect beat boundary crossings. + last_calculated_position: f32, } impl Interpolation { - pub fn new(current_time: Instant) -> Self { + pub fn new(_current_time: Instant) -> Self { Self { - last_osc_position: 0.0, - last_osc_timestamp: current_time, - tempo: 120.0, - last_frame_timestamp: current_time, - last_frame_position: 0.0, - beats_reported: 0, - initialized: false, + // Initialize to 0.0, it will be correctly updated on the first call to `update`. + last_calculated_position: 0.0, } } - /// Update interpolation with current state and return interpolated values + /// Update interpolation with current state and return interpolated values pub fn update(&mut self, state: &osc::State, current_time: Instant) -> Interpolated { - if !self.initialized { - // First call - just initialize - self.last_osc_position = state.metronome_position; - self.last_osc_timestamp = state.metronome_timestamp; - self.tempo = state.tempo; - self.last_frame_timestamp = current_time; - self.last_frame_position = state.metronome_position; - self.beats_reported = 0; - self.initialized = true; + // 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; + let tempo = state.tempo; + // If tempo is not positive, we cannot calculate a beat duration. + // Return the last known position without trying to interpolate. + if tempo <= 0.0 { return Interpolated { - position_in_beat: state.metronome_position, + position_in_beat: base_position, beat_boundary: false, }; } - let beat_duration_secs = 60.0 / self.tempo; + let beat_duration_secs = 60.0 / tempo; - // Check if we have new OSC data - let has_new_osc_data = state.metronome_timestamp != self.last_osc_timestamp; + // Calculate how much time has passed since the last OSC update's timestamp. + // This is the duration we need to interpolate over. `saturating_duration_since` + // prevents panics if current_time is somehow earlier than the base_timestamp. + let interpolation_duration = current_time.saturating_duration_since(base_timestamp); + let progress = interpolation_duration.as_secs_f32() / beat_duration_secs; - let current_position; - let mut beat_boundary = false; + // Calculate the new theoretical position by adding the progress. + let new_raw_position = base_position + progress; - if has_new_osc_data { - // Update tempo first in case it changed - self.tempo = state.tempo; - let beat_duration_secs = 60.0 / self.tempo; + // Wrap the position to the [0.0, 1.0) range. + let final_position = new_raw_position.fract(); - // Check for beat boundary in the OSC data itself (position wraparound) - if state.metronome_position < self.last_osc_position && self.last_osc_position > 0.7 { - beat_boundary = true; - } + // Detect a beat boundary. + // This occurs when the new position appears to have "jumped back" by a large + // amount (more than 0.5), which indicates it has wrapped around past 1.0. + // This handles both interpolation and late OSC corrections robustly. + let beat_boundary = self.last_calculated_position - final_position > 0.5; - // Use OSC position as our new reference and interpolate forward to current frame time - let elapsed_since_osc = current_time.duration_since(state.metronome_timestamp); - let advancement = elapsed_since_osc.as_secs_f32() / beat_duration_secs; - current_position = state.metronome_position + advancement; - - // Update our reference point - self.last_osc_position = state.metronome_position; - self.last_osc_timestamp = state.metronome_timestamp; - } else { - // No new OSC data - interpolate from our last reference point - let elapsed_since_osc = current_time.duration_since(self.last_osc_timestamp); - let advancement = elapsed_since_osc.as_secs_f32() / beat_duration_secs; - current_position = self.last_osc_position + advancement; - } - - // Detect beat boundary by comparing with last frame position - if !beat_boundary { - // Check if we crossed an integer boundary (beat boundary) - let last_beat = self.last_frame_position.floor(); - let current_beat = current_position.floor(); - - if current_beat > last_beat { - beat_boundary = true; - } - } - - // Normalize position to [0.0, 1.0) within current beat - let position_in_beat = current_position.fract(); - - // Update tracking for next frame - self.last_frame_timestamp = current_time; - self.last_frame_position = current_position; - - if beat_boundary { - self.beats_reported += 1; - } + // Store the new position for the next frame's calculation. + self.last_calculated_position = final_position; Interpolated { - position_in_beat, + position_in_beat: final_position, beat_boundary, } } } -impl Default for Interpolation { - fn default() -> Self { - Self::new(Instant::now()) +#[cfg(test)] +mod interpolation_without_state_update_tests { + use super::*; + use std::time::{Duration, Instant}; + + #[test] + 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); + 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 + // 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); + } + + #[test] + 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); + 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); + assert!(!result3.beat_boundary); + } + + #[test] + 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); + 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); + assert_eq!(result3.beat_boundary, result2.beat_boundary); + } + + #[test] + 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); + 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 + // Extra time after boundary: 150ms - 100ms = 50ms + // Position after boundary: 50ms / 500ms = 0.1 + 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 + } + + #[test] + 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); + 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 + let expected_position_3 = 0.1 + 0.002; + assert!((result3.position_in_beat - expected_position_3).abs() < 0.001); + } + + #[test] + 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); + 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 } } #[cfg(test)] -mod tests { +mod state_update_tests { use super::*; - use osc::State; - use std::time::Duration; + use std::time::{Duration, Instant}; - fn create_test_state(metronome_position: f32, tempo: f32, timestamp: Instant) -> State { - let mut state = State::new(5, 5, tempo); - state.set_metronome_position(metronome_position); - // Manually set the timestamp to make tests deterministic - state.metronome_timestamp = timestamp; - state + #[test] + 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); + 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; + assert!((result4.position_in_beat - expected_position_4).abs() < 0.001); + assert!(!result4.beat_boundary); } #[test] - fn test_initial_state() { + fn test_osc_update_arrives_slightly_late_no_beat() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - let state = create_test_state(0.0, 120.0, start_time); - - let result = interpolation.update(&state, start_time); - - // First call should initialize but not report beat boundary - assert_eq!(result.position_in_beat, 0.0); - assert_eq!(result.beat_boundary, false); + + // Create initial state with 120 BPM + let mut state = osc::State::new(5, 5, 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 + assert!((result3.position_in_beat - expected_position_3).abs() < 0.001); + assert!(!result3.beat_boundary); } #[test] - fn test_normal_interpolation() { + fn test_osc_update_arrives_late_confirms_detected_boundary() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - let state = create_test_state(0.5, 120.0, start_time); - - // First update to initialize - interpolation.update(&state, start_time); - - // Simulate 100ms delay (at 120 BPM, one beat = 500ms, so this is 0.2 of a beat) - let later_time = start_time + Duration::from_millis(100); - - let result = interpolation.update(&state, later_time); - - // Position should be interpolated forward - assert!(result.position_in_beat > 0.5); - assert_eq!(result.beat_boundary, false); + + // Create initial state with 120 BPM, starting near end of beat + let mut state = osc::State::new(5, 5, 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 + assert!((result3.position_in_beat - expected_position).abs() < 0.001); } #[test] - fn test_beat_boundary_detection() { + fn test_osc_update_arrives_late_shows_missed_boundary() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - - // Start at position 0.9 - let state1 = create_test_state(0.9, 120.0, start_time); - interpolation.update(&state1, start_time); - - // New OSC update shows we're at beginning of next beat - let later_time = start_time + Duration::from_millis(100); - let state2 = create_test_state(0.1, 120.0, later_time); - let result = interpolation.update(&state2, later_time); - - assert_eq!(result.beat_boundary, true); - assert_eq!(result.position_in_beat, 0.1); + + // Create initial state with 120 BPM, starting near end of beat + let mut state = osc::State::new(5, 5, 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 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 + assert!((result3.position_in_beat - expected_position).abs() < 0.001); } #[test] - fn test_single_missed_osc_update() { + fn test_osc_update_arrives_late_contradicts_detected_boundary() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - - // Initialize at 0.1 - let state1 = create_test_state(0.1, 120.0, start_time); - interpolation.update(&state1, start_time); - - // Frame update with no new OSC data - should interpolate - let frame_time = start_time + Duration::from_millis(50); - let result = interpolation.update(&state1, frame_time); - - // Should interpolate forward from 0.1 - assert!(result.position_in_beat > 0.1); - assert_eq!(result.beat_boundary, false); + + // Create initial state with 120 BPM, starting near end of beat + let mut state = osc::State::new(5, 5, 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 }; + assert!((result3.position_in_beat - wrapped_position).abs() < 0.001); } #[test] - fn test_single_late_osc_update() { + fn test_osc_update_arrives_very_late() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - - // Initialize - let state1 = create_test_state(0.1, 120.0, start_time); - interpolation.update(&state1, start_time); - - // Late OSC update arrives after we've been interpolating - let osc_time = start_time + Duration::from_millis(100); - let frame_time = start_time + Duration::from_millis(150); - let state2 = create_test_state(0.3, 120.0, osc_time); - - let result = interpolation.update(&state2, frame_time); - - // Should sync to OSC data and continue interpolating - assert!(result.position_in_beat >= 0.3); - assert_eq!(result.beat_boundary, false); - } - - #[test] - fn test_multiple_missed_updates() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Initialize - let state1 = create_test_state(0.8, 120.0, start_time); - interpolation.update(&state1, start_time); - - // Multiple frame updates without new OSC data, crossing beat boundary - let frame1_time = start_time + Duration::from_millis(100); - let result1 = interpolation.update(&state1, frame1_time); - // At 120 BPM: 100ms = 0.2 beats, so 0.8 + 0.2 = 1.0 (beat boundary) - assert_eq!(result1.beat_boundary, true); - - let frame2_time = start_time + Duration::from_millis(200); - let result2 = interpolation.update(&state1, frame2_time); - // No new beat boundary since we already crossed at 100ms - assert_eq!(result2.beat_boundary, false); - - let frame3_time = start_time + Duration::from_millis(600); - let result3 = interpolation.update(&state1, frame3_time); - // Another beat boundary should be detected (at 500ms mark) - assert_eq!(result3.beat_boundary, true); - } - - #[test] - fn test_full_missed_beat_several_updates() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Initialize at 0.9 - let state1 = create_test_state(0.9, 120.0, start_time); - interpolation.update(&state1, start_time); - - // OSC update arrives showing we've crossed a full beat - let osc_time = start_time + Duration::from_millis(600); // > 1 beat at 120 BPM - let frame_time = start_time + Duration::from_millis(650); - let state2 = create_test_state(0.1, 120.0, osc_time); - - let result = interpolation.update(&state2, frame_time); - - // Should only report one beat boundary, not multiple - assert_eq!(result.beat_boundary, true); - // Position should be interpolated forward from OSC time to frame time - // 50ms at 120 BPM = 50/500 = 0.1 beat advancement - assert!((result.position_in_beat - 0.2).abs() < 0.01); // 0.1 + 0.1 = 0.2 - } - - #[test] - fn test_missing_updates_for_several_beats() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Initialize with OSC data - let state1 = create_test_state(0.1, 120.0, start_time); - interpolation.update(&state1, start_time); - - let mut beat_count = 0; - - // Simulate regular frame updates (every 16ms) but OSC data becomes stale - // OSC data stops updating, but GUI keeps calling update() - for i in 1..=94 { - // 94 * 16ms = ~1500ms = 3 beats at 120 BPM - let frame_time = start_time + Duration::from_millis(i * 16); - - // Keep using the same stale OSC data (same timestamp) - let result = interpolation.update(&state1, frame_time); - - if result.beat_boundary { - beat_count += 1; - } - } - - // Should detect beat boundaries through time interpolation even with stale OSC data - // Starting at 0.1, over 1500ms at 120 BPM, should cross 3 beat boundaries - // (at ~450ms, ~950ms, ~1450ms from 0.1 starting point) - assert_eq!(beat_count, 3); - - // Finally, new OSC data arrives (simulating network recovery) - let late_osc_time = start_time + Duration::from_millis(1500); - let frame_time = start_time + Duration::from_millis(1500); - let state2 = create_test_state(0.7, 120.0, late_osc_time); - let result = interpolation.update(&state2, frame_time); - - // Should not trigger additional beat boundary since we already tracked them - assert_eq!(result.beat_boundary, false); - } - - #[test] - fn test_no_double_beat_counting() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Initialize - let state1 = create_test_state(0.9, 120.0, start_time); - interpolation.update(&state1, start_time); - - // First frame after beat boundary - let later_time = start_time + Duration::from_millis(100); - let state2 = create_test_state(0.1, 120.0, later_time); - let result1 = interpolation.update(&state2, later_time); - assert_eq!(result1.beat_boundary, true); - - // Second frame with same OSC data (no new timestamp) - let frame_time = later_time + Duration::from_millis(16); - let result2 = interpolation.update(&state2, frame_time); - assert_eq!(result2.beat_boundary, false); // Should not double-count - - // Third frame with same OSC data - let frame_time2 = later_time + Duration::from_millis(32); - let result3 = interpolation.update(&state2, frame_time2); - assert_eq!(result3.beat_boundary, false); // Should still not double-count - } - - #[test] - fn test_beat_counting_across_multiple_beats() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Initialize - let state1 = create_test_state(0.1, 120.0, start_time); - interpolation.update(&state1, start_time); - - let mut beat_count = 0; - - // Simulate several beats passing through time interpolation - // At 120 BPM: 250ms = 0.5 beats, 500ms = 1 beat - // Starting at 0.1, beat boundaries at: 500ms, 1000ms, 1500ms, 2000ms, 2500ms - for i in 1..=10 { - let frame_time = start_time + Duration::from_millis(i * 250); // Half beat intervals - let result = interpolation.update(&state1, frame_time); - if result.beat_boundary { - beat_count += 1; - } - } - - // Should detect exactly 5 beat boundaries (at 500ms, 1000ms, 1500ms, 2000ms, 2500ms) - assert_eq!(beat_count, 5); - } - - #[test] - fn test_tempo_change_during_interpolation() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Start with 120 BPM at position 0.5 - let state1 = create_test_state(0.5, 120.0, start_time); - interpolation.update(&state1, start_time); - - // 100ms later, tempo changes to 60 BPM but no position update - let frame_time = start_time + Duration::from_millis(100); - let state2 = create_test_state(0.5, 60.0, start_time); // Same OSC timestamp, new tempo - let result = interpolation.update(&state2, frame_time); - - // Should still advance based on old tempo until new OSC data arrives - assert!(result.position_in_beat > 0.5); - assert_eq!(result.beat_boundary, false); - - // Now new OSC data arrives with the new tempo - let osc_time = start_time + Duration::from_millis(150); - let frame_time2 = start_time + Duration::from_millis(200); - let state3 = create_test_state(0.7, 60.0, osc_time); - let result2 = interpolation.update(&state3, frame_time2); - - // Should interpolate with new tempo: 50ms at 60 BPM = 50/1000 = 0.05 beat - assert!((result2.position_in_beat - 0.75).abs() < 0.01); - } - - #[test] - fn test_osc_data_at_exact_beat_boundary() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Initialize at 0.9 - let state1 = create_test_state(0.9, 120.0, start_time); - interpolation.update(&state1, start_time); - - // OSC data arrives showing exact beat boundary (position 0.0) - let later_time = start_time + Duration::from_millis(100); - let state2 = create_test_state(0.0, 120.0, later_time); - let result = interpolation.update(&state2, later_time); - - assert_eq!(result.beat_boundary, true); - assert_eq!(result.position_in_beat, 0.0); - } - - #[test] - fn test_large_position_jump_without_beat() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Initialize at 0.2 - let state1 = create_test_state(0.2, 120.0, start_time); - interpolation.update(&state1, start_time); - - // Large jump within same beat (shouldn't trigger beat boundary) - let later_time = start_time + Duration::from_millis(50); - let state2 = create_test_state(0.8, 120.0, later_time); - let result = interpolation.update(&state2, later_time); - - assert_eq!(result.beat_boundary, false); - assert!((result.position_in_beat - 0.8).abs() < 0.1); - } - - #[test] - fn test_position_wraparound() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Position near end of beat - let state1 = create_test_state(0.95, 120.0, start_time); - interpolation.update(&state1, start_time); - - // Position wraps to beginning - let later_time = start_time + Duration::from_millis(50); - let state2 = create_test_state(0.05, 120.0, later_time); - let result = interpolation.update(&state2, later_time); - - assert_eq!(result.beat_boundary, true); - assert_eq!(result.position_in_beat, 0.05); - } - - #[test] - fn test_backward_time_jump() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Initialize at some position - let state1 = create_test_state(0.7, 120.0, start_time); - interpolation.update(&state1, start_time); - - // OSC update shows earlier position (shouldn't happen normally) - let later_time = start_time + Duration::from_millis(100); - let state2 = create_test_state(0.3, 120.0, later_time); - let result = interpolation.update(&state2, later_time); - - // Should handle gracefully without reporting false beat boundary - assert_eq!(result.position_in_beat, 0.3); - assert_eq!(result.beat_boundary, false); - } - - #[test] - fn test_multiple_consecutive_updates() { - let start_time = Instant::now(); - let mut interpolation = Interpolation::new(start_time); - - // Series of rapid updates - for i in 0..10 { - let position = (i as f32) * 0.1; - let time = start_time + Duration::from_millis(i * 50); - let state = create_test_state(position, 120.0, time); - let result = interpolation.update(&state, time); - - if i == 0 { - assert_eq!(result.beat_boundary, false); // First update - } - // Beat boundary should only occur when crossing 1.0 -> 0.0 + + // Create initial state with 120 BPM + let mut state = osc::State::new(5, 5, 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; + if expected_position_3 >= 1.0 { + // Handle wrap-around + assert!((result3.position_in_beat - (expected_position_3 - 1.0)).abs() < 0.1); + } else { + assert!((result3.position_in_beat - expected_position_3).abs() < 0.1); } } #[test] - fn test_rapid_frame_updates_between_osc() { + fn test_osc_update_arrives_slightly_early_no_beat() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - - // Initialize - let state = create_test_state(0.2, 120.0, start_time); - interpolation.update(&state, start_time); - - // Multiple rapid frame updates without new OSC data - for i in 1..5 { - let frame_time = start_time + Duration::from_millis(i * 16); // ~60fps - let result = interpolation.update(&state, frame_time); - - // Each frame should advance position slightly - assert!(result.position_in_beat >= 0.2); - // No beat boundaries unless we cross a full beat - } + + // Create initial state with 120 BPM + let mut state = osc::State::new(5, 5, 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 + assert!((result3.position_in_beat - expected_position_3).abs() < 0.001); + assert!(!result3.beat_boundary); } #[test] - fn test_very_slow_tempo() { + fn test_osc_update_arrives_slightly_early_with_past_beat() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - - // Very slow tempo (30 BPM = 2 second beats) - let state = create_test_state(0.1, 30.0, start_time); - interpolation.update(&state, start_time); - - // Small time advancement - let later_time = start_time + Duration::from_millis(100); - let result = interpolation.update(&state, later_time); - - // Position should advance very slowly - assert!(result.position_in_beat > 0.1); - assert!(result.position_in_beat < 0.2); - assert_eq!(result.beat_boundary, false); + + // Create initial state with 120 BPM, starting near end of beat + let mut state = osc::State::new(5, 5, 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 + assert!((result3.position_in_beat - expected_position_3).abs() < 0.001); + assert!(!result3.beat_boundary); // No boundary on this update } #[test] - fn test_very_fast_tempo() { + fn test_osc_update_arrives_slightly_early_with_beat_now() { let start_time = Instant::now(); let mut interpolation = Interpolation::new(start_time); - - // Very fast tempo (240 BPM = 250ms beats) - let state = create_test_state(0.8, 240.0, start_time); - interpolation.update(&state, start_time); - - // Time advancement that crosses beat boundary - let later_time = start_time + Duration::from_millis(100); - let result = interpolation.update(&state, later_time); - - // Should detect beat boundary quickly - assert_eq!(result.beat_boundary, true); + + // Create initial state with 120 BPM, starting near end of beat + let mut state = osc::State::new(5, 5, 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); + assert!(!result4.beat_boundary); } -} + + #[test] + 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); + 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