WIP: not sure

This commit is contained in:
2026-02-03 02:31:55 +01:00
parent 33ee1822a5
commit b305df3d79
7 changed files with 547 additions and 159 deletions

165
src/engine/dispatcher.rs Normal file
View File

@@ -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<f64> },
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<Ordering> {
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<TimedCommand>,
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
link: Arc<LinkState>,
) {
let mut queue: BinaryHeap<TimedCommand> = 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<ArcSwap<Sender<AudioCommand>>>,
midi_tx: &Arc<ArcSwap<Sender<MidiCommand>>>,
) {
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<TimedCommand> = 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);
}
}

View File

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

View File

@@ -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)]

View File

@@ -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::<TimedCommand>();
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<PatternId>,
buf_completed_iterations: Vec<PatternId>,
cc_access: Option<Arc<dyn CcAccess>>,
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<SeqCommand>,
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
dispatch_tx: Sender<TimedCommand>,
link: Arc<LinkState>,
playing: Arc<std::sync::atomic::AtomicBool>,
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<f64>)> {
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]

225
src/engine/timing.rs Normal file
View File

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