diff --git a/Cargo.toml b/Cargo.toml index 7590409..3446799 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,11 +37,11 @@ required-features = ["desktop"] default = [] desktop = [ "cagire-forth/desktop", - "egui", - "eframe", - "egui_ratatui", - "soft_ratatui", - "image", + "dep:egui", + "dep:eframe", + "dep:egui_ratatui", + "dep:soft_ratatui", + "dep:image", ] [dependencies] @@ -71,9 +71,6 @@ midir = "0.10" parking_lot = "0.12" libc = "0.2" -[target.'cfg(target_os = "linux")'.dependencies] -nix = { version = "0.29", features = ["time"] } - # Desktop-only dependencies (behind feature flag) egui = { version = "0.33", optional = true } eframe = { version = "0.33", optional = true } @@ -81,6 +78,9 @@ egui_ratatui = { version = "2.1", optional = true } soft_ratatui = { version = "0.1.3", features = ["unicodefonts"], optional = true } image = { version = "0.25", default-features = false, features = ["png"], optional = true } +[target.'cfg(target_os = "linux")'.dependencies] +nix = { version = "0.29", features = ["time"] } + [profile.release] opt-level = 3 lto = "fat" diff --git a/src/engine/dispatcher.rs b/src/engine/dispatcher.rs new file mode 100644 index 0000000..7c401c9 --- /dev/null +++ b/src/engine/dispatcher.rs @@ -0,0 +1,165 @@ +use arc_swap::ArcSwap; +use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; +use std::cmp::Ordering; +use std::collections::BinaryHeap; +use std::sync::Arc; +use std::time::Duration; + +use super::link::LinkState; +use super::sequencer::{AudioCommand, MidiCommand}; +use super::timing::{SyncTime, ACTIVE_WAIT_THRESHOLD_US}; + +/// A command scheduled for dispatch at a specific time. +#[derive(Clone)] +pub struct TimedCommand { + pub command: DispatchCommand, + pub target_time_us: SyncTime, +} + +/// Commands the dispatcher can send to audio/MIDI threads. +#[derive(Clone)] +pub enum DispatchCommand { + Audio { cmd: String, time: Option }, + Midi(MidiCommand), + FlushMidi, + Hush, + Panic, +} + +impl Ord for TimedCommand { + fn cmp(&self, other: &Self) -> Ordering { + // Reverse ordering for min-heap (earliest time first) + other.target_time_us.cmp(&self.target_time_us) + } +} + +impl PartialOrd for TimedCommand { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for TimedCommand { + fn eq(&self, other: &Self) -> bool { + self.target_time_us == other.target_time_us + } +} + +impl Eq for TimedCommand {} + +/// Main dispatcher loop - receives timed commands and dispatches them at the right moment. +pub fn dispatcher_loop( + cmd_rx: Receiver, + audio_tx: Arc>>, + midi_tx: Arc>>, + link: Arc, +) { + let mut queue: BinaryHeap = BinaryHeap::with_capacity(256); + + loop { + let current_us = link.clock_micros() as SyncTime; + + // Calculate timeout based on next queued event + let timeout_us = queue + .peek() + .map(|cmd| cmd.target_time_us.saturating_sub(current_us)) + .unwrap_or(100_000) // 100ms default when idle + .max(100); // Minimum 100μs to prevent busy-looping + + // Receive new commands (with timeout) + match cmd_rx.recv_timeout(Duration::from_micros(timeout_us)) { + Ok(cmd) => queue.push(cmd), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + + // Drain any additional pending commands + while let Ok(cmd) = cmd_rx.try_recv() { + queue.push(cmd); + } + + // Dispatch ready commands + let current_us = link.clock_micros() as SyncTime; + while let Some(cmd) = queue.peek() { + if cmd.target_time_us <= current_us + ACTIVE_WAIT_THRESHOLD_US { + let cmd = queue.pop().unwrap(); + wait_until_dispatch(cmd.target_time_us, &link); + dispatch_command(cmd.command, &audio_tx, &midi_tx); + } else { + break; + } + } + } +} + +/// Active-wait until the target time for precise dispatch. +fn wait_until_dispatch(target_us: SyncTime, link: &LinkState) { + while (link.clock_micros() as SyncTime) < target_us { + std::hint::spin_loop(); + } +} + +/// Route a command to the appropriate output channel. +fn dispatch_command( + cmd: DispatchCommand, + audio_tx: &Arc>>, + midi_tx: &Arc>>, +) { + match cmd { + DispatchCommand::Audio { cmd, time } => { + let _ = audio_tx + .load() + .try_send(AudioCommand::Evaluate { cmd, time }); + } + DispatchCommand::Midi(midi_cmd) => { + let _ = midi_tx.load().try_send(midi_cmd); + } + DispatchCommand::FlushMidi => { + // Send All Notes Off (CC 123) on all 16 channels for all 4 devices + for dev in 0..4u8 { + for chan in 0..16u8 { + let _ = midi_tx.load().try_send(MidiCommand::CC { + device: dev, + channel: chan, + cc: 123, + value: 0, + }); + } + } + } + DispatchCommand::Hush => { + let _ = audio_tx.load().try_send(AudioCommand::Hush); + } + DispatchCommand::Panic => { + let _ = audio_tx.load().try_send(AudioCommand::Panic); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_timed_command_ordering() { + let mut heap: BinaryHeap = BinaryHeap::new(); + + heap.push(TimedCommand { + command: DispatchCommand::Hush, + target_time_us: 300, + }); + heap.push(TimedCommand { + command: DispatchCommand::Hush, + target_time_us: 100, + }); + heap.push(TimedCommand { + command: DispatchCommand::Hush, + target_time_us: 200, + }); + + // Min-heap: earliest time should come out first + assert_eq!(heap.pop().unwrap().target_time_us, 100); + assert_eq!(heap.pop().unwrap().target_time_us, 200); + assert_eq!(heap.pop().unwrap().target_time_us, 300); + } +} diff --git a/src/engine/link.rs b/src/engine/link.rs index d8b2e3a..398b66d 100644 --- a/src/engine/link.rs +++ b/src/engine/link.rs @@ -37,12 +37,12 @@ impl LinkState { } pub fn quantum(&self) -> f64 { - f64::from_bits(self.quantum.load(Ordering::Relaxed)) + f64::from_bits(self.quantum.load(Ordering::Acquire)) } pub fn set_quantum(&self, quantum: f64) { let clamped = quantum.clamp(1.0, 16.0); - self.quantum.store(clamped.to_bits(), Ordering::Relaxed); + self.quantum.store(clamped.to_bits(), Ordering::Release); } pub fn clock_micros(&self) -> i64 { @@ -86,4 +86,10 @@ impl LinkState { self.link.capture_app_session_state(&mut state); state } + + pub fn beat_at_time(&self, time_us: i64, quantum: f64) -> f64 { + let mut state = SessionState::new(); + self.link.capture_app_session_state(&mut state); + state.beat_at_time(time_us, quantum) + } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index b76e29d..c04416c 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1,6 +1,13 @@ mod audio; +mod dispatcher; mod link; pub mod sequencer; +mod timing; + +pub use timing::{ + beats_to_micros, micros_to_beats, micros_until_next_substep, substeps_crossed, StepTiming, + SyncTime, ACTIVE_WAIT_THRESHOLD_US, NEVER, +}; // AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs #[allow(unused_imports)] diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index c039548..3538994 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -1,5 +1,5 @@ use arc_swap::ArcSwap; -use crossbeam_channel::{bounded, Receiver, Sender, TrySendError}; +use crossbeam_channel::{bounded, unbounded, Receiver, Sender}; use std::collections::HashMap; #[cfg(feature = "desktop")] use std::sync::atomic::AtomicU32; @@ -7,11 +7,15 @@ use std::sync::atomic::{AtomicI64, AtomicU64}; use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::Duration; -use thread_priority::ThreadPriority; #[cfg(not(unix))] use thread_priority::set_current_thread_priority; +use thread_priority::ThreadPriority; -use super::LinkState; +use super::dispatcher::{dispatcher_loop, DispatchCommand, TimedCommand}; +use super::{ + micros_until_next_substep, substeps_crossed, LinkState, StepTiming, SyncTime, + ACTIVE_WAIT_THRESHOLD_US, +}; use crate::model::{ CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables, }; @@ -301,10 +305,11 @@ pub fn spawn_sequencer( let audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx)); let midi_tx = Arc::new(ArcSwap::from_pointee(midi_tx)); + // Dispatcher channel (unbounded to avoid blocking the scheduler) + let (dispatch_tx, dispatch_rx) = unbounded::(); + let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default())); let shared_state_clone = Arc::clone(&shared_state); - let audio_tx_for_thread = Arc::clone(&audio_tx); - let midi_tx_for_thread = Arc::clone(&midi_tx); #[cfg(feature = "desktop")] let mouse_x = config.mouse_x; @@ -313,13 +318,28 @@ pub fn spawn_sequencer( #[cfg(feature = "desktop")] let mouse_down = config.mouse_down; + // Spawn dispatcher thread + let dispatcher_link = Arc::clone(&link); + let dispatcher_audio_tx = Arc::clone(&audio_tx); + let dispatcher_midi_tx = Arc::clone(&midi_tx); + thread::Builder::new() + .name("cagire-dispatcher".into()) + .spawn(move || { + dispatcher_loop( + dispatch_rx, + dispatcher_audio_tx, + dispatcher_midi_tx, + dispatcher_link, + ); + }) + .expect("Failed to spawn dispatcher thread"); + let thread = thread::Builder::new() .name("sequencer".into()) .spawn(move || { sequencer_loop( cmd_rx, - audio_tx_for_thread, - midi_tx_for_thread, + dispatch_tx, link, playing, variables, @@ -407,32 +427,13 @@ fn check_quantization_boundary( prev_beat: f64, quantum: f64, ) -> bool { - if prev_beat < 0.0 { - return false; - } match quantization { - LaunchQuantization::Immediate => true, - LaunchQuantization::Beat => beat.floor() as i64 != prev_beat.floor() as i64, - LaunchQuantization::Bar => { - let bar = (beat / quantum).floor() as i64; - let prev_bar = (prev_beat / quantum).floor() as i64; - bar != prev_bar - } - LaunchQuantization::Bars2 => { - let bars2 = (beat / (quantum * 2.0)).floor() as i64; - let prev_bars2 = (prev_beat / (quantum * 2.0)).floor() as i64; - bars2 != prev_bars2 - } - LaunchQuantization::Bars4 => { - let bars4 = (beat / (quantum * 4.0)).floor() as i64; - let prev_bars4 = (prev_beat / (quantum * 4.0)).floor() as i64; - bars4 != prev_bars4 - } - LaunchQuantization::Bars8 => { - let bars8 = (beat / (quantum * 8.0)).floor() as i64; - let prev_bars8 = (prev_beat / (quantum * 8.0)).floor() as i64; - bars8 != prev_bars8 - } + LaunchQuantization::Immediate => prev_beat >= 0.0, + LaunchQuantization::Beat => StepTiming::NextBeat.crossed(prev_beat, beat, quantum), + LaunchQuantization::Bar => StepTiming::NextBar.crossed(prev_beat, beat, quantum), + LaunchQuantization::Bars2 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 2.0), + LaunchQuantization::Bars4 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 4.0), + LaunchQuantization::Bars8 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 8.0), } } @@ -458,7 +459,8 @@ impl RunsCounter { } fn clear_pattern(&mut self, bank: usize, pattern: usize) { - self.counts.retain(|&(b, p, _), _| b != bank || p != pattern); + self.counts + .retain(|&(b, p, _), _| b != bank || p != pattern); } } @@ -470,7 +472,7 @@ pub(crate) struct TickInput { pub quantum: f64, pub fill: bool, pub nudge_secs: f64, - pub current_time_us: i64, + pub current_time_us: SyncTime, pub engine_time: f64, pub lookahead_secs: f64, #[cfg(feature = "desktop")] @@ -536,12 +538,6 @@ impl KeyCache { } } -#[derive(Clone, Copy)] -struct ActiveNote { - off_time_us: i64, - start_time_us: i64, -} - pub(crate) struct SequencerState { audio_state: AudioState, pattern_cache: PatternCache, @@ -558,7 +554,6 @@ pub(crate) struct SequencerState { buf_stopped: Vec, buf_completed_iterations: Vec, cc_access: Option>, - active_notes: HashMap<(u8, u8, u8), ActiveNote>, muted: std::collections::HashSet<(usize, usize)>, soloed: std::collections::HashSet<(usize, usize)>, } @@ -587,7 +582,6 @@ impl SequencerState { buf_stopped: Vec::with_capacity(16), buf_completed_iterations: Vec::with_capacity(16), cc_access, - active_notes: HashMap::new(), muted: std::collections::HashSet::new(), soloed: std::collections::HashSet::new(), } @@ -761,7 +755,8 @@ impl SequencerState { } } }; - self.runs_counter.clear_pattern(pending.id.bank, pending.id.pattern); + self.runs_counter + .clear_pattern(pending.id.bank, pending.id.pattern); self.audio_state.active_patterns.insert( pending.id, ActivePattern { @@ -806,7 +801,7 @@ impl SequencerState { quantum: f64, fill: bool, nudge_secs: f64, - _current_time_us: i64, + _current_time_us: SyncTime, engine_time: f64, lookahead_secs: f64, #[cfg(feature = "desktop")] mouse_x: f64, @@ -840,15 +835,7 @@ impl SequencerState { .get(&(active.bank, active.pattern)) .copied() .unwrap_or_else(|| pattern.speed.multiplier()); - let beat_int = (beat * 4.0 * speed_mult).floor() as i64; - let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64; - - // Fire ALL skipped steps when scheduler jitter causes us to miss beats - let steps_to_fire = if prev_beat >= 0.0 { - (beat_int - prev_beat_int).clamp(0, 16) as usize - } else { - 0 - }; + let steps_to_fire = substeps_crossed(prev_beat, beat, speed_mult); for _ in 0..steps_to_fire { result.any_step_fired = true; @@ -863,8 +850,7 @@ impl SequencerState { if step.active && has_script { let pattern_key = (active.bank, active.pattern); let is_muted = self.muted.contains(&pattern_key) - || (!self.soloed.is_empty() - && !self.soloed.contains(&pattern_key)); + || (!self.soloed.is_empty() && !self.soloed.contains(&pattern_key)); if !is_muted { let source_idx = pattern.resolve_source(step_idx); @@ -941,11 +927,7 @@ impl SequencerState { result } - fn read_variables( - &self, - completed: &[PatternId], - any_step_fired: bool, - ) -> VariableReads { + fn read_variables(&self, completed: &[PatternId], any_step_fired: bool) -> VariableReads { let stopped = &self.buf_stopped; let needs_access = !completed.is_empty() || !stopped.is_empty() || any_step_fired; if !needs_access { @@ -1037,8 +1019,7 @@ impl SequencerState { #[allow(clippy::too_many_arguments)] fn sequencer_loop( cmd_rx: Receiver, - audio_tx: Arc>>, - midi_tx: Arc>>, + dispatch_tx: Sender, link: Arc, playing: Arc, variables: Variables, @@ -1105,8 +1086,8 @@ fn sequencer_loop( } let state = link.capture_app_state(); - let current_time_us = link.clock_micros(); - let beat = state.beat_at_time(current_time_us, quantum); + let current_time_us = link.clock_micros() as SyncTime; + let beat = state.beat_at_time(current_time_us as i64, quantum); let tempo = state.tempo(); let sr = sample_rate.load(Ordering::Relaxed) as f64; @@ -1139,87 +1120,54 @@ fn sequencer_loop( let output = seq_state.tick(input); + // Dispatch commands via the dispatcher thread for tsc in output.audio_commands { if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) { - match midi_tx.load().try_send(midi_cmd.clone()) { - Ok(()) => { - if let ( - MidiCommand::NoteOn { - device, - channel, - note, - .. - }, - Some(dur_secs), - ) = (&midi_cmd, dur) - { - let dur_us = (dur_secs * 1_000_000.0) as i64; - seq_state.active_notes.insert( - (*device, *channel, *note), - ActiveNote { - off_time_us: current_time_us + dur_us, - start_time_us: current_time_us, - }, - ); - } - } - Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => { - seq_state.dropped_events += 1; - } + // Queue MIDI command for immediate dispatch + let _ = dispatch_tx.send(TimedCommand { + command: DispatchCommand::Midi(midi_cmd.clone()), + target_time_us: current_time_us, + }); + + // Schedule note-off if duration specified + if let ( + MidiCommand::NoteOn { + device, + channel, + note, + .. + }, + Some(dur_secs), + ) = (&midi_cmd, dur) + { + let off_time_us = current_time_us + (dur_secs * 1_000_000.0) as SyncTime; + let _ = dispatch_tx.send(TimedCommand { + command: DispatchCommand::Midi(MidiCommand::NoteOff { + device: *device, + channel: *channel, + note: *note, + }), + target_time_us: off_time_us, + }); } } else { - let cmd = AudioCommand::Evaluate { - cmd: tsc.cmd, - time: tsc.time, - }; - match audio_tx.load().try_send(cmd) { - Ok(()) => {} - Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => { - seq_state.dropped_events += 1; - } - } + // Queue audio command + let _ = dispatch_tx.send(TimedCommand { + command: DispatchCommand::Audio { + cmd: tsc.cmd, + time: tsc.time, + }, + target_time_us: current_time_us, + }); } } - const MAX_NOTE_DURATION_US: i64 = 30_000_000; // 30 second safety timeout - + // Handle MIDI flush request if output.flush_midi_notes { - for ((device, channel, note), _) in seq_state.active_notes.drain() { - let _ = midi_tx.load().try_send(MidiCommand::NoteOff { - device, - channel, - note, - }); - } - // Send MIDI panic (CC 123 = All Notes Off) on all 16 channels for all devices - for dev in 0..4u8 { - for chan in 0..16u8 { - let _ = midi_tx.load().try_send(MidiCommand::CC { - device: dev, - channel: chan, - cc: 123, - value: 0, - }); - } - } - } else { - seq_state - .active_notes - .retain(|&(device, channel, note), active| { - let should_release = current_time_us >= active.off_time_us; - let timed_out = (current_time_us - active.start_time_us) > MAX_NOTE_DURATION_US; - - if should_release || timed_out { - let _ = midi_tx.load().try_send(MidiCommand::NoteOff { - device, - channel, - note, - }); - false - } else { - true - } - }); + let _ = dispatch_tx.send(TimedCommand { + command: DispatchCommand::FlushMidi, + target_time_us: current_time_us, + }); } if let Some(t) = output.new_tempo { @@ -1228,16 +1176,34 @@ fn sequencer_loop( shared_state.store(Arc::new(output.shared_state)); - // Adaptive sleep: calculate time until next substep boundary - // At max speed (8x), substeps occur every beat/32 - // Sleep for most of that time, leaving 500μs margin for processing - let beats_per_sec = tempo / 60.0; - let max_speed = 8.0; // Maximum speed multiplier from speed.clamp() - let secs_per_substep = 1.0 / (beats_per_sec * 4.0 * max_speed); - let substep_us = (secs_per_substep * 1_000_000.0) as u64; - // Sleep for most of the substep duration, clamped to reasonable bounds - let sleep_us = substep_us.saturating_sub(500).clamp(50, 2000); - precise_sleep(sleep_us); + // Calculate time until next substep based on active patterns + let next_event_us = { + let mut min_micros = SyncTime::MAX; + for id in seq_state.audio_state.active_patterns.keys() { + let speed = seq_state + .speed_overrides + .get(&(id.bank, id.pattern)) + .copied() + .or_else(|| { + seq_state + .pattern_cache + .get(id.bank, id.pattern) + .map(|p| p.speed.multiplier()) + }) + .unwrap_or(1.0); + let micros = micros_until_next_substep(beat, speed, tempo); + min_micros = min_micros.min(micros); + } + // If no active patterns, default to 1ms for command responsiveness + if min_micros == SyncTime::MAX { + 1000 + } else { + min_micros.max(50) // Minimum 50μs to prevent excessive CPU usage + } + }; + + let target_time_us = current_time_us + next_event_us; + wait_until(target_time_us, &link); } } @@ -1285,6 +1251,21 @@ fn precise_sleep(micros: u64) { thread::sleep(Duration::from_micros(micros)); } +/// Two-phase wait: bulk sleep followed by active spin-wait for final precision. +fn wait_until(target_us: SyncTime, link: &LinkState) { + let current = link.clock_micros() as SyncTime; + let remaining = target_us.saturating_sub(current); + + if remaining > ACTIVE_WAIT_THRESHOLD_US { + precise_sleep(remaining - ACTIVE_WAIT_THRESHOLD_US); + } + + // Active wait for final precision + while (link.clock_micros() as SyncTime) < target_us { + std::hint::spin_loop(); + } +} + fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option)> { if !cmd.starts_with("/midi/") { return None; @@ -2131,7 +2112,10 @@ mod tests { } // Should fire steps continuously without gaps - assert!(step_count > 350, "Expected continuous steps, got {step_count}"); + assert!( + step_count > 350, + "Expected continuous steps, got {step_count}" + ); } #[test] diff --git a/src/engine/timing.rs b/src/engine/timing.rs new file mode 100644 index 0000000..45d2296 --- /dev/null +++ b/src/engine/timing.rs @@ -0,0 +1,225 @@ +/// Microsecond-precision timestamp for audio synchronization. +pub type SyncTime = u64; + +/// Sentinel value representing "never" or "no scheduled time". +pub const NEVER: SyncTime = SyncTime::MAX; + +/// Convert beat duration to microseconds at given tempo. +pub fn beats_to_micros(beats: f64, tempo: f64) -> SyncTime { + if tempo <= 0.0 { + return 0; + } + ((beats / tempo) * 60_000_000.0).round() as SyncTime +} + +/// Convert microseconds to beats at given tempo. +pub fn micros_to_beats(micros: SyncTime, tempo: f64) -> f64 { + if tempo <= 0.0 { + return 0.0; + } + (tempo * (micros as f64)) / 60_000_000.0 +} + +/// Timing boundary types for step and pattern scheduling. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepTiming { + /// Fire at a specific absolute substep number. + Substep(u64), + /// Fire when any substep boundary is crossed (4 substeps per beat). + NextSubstep, + /// Fire when a beat boundary is crossed. + NextBeat, + /// Fire when a bar/quantum boundary is crossed. + NextBar, +} + +impl StepTiming { + /// Returns true if the boundary was crossed between prev_beat and curr_beat. + pub fn crossed(&self, prev_beat: f64, curr_beat: f64, quantum: f64) -> bool { + if prev_beat < 0.0 { + return false; + } + match self { + Self::NextSubstep => { + (prev_beat * 4.0).floor() as i64 != (curr_beat * 4.0).floor() as i64 + } + Self::NextBeat => prev_beat.floor() as i64 != curr_beat.floor() as i64, + Self::NextBar => { + (prev_beat / quantum).floor() as i64 != (curr_beat / quantum).floor() as i64 + } + Self::Substep(target) => { + let prev_substep = (prev_beat * 4.0).floor() as i64; + let curr_substep = (curr_beat * 4.0).floor() as i64; + prev_substep < *target as i64 && curr_substep >= *target as i64 + } + } + } +} + +/// Calculate how many substeps were crossed between two beat positions. +/// Speed multiplier affects the substep rate (2x speed = 2x substeps per beat). +pub fn substeps_crossed(prev_beat: f64, curr_beat: f64, speed: f64) -> usize { + if prev_beat < 0.0 { + return 0; + } + let prev_substep = (prev_beat * 4.0 * speed).floor() as i64; + let curr_substep = (curr_beat * 4.0 * speed).floor() as i64; + (curr_substep - prev_substep).clamp(0, 16) as usize +} + +/// Threshold for switching from sleep to active wait (100μs). +pub const ACTIVE_WAIT_THRESHOLD_US: SyncTime = 100; + +/// Calculate microseconds until the next substep boundary. +pub fn micros_until_next_substep(current_beat: f64, speed: f64, tempo: f64) -> SyncTime { + if tempo <= 0.0 || speed <= 0.0 { + return 0; + } + let substeps_per_beat = 4.0 * speed; + let current_substep = (current_beat * substeps_per_beat).floor(); + let next_substep_beat = (current_substep + 1.0) / substeps_per_beat; + let beats_until = next_substep_beat - current_beat; + beats_to_micros(beats_until, tempo) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_beats_to_micros_at_120_bpm() { + // At 120 BPM, one beat = 0.5 seconds = 500,000 microseconds + assert_eq!(beats_to_micros(1.0, 120.0), 500_000); + assert_eq!(beats_to_micros(2.0, 120.0), 1_000_000); + assert_eq!(beats_to_micros(0.5, 120.0), 250_000); + } + + #[test] + fn test_micros_to_beats_at_120_bpm() { + // At 120 BPM, 500,000 microseconds = 1 beat + assert!((micros_to_beats(500_000, 120.0) - 1.0).abs() < 1e-10); + assert!((micros_to_beats(1_000_000, 120.0) - 2.0).abs() < 1e-10); + } + + #[test] + fn test_zero_tempo() { + assert_eq!(beats_to_micros(1.0, 0.0), 0); + assert_eq!(micros_to_beats(1_000_000, 0.0), 0.0); + } + + #[test] + fn test_roundtrip() { + let tempo = 135.0; + let beats = 3.75; + let micros = beats_to_micros(beats, tempo); + let back = micros_to_beats(micros, tempo); + assert!((back - beats).abs() < 1e-6); + } + + #[test] + fn test_step_timing_substep_crossed() { + // Crossing from substep 0 to substep 1 (beat 0.0 to 0.26) + assert!(StepTiming::NextSubstep.crossed(0.0, 0.26, 4.0)); + // Not crossing (both in same substep) + assert!(!StepTiming::NextSubstep.crossed(0.26, 0.27, 4.0)); + // Negative prev_beat returns false + assert!(!StepTiming::NextSubstep.crossed(-1.0, 0.5, 4.0)); + } + + #[test] + fn test_step_timing_beat_crossed() { + // Crossing from beat 0 to beat 1 + assert!(StepTiming::NextBeat.crossed(0.9, 1.1, 4.0)); + // Not crossing (both in same beat) + assert!(!StepTiming::NextBeat.crossed(0.5, 0.9, 4.0)); + // Negative prev_beat returns false + assert!(!StepTiming::NextBeat.crossed(-1.0, 1.0, 4.0)); + } + + #[test] + fn test_step_timing_bar_crossed() { + // Crossing from bar 0 to bar 1 (quantum=4) + assert!(StepTiming::NextBar.crossed(3.9, 4.1, 4.0)); + // Not crossing (both in same bar) + assert!(!StepTiming::NextBar.crossed(2.0, 3.0, 4.0)); + // Crossing with different quantum + assert!(StepTiming::NextBar.crossed(7.9, 8.1, 8.0)); + } + + #[test] + fn test_step_timing_at_substep() { + // Crossing to substep 4 (beat 1.0) + assert!(StepTiming::Substep(4).crossed(0.9, 1.1, 4.0)); + // Not yet at substep 4 + assert!(!StepTiming::Substep(4).crossed(0.5, 0.9, 4.0)); + // Already past substep 4 + assert!(!StepTiming::Substep(4).crossed(1.5, 2.0, 4.0)); + } + + #[test] + fn test_substeps_crossed_normal() { + // One substep crossed at 1x speed + assert_eq!(substeps_crossed(0.0, 0.26, 1.0), 1); + // Two substeps crossed + assert_eq!(substeps_crossed(0.0, 0.51, 1.0), 2); + // No substep crossed + assert_eq!(substeps_crossed(0.1, 0.2, 1.0), 0); + } + + #[test] + fn test_substeps_crossed_with_speed() { + // At 2x speed, 0.5 beats = 4 substeps + assert_eq!(substeps_crossed(0.0, 0.5, 2.0), 4); + // At 0.5x speed, 0.5 beats = 1 substep + assert_eq!(substeps_crossed(0.0, 0.5, 0.5), 1); + } + + #[test] + fn test_substeps_crossed_negative_prev() { + // Negative prev_beat returns 0 + assert_eq!(substeps_crossed(-1.0, 0.5, 1.0), 0); + } + + #[test] + fn test_substeps_crossed_clamp() { + // Large jump clamped to 16 + assert_eq!(substeps_crossed(0.0, 100.0, 1.0), 16); + } + + #[test] + fn test_micros_until_next_substep_at_beat_zero() { + // At beat 0.0, speed 1.0, tempo 120 BPM + // Next substep is at beat 0.25 (1/4 beat) + // 1/4 beat at 120 BPM = 0.25 / 120 * 60_000_000 = 125_000 μs + let micros = micros_until_next_substep(0.0, 1.0, 120.0); + assert_eq!(micros, 125_000); + } + + #[test] + fn test_micros_until_next_substep_near_boundary() { + // At beat 0.24, almost at the substep boundary (0.25) + // Next substep at 0.25, so 0.01 beats away + let micros = micros_until_next_substep(0.24, 1.0, 120.0); + // 0.01 beats at 120 BPM = 5000 μs + assert_eq!(micros, 5000); + } + + #[test] + fn test_micros_until_next_substep_with_speed() { + // At 2x speed, substeps are at 0.125, 0.25, 0.375... + // At beat 0.0, next substep is at 0.125 + let micros = micros_until_next_substep(0.0, 2.0, 120.0); + // 0.125 beats at 120 BPM = 62_500 μs + assert_eq!(micros, 62_500); + } + + #[test] + fn test_micros_until_next_substep_zero_tempo() { + assert_eq!(micros_until_next_substep(0.0, 1.0, 0.0), 0); + } + + #[test] + fn test_micros_until_next_substep_zero_speed() { + assert_eq!(micros_until_next_substep(0.0, 0.0, 120.0), 0); + } +} diff --git a/src/input_egui.rs b/src/input_egui.rs index f06e878..da6f0c6 100644 --- a/src/input_egui.rs +++ b/src/input_egui.rs @@ -25,7 +25,8 @@ fn convert_event(event: &egui::Event) -> Option { } let mods = convert_modifiers(*modifiers); // For character keys without ctrl/alt, let Event::Text handle it - if is_character_key(*key) && !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) { + if is_character_key(*key) && !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { return None; } let code = convert_key(*key)?;