OSC messages for column beat tracking

This commit is contained in:
2025-06-22 18:52:57 +02:00
parent cfa2f1709e
commit b4f51548b3
16 changed files with 441 additions and 272 deletions

View File

@@ -26,7 +26,11 @@ impl Interpolation {
}
/// Update interpolation with current state and return interpolated values
pub fn update(&mut self, state: &osc::State, current_time: Instant) -> Interpolated {
pub fn update<const COLS: usize, const ROWS: usize>(
&mut self,
state: &osc::State<COLS, ROWS>,
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);
}
}
}

View File

@@ -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::<COLUMNS, ROWS>::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::<COLUMNS, ROWS>::new(state_receiver)))),
)
.map_err(|e| anyhow::anyhow!("Failed to run egui: {}", e))?;

View File

@@ -15,32 +15,33 @@ type PlatformStream = UnixStream;
#[cfg(windows)]
type PlatformStream = NamedPipeClient;
pub struct Client {
pub struct Client<const COLS: usize, const ROWS: usize> {
socket_path: String,
state_sender: watch::Sender<osc::State>,
state_receiver: watch::Receiver<osc::State>,
state_sender: watch::Sender<osc::State<COLS, ROWS>>,
state_receiver: watch::Receiver<osc::State<COLS, ROWS>>,
}
impl Client {
pub fn new(socket_path: &str, columns: usize, rows: usize) -> Result<Self> {
let initial_state = osc::State::new(columns, rows, 120.0);
impl<const COLS: usize, const ROWS: usize> Client<COLS, ROWS> {
pub fn new(socket_path: &str) -> Result<Self> {
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<osc::State> {
pub fn state_receiver(&self) -> watch::Receiver<osc::State<COLS, ROWS>> {
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<COLS, ROWS>,
) -> 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()) {

View File

@@ -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()
}
}
}

View File

@@ -1,11 +1,11 @@
pub struct App {
state_receiver: tokio::sync::watch::Receiver<osc::State>,
pub struct App<const COLS: usize, const ROWS: usize> {
state_receiver: tokio::sync::watch::Receiver<osc::State<COLS, ROWS>>,
interpolation: crate::interpolation::Interpolation,
pendulum: crate::pendulum::Pendulum,
}
impl App {
pub fn new(state_receiver: tokio::sync::watch::Receiver<osc::State>) -> Self {
impl<const COLS: usize, const ROWS: usize> App<COLS, ROWS> {
pub fn new(state_receiver: tokio::sync::watch::Receiver<osc::State<COLS, ROWS>>) -> 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<const COLS: usize, const ROWS: usize> eframe::App for App<COLS, ROWS> {
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::<COLS, ROWS>::new(
&state.columns,
state.selected_column,
state.selected_row,
));

View File

@@ -1,22 +1,23 @@
use super::ui_rows_ext::UiRowsExt;
pub struct Column<'a> {
state: &'a Vec<osc::Track>,
pub struct Column<'a, const ROWS: usize> {
tracks: &'a [osc::Track; ROWS],
selected_row: Option<usize>,
}
impl<'a> Column<'a> {
pub fn new(state: &'a Vec<osc::Track>, selected_row: Option<usize>) -> Self {
impl<'a, const ROWS: usize> Column<'a, ROWS> {
pub fn new(tracks: &'a [osc::Track; ROWS], selected_row: Option<usize>) -> 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));
}
});

View File

@@ -1,33 +1,33 @@
pub struct Matrix<'a> {
cells: &'a Vec<Vec<osc::Track>>,
pub struct Matrix<'a, const COLS: usize, const ROWS: usize> {
columns: &'a [osc::Column<ROWS>; 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<Vec<osc::Track>>,
columns: &'a [osc::Column<ROWS>; 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()