First attempt at interpolation
This commit is contained in:
parent
7e6a539296
commit
b35574c364
@ -51,6 +51,9 @@ use track::Track;
|
||||
use track::TrackState;
|
||||
use track_matrix::TrackMatrix;
|
||||
|
||||
const COLS: usize = 5;
|
||||
const ROWS: usize = 5;
|
||||
|
||||
pub struct JackPorts {
|
||||
pub audio_in: jack::Port<jack::AudioIn>,
|
||||
pub audio_out: jack::Port<jack::AudioOut>,
|
||||
@ -66,10 +69,6 @@ async fn main() {
|
||||
.init()
|
||||
.expect("Could not initialize logger");
|
||||
|
||||
let (mut osc, osc_controller) = Osc::new(&args.socket)
|
||||
.await
|
||||
.expect("Could not create OSC server");
|
||||
|
||||
let (jack_client, ports) = setup_jack();
|
||||
|
||||
let mut allocator = Allocator::spawn(jack_client.sample_rate(), 3);
|
||||
@ -84,11 +83,19 @@ async fn main() {
|
||||
PersistenceManager::new(&args, notification_handler.subscribe());
|
||||
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 (mut osc, osc_controller) = Osc::new(&args.socket, COLS, ROWS, tempo_bpm)
|
||||
.await
|
||||
.expect("Could not create OSC server");
|
||||
|
||||
// Create post-record handler and get controller for ProcessHandler
|
||||
let (mut post_record_handler, post_record_controller) =
|
||||
PostRecordHandler::new(&args).expect("Could not create post-record handler");
|
||||
|
||||
let process_handler = ProcessHandler::new(
|
||||
let process_handler = ProcessHandler::<_, COLS, ROWS>::new(
|
||||
&jack_client,
|
||||
ports,
|
||||
allocator,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use crate::*;
|
||||
|
||||
/// Process MIDI events
|
||||
pub fn process_events<F: ChunkFactory>(
|
||||
process_handler: &mut ProcessHandler<F>,
|
||||
pub fn process_events<F: ChunkFactory, const COLS: usize, const ROWS: usize>(
|
||||
process_handler: &mut ProcessHandler<F, COLS, ROWS>,
|
||||
ps: &jack::ProcessScope,
|
||||
) -> Result<()> {
|
||||
// First, collect all MIDI events into a fixed-size array
|
||||
|
||||
@ -11,8 +11,13 @@ pub struct Osc {
|
||||
}
|
||||
|
||||
impl Osc {
|
||||
/// Create new OSC server and controller with same interface as before
|
||||
pub async fn new(socket_path: &str) -> Result<(Self, OscController)> {
|
||||
/// 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)> {
|
||||
// Create platform listener (server)
|
||||
let listener = osc_server::platform::create_listener(socket_path).await?;
|
||||
|
||||
@ -27,7 +32,7 @@ impl Osc {
|
||||
receiver,
|
||||
listener,
|
||||
broadcaster,
|
||||
shadow_state: osc::State::new(5, 5), // Default 5x5 matrix
|
||||
shadow_state: osc::State::new(columns, rows, tempo),
|
||||
};
|
||||
|
||||
Ok((server, controller))
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
use crate::*;
|
||||
|
||||
const COLS: usize = 5;
|
||||
const ROWS: usize = 5;
|
||||
|
||||
pub struct ProcessHandler<F: ChunkFactory> {
|
||||
pub struct ProcessHandler<F: ChunkFactory, const COLS: usize, const ROWS: usize> {
|
||||
post_record_controller: PostRecordController,
|
||||
osc: OscController,
|
||||
persistence: PersistenceManagerController,
|
||||
@ -16,7 +13,7 @@ pub struct ProcessHandler<F: ChunkFactory> {
|
||||
sample_rate: usize,
|
||||
}
|
||||
|
||||
impl<F: ChunkFactory> ProcessHandler<F> {
|
||||
impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> ProcessHandler<F, COLS, ROWS> {
|
||||
pub fn new(
|
||||
client: &jack::Client,
|
||||
ports: JackPorts,
|
||||
@ -153,7 +150,7 @@ impl<F: ChunkFactory> ProcessHandler<F> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
|
||||
impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> jack::ProcessHandler for ProcessHandler<F, COLS, ROWS> {
|
||||
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);
|
||||
@ -164,7 +161,7 @@ impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: ChunkFactory> ProcessHandler<F> {
|
||||
impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> ProcessHandler<F, COLS, ROWS> {
|
||||
fn process_with_error_handling(&mut self, ps: &jack::ProcessScope) -> Result<()> {
|
||||
// Process metronome and get beat timing information
|
||||
let timing = self.metronome.process(ps, &mut self.ports, &self.osc)?;
|
||||
|
||||
549
gui/src/interpolation.rs
Normal file
549
gui/src/interpolation.rs
Normal file
@ -0,0 +1,549 @@
|
||||
use std::time::Instant;
|
||||
|
||||
/// Result of interpolation calculation for one frame
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Interpolated {
|
||||
/// Position within current beat (0.0 = start of beat, 1.0 = end of beat)
|
||||
pub position_in_beat: f32,
|
||||
/// True if a beat boundary occurred since the last frame
|
||||
pub beat_boundary: bool,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Interpolation {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
return Interpolated {
|
||||
position_in_beat: state.metronome_position,
|
||||
beat_boundary: false,
|
||||
};
|
||||
}
|
||||
|
||||
let beat_duration_secs = 60.0 / self.tempo;
|
||||
|
||||
// Check if we have new OSC data
|
||||
let has_new_osc_data = state.metronome_timestamp != self.last_osc_timestamp;
|
||||
|
||||
let current_position;
|
||||
let mut beat_boundary = false;
|
||||
|
||||
if has_new_osc_data {
|
||||
// Update tempo first in case it changed
|
||||
self.tempo = state.tempo;
|
||||
let beat_duration_secs = 60.0 / self.tempo;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
Interpolated {
|
||||
position_in_beat,
|
||||
beat_boundary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Interpolation {
|
||||
fn default() -> Self {
|
||||
Self::new(Instant::now())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use osc::State;
|
||||
use std::time::Duration;
|
||||
|
||||
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_initial_state() {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normal_interpolation() {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_beat_boundary_detection() {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_missed_osc_update() {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_late_osc_update() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rapid_frame_updates_between_osc() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_very_slow_tempo() {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_very_fast_tempo() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
mod interpolation;
|
||||
mod osc_client;
|
||||
mod pendulum;
|
||||
mod ui;
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
@ -23,7 +23,7 @@ pub struct Client {
|
||||
|
||||
impl Client {
|
||||
pub fn new(socket_path: &str, columns: usize, rows: usize) -> Result<Self> {
|
||||
let initial_state = osc::State::new(columns, rows);
|
||||
let initial_state = osc::State::new(columns, rows, 120.0);
|
||||
// Test data
|
||||
let initial_state = {
|
||||
let mut initial_state = initial_state;
|
||||
|
||||
40
gui/src/pendulum.rs
Normal file
40
gui/src/pendulum.rs
Normal file
@ -0,0 +1,40 @@
|
||||
use crate::interpolation::Interpolated;
|
||||
|
||||
/// Handles pendulum swing direction and position calculation
|
||||
#[derive(Debug)]
|
||||
pub struct Pendulum {
|
||||
/// Current swing direction (true = left to right, false = right to left)
|
||||
going_right: bool,
|
||||
}
|
||||
|
||||
impl Pendulum {
|
||||
/// Create a new pendulum starting in the right direction
|
||||
pub fn new() -> Self {
|
||||
Self { going_right: true }
|
||||
}
|
||||
|
||||
/// 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
|
||||
if interpolated.beat_boundary {
|
||||
self.going_right = !self.going_right;
|
||||
}
|
||||
|
||||
// Calculate position based on current direction
|
||||
if self.going_right {
|
||||
// Left to right: 0.0 -> 0.0, 1.0 -> 1.0
|
||||
interpolated.position_in_beat
|
||||
} else {
|
||||
// Right to left: 0.0 -> 1.0, 1.0 -> 0.0
|
||||
1.0 - interpolated.position_in_beat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Pendulum {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,26 @@
|
||||
pub struct App {
|
||||
state_receiver: tokio::sync::watch::Receiver<osc::State>,
|
||||
interpolation: crate::interpolation::Interpolation,
|
||||
pendulum: crate::pendulum::Pendulum,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(state_receiver: tokio::sync::watch::Receiver<osc::State>) -> Self {
|
||||
Self { state_receiver }
|
||||
let interpolation = crate::interpolation::Interpolation::new(std::time::Instant::now());
|
||||
let pendulum = crate::pendulum::Pendulum::new();
|
||||
Self {
|
||||
state_receiver,
|
||||
interpolation,
|
||||
pendulum,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
let pendulum_position = self.pendulum.update(&interpolated);
|
||||
|
||||
ctx.style_mut(|style| {
|
||||
style.visuals.panel_fill = egui::Color32::from_rgb(17, 17, 17);
|
||||
@ -20,7 +30,7 @@ impl eframe::App for App {
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.add(super::Metronome::new(state.metronome_position));
|
||||
ui.add(super::Metronome::new(pendulum_position));
|
||||
ui.add(super::Matrix::new(
|
||||
&state.cells,
|
||||
state.selected_column,
|
||||
|
||||
@ -21,6 +21,9 @@ pub enum Message {
|
||||
MetronomePosition {
|
||||
position: f32, // 0.0 - 1.0 position within current beat
|
||||
},
|
||||
Tempo {
|
||||
bpm: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Message {
|
||||
@ -59,6 +62,9 @@ impl Message {
|
||||
Message::MetronomePosition { position } => {
|
||||
state.set_metronome_position(*position);
|
||||
}
|
||||
Message::Tempo { bpm } => {
|
||||
state.set_tempo(*bpm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -117,6 +123,11 @@ impl Message {
|
||||
position: *position,
|
||||
}));
|
||||
}
|
||||
} else if addr == "/looper/tempo" {
|
||||
if let Some(rosc::OscType::Float(bpm)) = args.first() {
|
||||
log::trace!("Tempo: bpm={bpm}");
|
||||
return Ok(Some(Message::Tempo { bpm: *bpm }));
|
||||
}
|
||||
}
|
||||
// Unknown or unsupported message
|
||||
Ok(None)
|
||||
@ -168,6 +179,14 @@ impl Message {
|
||||
args: vec![rosc::OscType::Float(position)],
|
||||
})
|
||||
}
|
||||
Message::Tempo { bpm } => {
|
||||
let address = "/looper/tempo".to_string();
|
||||
|
||||
rosc::OscPacket::Message(rosc::OscMessage {
|
||||
addr: address,
|
||||
args: vec![rosc::OscType::Float(bpm)],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,10 +23,11 @@ pub struct State {
|
||||
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
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new(columns: usize, rows: usize) -> Self {
|
||||
pub fn new(columns: usize, rows: usize, tempo: f32) -> Self {
|
||||
let cells = (0..columns)
|
||||
.map(|_| {
|
||||
vec![
|
||||
@ -47,6 +48,7 @@ impl State {
|
||||
rows,
|
||||
metronome_position: 0.0,
|
||||
metronome_timestamp: std::time::Instant::now(),
|
||||
tempo,
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,12 +81,18 @@ impl State {
|
||||
Message::MetronomePosition { position } => {
|
||||
self.set_metronome_position(*position);
|
||||
}
|
||||
Message::Tempo { bpm } => {
|
||||
self.set_tempo(*bpm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_state_dump(&self) -> Vec<Message> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// Send tempo
|
||||
messages.push(Message::Tempo { bpm: self.tempo });
|
||||
|
||||
// Send current selections
|
||||
messages.push(Message::SelectedColumnChanged {
|
||||
column: self.selected_column,
|
||||
@ -140,4 +148,8 @@ impl State {
|
||||
self.metronome_position = position;
|
||||
self.metronome_timestamp = std::time::Instant::now();
|
||||
}
|
||||
|
||||
pub fn set_tempo(&mut self, bpm: f32) {
|
||||
self.tempo = bpm;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user