Feat: really good lookahead mechanism for scheduling
This commit is contained in:
@@ -8,10 +8,10 @@ All notable changes to this project will be documented in this file.
|
|||||||
- TachyonFX based animations
|
- TachyonFX based animations
|
||||||
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- Removed lookahead from sequencer; event timing now uses `engine_time + time_delta` directly.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Sequencer rewritten with prospective lookahead scheduling. Instead of sleeping until a substep, waking late, and detecting past events, the sequencer now pre-computes all events within a ~20ms forward window. Events arrive at doux with positive time deltas, scheduled before they need to fire. Sleep+spin-wait replaced by `recv_timeout(3ms)` on the command channel. Timing no longer depends on OS sleep precision.
|
||||||
|
- `audio_sample_pos` updated at buffer start instead of end, so `engine_time` reflects current playback position.
|
||||||
|
- Doux grace period increased from 1ms to 50ms as a safety net (events should never be late with lookahead).
|
||||||
- Flattened model re-export indirection; `script.rs` now exports only `ScriptEngine`.
|
- Flattened model re-export indirection; `script.rs` now exports only `ScriptEngine`.
|
||||||
- Hue rotation step size increased from 1° to 5° for faster adjustment.
|
- Hue rotation step size increased from 1° to 5° for faster adjustment.
|
||||||
- Moved catalog data (DOCS, CATEGORIES) from views to `src/model/`, eliminating state-to-view layer inversion.
|
- Moved catalog data (DOCS, CATEGORIES) from views to `src/model/`, eliminating state-to-view layer inversion.
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ pub fn build_stream(
|
|||||||
let buffer_samples = data.len() / channels;
|
let buffer_samples = data.len() / channels;
|
||||||
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
|
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
|
||||||
|
|
||||||
|
audio_sample_pos.fetch_add(buffer_samples as u64, Ordering::Release);
|
||||||
|
|
||||||
while let Ok(cmd) = audio_rx.try_recv() {
|
while let Ok(cmd) = audio_rx.try_recv() {
|
||||||
match cmd {
|
match cmd {
|
||||||
AudioCommand::Evaluate { cmd, time } => {
|
AudioCommand::Evaluate { cmd, time } => {
|
||||||
@@ -335,8 +337,6 @@ pub fn build_stream(
|
|||||||
engine.process_block(data, &[], &[]);
|
engine.process_block(data, &[], &[]);
|
||||||
scope_buffer.write(&engine.output);
|
scope_buffer.write(&engine.output);
|
||||||
|
|
||||||
audio_sample_pos.fetch_add(buffer_samples as u64, Ordering::Relaxed);
|
|
||||||
|
|
||||||
// Feed mono mix to analysis thread via ring buffer (non-blocking)
|
// Feed mono mix to analysis thread via ring buffer (non-blocking)
|
||||||
for chunk in engine.output.chunks(channels) {
|
for chunk in engine.output.chunks(channels) {
|
||||||
let mono = chunk.iter().sum::<f32>() / channels as f32;
|
let mono = chunk.iter().sum::<f32>() / channels as f32;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ pub mod realtime;
|
|||||||
pub mod sequencer;
|
pub mod sequencer;
|
||||||
mod timing;
|
mod timing;
|
||||||
|
|
||||||
pub use timing::{micros_until_next_substep, substeps_crossed, StepTiming, SyncTime};
|
pub use timing::{substeps_in_window, StepTiming, SyncTime};
|
||||||
|
|
||||||
// AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs
|
// AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use std::sync::Arc;
|
|||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
use super::dispatcher::{dispatcher_loop, MidiDispatch, TimedMidiCommand};
|
use super::dispatcher::{dispatcher_loop, MidiDispatch, TimedMidiCommand};
|
||||||
use super::realtime::{precise_sleep_us, set_realtime_priority};
|
use super::realtime::set_realtime_priority;
|
||||||
use super::{micros_until_next_substep, substeps_crossed, LinkState, StepTiming, SyncTime};
|
use super::{substeps_in_window, LinkState, StepTiming, SyncTime};
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables,
|
CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables,
|
||||||
};
|
};
|
||||||
@@ -450,6 +450,7 @@ pub(crate) struct TickInput {
|
|||||||
pub commands: Vec<SeqCommand>,
|
pub commands: Vec<SeqCommand>,
|
||||||
pub playing: bool,
|
pub playing: bool,
|
||||||
pub beat: f64,
|
pub beat: f64,
|
||||||
|
pub lookahead_end: f64,
|
||||||
pub tempo: f64,
|
pub tempo: f64,
|
||||||
pub quantum: f64,
|
pub quantum: f64,
|
||||||
pub fill: bool,
|
pub fill: bool,
|
||||||
@@ -682,15 +683,16 @@ impl SequencerState {
|
|||||||
return self.tick_paused();
|
return self.tick_paused();
|
||||||
}
|
}
|
||||||
|
|
||||||
let beat = input.beat;
|
let frontier = self.audio_state.prev_beat;
|
||||||
let prev_beat = self.audio_state.prev_beat;
|
let lookahead_end = input.lookahead_end;
|
||||||
|
|
||||||
self.activate_pending(beat, prev_beat, input.quantum);
|
self.activate_pending(lookahead_end, frontier, input.quantum);
|
||||||
self.deactivate_pending(beat, prev_beat, input.quantum);
|
self.deactivate_pending(lookahead_end, frontier, input.quantum);
|
||||||
|
|
||||||
let steps = self.execute_steps(
|
let steps = self.execute_steps(
|
||||||
beat,
|
input.beat,
|
||||||
prev_beat,
|
frontier,
|
||||||
|
lookahead_end,
|
||||||
input.tempo,
|
input.tempo,
|
||||||
input.quantum,
|
input.quantum,
|
||||||
input.fill,
|
input.fill,
|
||||||
@@ -708,7 +710,7 @@ impl SequencerState {
|
|||||||
let vars = self.read_variables(&self.buf_completed_iterations, steps.any_step_fired);
|
let vars = self.read_variables(&self.buf_completed_iterations, steps.any_step_fired);
|
||||||
self.apply_chain_transitions(vars.chain_transitions);
|
self.apply_chain_transitions(vars.chain_transitions);
|
||||||
|
|
||||||
self.audio_state.prev_beat = beat;
|
self.audio_state.prev_beat = lookahead_end;
|
||||||
|
|
||||||
let flush = std::mem::take(&mut self.audio_state.flush_midi_notes);
|
let flush = std::mem::take(&mut self.audio_state.flush_midi_notes);
|
||||||
TickOutput {
|
TickOutput {
|
||||||
@@ -805,7 +807,8 @@ impl SequencerState {
|
|||||||
fn execute_steps(
|
fn execute_steps(
|
||||||
&mut self,
|
&mut self,
|
||||||
beat: f64,
|
beat: f64,
|
||||||
prev_beat: f64,
|
frontier: f64,
|
||||||
|
lookahead_end: f64,
|
||||||
tempo: f64,
|
tempo: f64,
|
||||||
quantum: f64,
|
quantum: f64,
|
||||||
fill: bool,
|
fill: bool,
|
||||||
@@ -843,17 +846,13 @@ impl SequencerState {
|
|||||||
.get(&(active.bank, active.pattern))
|
.get(&(active.bank, active.pattern))
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or_else(|| pattern.speed.multiplier());
|
.unwrap_or_else(|| pattern.speed.multiplier());
|
||||||
let steps_to_fire = substeps_crossed(prev_beat, beat, speed_mult);
|
|
||||||
let substeps_per_beat = 4.0 * speed_mult;
|
|
||||||
let base_substep = (prev_beat * substeps_per_beat).floor() as i64;
|
|
||||||
|
|
||||||
for step_offset in 0..steps_to_fire {
|
let step_beats = substeps_in_window(frontier, lookahead_end, speed_mult);
|
||||||
|
|
||||||
|
for step_beat in step_beats {
|
||||||
result.any_step_fired = true;
|
result.any_step_fired = true;
|
||||||
let step_idx = active.step_index % pattern.length;
|
let step_idx = active.step_index % pattern.length;
|
||||||
|
|
||||||
// Per-step timing: each step gets its exact beat position
|
|
||||||
let step_substep = base_substep + step_offset as i64 + 1;
|
|
||||||
let step_beat = step_substep as f64 / substeps_per_beat;
|
|
||||||
let beat_delta = step_beat - beat;
|
let beat_delta = step_beat - beat;
|
||||||
let time_delta = if tempo > 0.0 {
|
let time_delta = if tempo > 0.0 {
|
||||||
(beat_delta / tempo) * 60.0
|
(beat_delta / tempo) * 60.0
|
||||||
@@ -882,11 +881,11 @@ impl SequencerState {
|
|||||||
);
|
);
|
||||||
let ctx = StepContext {
|
let ctx = StepContext {
|
||||||
step: step_idx,
|
step: step_idx,
|
||||||
beat,
|
beat: step_beat,
|
||||||
bank: active.bank,
|
bank: active.bank,
|
||||||
pattern: active.pattern,
|
pattern: active.pattern,
|
||||||
tempo,
|
tempo,
|
||||||
phase: beat % quantum,
|
phase: step_beat % quantum,
|
||||||
slot: 0,
|
slot: 0,
|
||||||
runs,
|
runs,
|
||||||
iter: active.iter,
|
iter: active.iter,
|
||||||
@@ -1077,26 +1076,31 @@ fn sequencer_loop(
|
|||||||
) {
|
) {
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
let has_rt = set_realtime_priority();
|
set_realtime_priority();
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
if !has_rt {
|
|
||||||
eprintln!("[cagire] Warning: Could not set realtime priority for sequencer thread.");
|
|
||||||
eprintln!("[cagire] For best performance on Linux, configure rtprio limits:");
|
|
||||||
eprintln!("[cagire] Add your user to 'audio' group: sudo usermod -aG audio $USER");
|
|
||||||
eprintln!("[cagire] Edit /etc/security/limits.conf and add:");
|
|
||||||
eprintln!("[cagire] @audio - rtprio 95");
|
|
||||||
eprintln!("[cagire] @audio - memlock unlimited");
|
|
||||||
eprintln!("[cagire] Then log out and back in.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new()));
|
let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new()));
|
||||||
let dict: Dictionary = Arc::new(Mutex::new(HashMap::new()));
|
let dict: Dictionary = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
|
let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
|
||||||
let mut seq_state = SequencerState::new(variables, dict, rng, cc_access);
|
let mut seq_state = SequencerState::new(variables, dict, rng, cc_access);
|
||||||
|
|
||||||
|
// Lookahead window: ~20ms expressed in beats, recomputed each tick
|
||||||
|
const LOOKAHEAD_SECS: f64 = 0.02;
|
||||||
|
// Wake cadence: how long to sleep between scheduling passes
|
||||||
|
const WAKE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(3);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// Drain all pending commands, also serves as the sleep mechanism
|
||||||
let mut commands = Vec::with_capacity(8);
|
let mut commands = Vec::with_capacity(8);
|
||||||
|
match cmd_rx.recv_timeout(WAKE_INTERVAL) {
|
||||||
|
Ok(cmd) => {
|
||||||
|
if matches!(cmd, SeqCommand::Shutdown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commands.push(cmd);
|
||||||
|
}
|
||||||
|
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => return,
|
||||||
|
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
|
||||||
|
}
|
||||||
while let Ok(cmd) = cmd_rx.try_recv() {
|
while let Ok(cmd) = cmd_rx.try_recv() {
|
||||||
if matches!(cmd, SeqCommand::Shutdown) {
|
if matches!(cmd, SeqCommand::Shutdown) {
|
||||||
return;
|
return;
|
||||||
@@ -1109,6 +1113,13 @@ fn sequencer_loop(
|
|||||||
let beat = state.beat_at_time(current_time_us as i64, quantum);
|
let beat = state.beat_at_time(current_time_us as i64, quantum);
|
||||||
let tempo = state.tempo();
|
let tempo = state.tempo();
|
||||||
|
|
||||||
|
let lookahead_beats = if tempo > 0.0 {
|
||||||
|
LOOKAHEAD_SECS * tempo / 60.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let lookahead_end = beat + lookahead_beats;
|
||||||
|
|
||||||
let sr = sample_rate.load(Ordering::Relaxed) as f64;
|
let sr = sample_rate.load(Ordering::Relaxed) as f64;
|
||||||
let audio_samples = audio_sample_pos.load(Ordering::Acquire);
|
let audio_samples = audio_sample_pos.load(Ordering::Acquire);
|
||||||
let engine_time = if sr > 0.0 {
|
let engine_time = if sr > 0.0 {
|
||||||
@@ -1120,6 +1131,7 @@ fn sequencer_loop(
|
|||||||
commands,
|
commands,
|
||||||
playing: playing.load(Ordering::Relaxed),
|
playing: playing.load(Ordering::Relaxed),
|
||||||
beat,
|
beat,
|
||||||
|
lookahead_end,
|
||||||
tempo,
|
tempo,
|
||||||
quantum,
|
quantum,
|
||||||
fill: live_keys.fill(),
|
fill: live_keys.fill(),
|
||||||
@@ -1165,7 +1177,6 @@ fn sequencer_loop(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Audio direct to doux — sample-accurate scheduling via /time/ parameter
|
|
||||||
let _ = audio_tx.load().send(AudioCommand::Evaluate {
|
let _ = audio_tx.load().send(AudioCommand::Evaluate {
|
||||||
cmd: tsc.cmd,
|
cmd: tsc.cmd,
|
||||||
time: tsc.time,
|
time: tsc.time,
|
||||||
@@ -1185,63 +1196,6 @@ fn sequencer_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
shared_state.store(Arc::new(output.shared_state));
|
shared_state.store(Arc::new(output.shared_state));
|
||||||
|
|
||||||
// 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, has_rt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spin-wait threshold in microseconds. With RT priority, we sleep most of the
|
|
||||||
/// wait time and spin-wait for the final portion for precision. Without RT priority,
|
|
||||||
/// spinning is counterproductive and we sleep the entire duration.
|
|
||||||
const SPIN_THRESHOLD_US: SyncTime = 100;
|
|
||||||
|
|
||||||
/// Two-phase wait: sleep most of the time, optionally spin-wait for final precision.
|
|
||||||
/// With RT priority: sleep + spin for precision
|
|
||||||
/// Without RT priority: sleep only (spinning wastes CPU without benefit)
|
|
||||||
fn wait_until(target_us: SyncTime, link: &LinkState, has_rt_priority: bool) {
|
|
||||||
let current = link.clock_micros() as SyncTime;
|
|
||||||
let remaining = target_us.saturating_sub(current);
|
|
||||||
|
|
||||||
if has_rt_priority {
|
|
||||||
// With RT priority: sleep most, spin for final precision
|
|
||||||
if remaining > SPIN_THRESHOLD_US {
|
|
||||||
precise_sleep_us(remaining - SPIN_THRESHOLD_US);
|
|
||||||
}
|
|
||||||
while (link.clock_micros() as SyncTime) < target_us {
|
|
||||||
std::hint::spin_loop();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Without RT priority: sleep the entire time (spin-waiting is counterproductive)
|
|
||||||
if remaining > 0 {
|
|
||||||
precise_sleep_us(remaining);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1390,6 +1344,7 @@ mod tests {
|
|||||||
commands: Vec::new(),
|
commands: Vec::new(),
|
||||||
playing,
|
playing,
|
||||||
beat,
|
beat,
|
||||||
|
lookahead_end: beat,
|
||||||
tempo: 120.0,
|
tempo: 120.0,
|
||||||
quantum: 4.0,
|
quantum: 4.0,
|
||||||
fill: false,
|
fill: false,
|
||||||
@@ -1410,6 +1365,7 @@ mod tests {
|
|||||||
commands,
|
commands,
|
||||||
playing: true,
|
playing: true,
|
||||||
beat,
|
beat,
|
||||||
|
lookahead_end: beat,
|
||||||
tempo: 120.0,
|
tempo: 120.0,
|
||||||
quantum: 4.0,
|
quantum: 4.0,
|
||||||
fill: false,
|
fill: false,
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
/// Microsecond-precision timestamp for audio synchronization.
|
/// Microsecond-precision timestamp for audio synchronization.
|
||||||
pub type SyncTime = u64;
|
pub type SyncTime = u64;
|
||||||
|
|
||||||
/// Convert beat duration to microseconds at given tempo.
|
|
||||||
fn beats_to_micros(beats: f64, tempo: f64) -> SyncTime {
|
|
||||||
if tempo <= 0.0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
((beats / tempo) * 60_000_000.0).round() as SyncTime
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Timing boundary types for step and pattern scheduling.
|
/// Timing boundary types for step and pattern scheduling.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum StepTiming {
|
pub enum StepTiming {
|
||||||
@@ -33,19 +25,45 @@ impl StepTiming {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate how many substeps were crossed between two beat positions.
|
/// Return the beat positions of all substeps in the window [frontier, end).
|
||||||
/// Speed multiplier affects the substep rate (2x speed = 2x substeps per beat).
|
/// Each entry is the exact beat at which that substep fires.
|
||||||
pub fn substeps_crossed(prev_beat: f64, curr_beat: f64, speed: f64) -> usize {
|
/// Clamped to 64 results max to prevent runaway.
|
||||||
|
pub fn substeps_in_window(frontier: f64, end: f64, speed: f64) -> Vec<f64> {
|
||||||
|
if frontier < 0.0 || end <= frontier || speed <= 0.0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let substeps_per_beat = 4.0 * speed;
|
||||||
|
let first = (frontier * substeps_per_beat).floor() as i64 + 1;
|
||||||
|
let last = (end * substeps_per_beat).floor() as i64;
|
||||||
|
let count = (last - first + 1).clamp(0, 64) as usize;
|
||||||
|
let mut result = Vec::with_capacity(count);
|
||||||
|
for i in 0..count as i64 {
|
||||||
|
result.push((first + i) as f64 / substeps_per_beat);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn beats_to_micros(beats: f64, tempo: f64) -> SyncTime {
|
||||||
|
if tempo <= 0.0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
((beats / tempo) * 60_000_000.0).round() as SyncTime
|
||||||
|
}
|
||||||
|
|
||||||
|
fn substeps_crossed(prev_beat: f64, curr_beat: f64, speed: f64) -> usize {
|
||||||
if prev_beat < 0.0 {
|
if prev_beat < 0.0 {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let prev_substep = (prev_beat * 4.0 * speed).floor() as i64;
|
let prev_substep = (prev_beat * 4.0 * speed).floor() as i64;
|
||||||
let curr_substep = (curr_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
|
(curr_substep - prev_substep).clamp(0, 16) as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate microseconds until the next substep boundary.
|
fn micros_until_next_substep(current_beat: f64, speed: f64, tempo: f64) -> SyncTime {
|
||||||
pub fn micros_until_next_substep(current_beat: f64, speed: f64, tempo: f64) -> SyncTime {
|
|
||||||
if tempo <= 0.0 || speed <= 0.0 {
|
if tempo <= 0.0 || speed <= 0.0 {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -54,11 +72,7 @@ pub fn micros_until_next_substep(current_beat: f64, speed: f64, tempo: f64) -> S
|
|||||||
let next_substep_beat = (current_substep + 1.0) / substeps_per_beat;
|
let next_substep_beat = (current_substep + 1.0) / substeps_per_beat;
|
||||||
let beats_until = next_substep_beat - current_beat;
|
let beats_until = next_substep_beat - current_beat;
|
||||||
beats_to_micros(beats_until, tempo)
|
beats_to_micros(beats_until, tempo)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_beats_to_micros_at_120_bpm() {
|
fn test_beats_to_micros_at_120_bpm() {
|
||||||
@@ -159,4 +173,40 @@ mod tests {
|
|||||||
fn test_micros_until_next_substep_zero_speed() {
|
fn test_micros_until_next_substep_zero_speed() {
|
||||||
assert_eq!(micros_until_next_substep(0.0, 0.0, 120.0), 0);
|
assert_eq!(micros_until_next_substep(0.0, 0.0, 120.0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substeps_in_window_basic() {
|
||||||
|
// At 1x speed, substeps at 0.25, 0.5, 0.75, 1.0...
|
||||||
|
// Window [0.0, 0.5) should contain 0.25 and 0.5
|
||||||
|
let result = substeps_in_window(0.0, 0.5, 1.0);
|
||||||
|
assert_eq!(result, vec![0.25, 0.5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substeps_in_window_2x_speed() {
|
||||||
|
// At 2x speed, substeps at 0.125, 0.25, 0.375, 0.5...
|
||||||
|
// Window [0.0, 0.5) should contain 4 substeps
|
||||||
|
let result = substeps_in_window(0.0, 0.5, 2.0);
|
||||||
|
assert_eq!(result, vec![0.125, 0.25, 0.375, 0.5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substeps_in_window_mid_beat() {
|
||||||
|
// Window [0.3, 0.6): should contain 0.5
|
||||||
|
let result = substeps_in_window(0.3, 0.6, 1.0);
|
||||||
|
assert_eq!(result, vec![0.5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substeps_in_window_empty() {
|
||||||
|
// Window too small to contain any substep
|
||||||
|
let result = substeps_in_window(0.1, 0.2, 1.0);
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substeps_in_window_negative_frontier() {
|
||||||
|
let result = substeps_in_window(-1.0, 0.5, 1.0);
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user