WIP: not sure
This commit is contained in:
16
Cargo.toml
16
Cargo.toml
@@ -37,11 +37,11 @@ required-features = ["desktop"]
|
|||||||
default = []
|
default = []
|
||||||
desktop = [
|
desktop = [
|
||||||
"cagire-forth/desktop",
|
"cagire-forth/desktop",
|
||||||
"egui",
|
"dep:egui",
|
||||||
"eframe",
|
"dep:eframe",
|
||||||
"egui_ratatui",
|
"dep:egui_ratatui",
|
||||||
"soft_ratatui",
|
"dep:soft_ratatui",
|
||||||
"image",
|
"dep:image",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -71,9 +71,6 @@ midir = "0.10"
|
|||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
nix = { version = "0.29", features = ["time"] }
|
|
||||||
|
|
||||||
# Desktop-only dependencies (behind feature flag)
|
# Desktop-only dependencies (behind feature flag)
|
||||||
egui = { version = "0.33", optional = true }
|
egui = { version = "0.33", optional = true }
|
||||||
eframe = { 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 }
|
soft_ratatui = { version = "0.1.3", features = ["unicodefonts"], optional = true }
|
||||||
image = { version = "0.25", default-features = false, features = ["png"], 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]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "fat"
|
lto = "fat"
|
||||||
|
|||||||
165
src/engine/dispatcher.rs
Normal file
165
src/engine/dispatcher.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,12 +37,12 @@ impl LinkState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn quantum(&self) -> f64 {
|
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) {
|
pub fn set_quantum(&self, quantum: f64) {
|
||||||
let clamped = quantum.clamp(1.0, 16.0);
|
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 {
|
pub fn clock_micros(&self) -> i64 {
|
||||||
@@ -86,4 +86,10 @@ impl LinkState {
|
|||||||
self.link.capture_app_session_state(&mut state);
|
self.link.capture_app_session_state(&mut state);
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
mod audio;
|
mod audio;
|
||||||
|
mod dispatcher;
|
||||||
mod link;
|
mod link;
|
||||||
pub mod sequencer;
|
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
|
// AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
|
use crossbeam_channel::{bounded, unbounded, Receiver, Sender};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
use std::sync::atomic::AtomicU32;
|
use std::sync::atomic::AtomicU32;
|
||||||
@@ -7,11 +7,15 @@ use std::sync::atomic::{AtomicI64, AtomicU64};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use thread_priority::ThreadPriority;
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
use thread_priority::set_current_thread_priority;
|
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::{
|
use crate::model::{
|
||||||
CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables,
|
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 audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx));
|
||||||
let midi_tx = Arc::new(ArcSwap::from_pointee(midi_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 = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default()));
|
||||||
let shared_state_clone = Arc::clone(&shared_state);
|
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")]
|
#[cfg(feature = "desktop")]
|
||||||
let mouse_x = config.mouse_x;
|
let mouse_x = config.mouse_x;
|
||||||
@@ -313,13 +318,28 @@ pub fn spawn_sequencer(
|
|||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
let mouse_down = config.mouse_down;
|
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()
|
let thread = thread::Builder::new()
|
||||||
.name("sequencer".into())
|
.name("sequencer".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
sequencer_loop(
|
sequencer_loop(
|
||||||
cmd_rx,
|
cmd_rx,
|
||||||
audio_tx_for_thread,
|
dispatch_tx,
|
||||||
midi_tx_for_thread,
|
|
||||||
link,
|
link,
|
||||||
playing,
|
playing,
|
||||||
variables,
|
variables,
|
||||||
@@ -407,32 +427,13 @@ fn check_quantization_boundary(
|
|||||||
prev_beat: f64,
|
prev_beat: f64,
|
||||||
quantum: f64,
|
quantum: f64,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if prev_beat < 0.0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match quantization {
|
match quantization {
|
||||||
LaunchQuantization::Immediate => true,
|
LaunchQuantization::Immediate => prev_beat >= 0.0,
|
||||||
LaunchQuantization::Beat => beat.floor() as i64 != prev_beat.floor() as i64,
|
LaunchQuantization::Beat => StepTiming::NextBeat.crossed(prev_beat, beat, quantum),
|
||||||
LaunchQuantization::Bar => {
|
LaunchQuantization::Bar => StepTiming::NextBar.crossed(prev_beat, beat, quantum),
|
||||||
let bar = (beat / quantum).floor() as i64;
|
LaunchQuantization::Bars2 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 2.0),
|
||||||
let prev_bar = (prev_beat / quantum).floor() as i64;
|
LaunchQuantization::Bars4 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 4.0),
|
||||||
bar != prev_bar
|
LaunchQuantization::Bars8 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 8.0),
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +459,8 @@ impl RunsCounter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn clear_pattern(&mut self, bank: usize, pattern: usize) {
|
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 quantum: f64,
|
||||||
pub fill: bool,
|
pub fill: bool,
|
||||||
pub nudge_secs: f64,
|
pub nudge_secs: f64,
|
||||||
pub current_time_us: i64,
|
pub current_time_us: SyncTime,
|
||||||
pub engine_time: f64,
|
pub engine_time: f64,
|
||||||
pub lookahead_secs: f64,
|
pub lookahead_secs: f64,
|
||||||
#[cfg(feature = "desktop")]
|
#[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 {
|
pub(crate) struct SequencerState {
|
||||||
audio_state: AudioState,
|
audio_state: AudioState,
|
||||||
pattern_cache: PatternCache,
|
pattern_cache: PatternCache,
|
||||||
@@ -558,7 +554,6 @@ pub(crate) struct SequencerState {
|
|||||||
buf_stopped: Vec<PatternId>,
|
buf_stopped: Vec<PatternId>,
|
||||||
buf_completed_iterations: Vec<PatternId>,
|
buf_completed_iterations: Vec<PatternId>,
|
||||||
cc_access: Option<Arc<dyn CcAccess>>,
|
cc_access: Option<Arc<dyn CcAccess>>,
|
||||||
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
|
|
||||||
muted: std::collections::HashSet<(usize, usize)>,
|
muted: std::collections::HashSet<(usize, usize)>,
|
||||||
soloed: std::collections::HashSet<(usize, usize)>,
|
soloed: std::collections::HashSet<(usize, usize)>,
|
||||||
}
|
}
|
||||||
@@ -587,7 +582,6 @@ impl SequencerState {
|
|||||||
buf_stopped: Vec::with_capacity(16),
|
buf_stopped: Vec::with_capacity(16),
|
||||||
buf_completed_iterations: Vec::with_capacity(16),
|
buf_completed_iterations: Vec::with_capacity(16),
|
||||||
cc_access,
|
cc_access,
|
||||||
active_notes: HashMap::new(),
|
|
||||||
muted: std::collections::HashSet::new(),
|
muted: std::collections::HashSet::new(),
|
||||||
soloed: 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(
|
self.audio_state.active_patterns.insert(
|
||||||
pending.id,
|
pending.id,
|
||||||
ActivePattern {
|
ActivePattern {
|
||||||
@@ -806,7 +801,7 @@ impl SequencerState {
|
|||||||
quantum: f64,
|
quantum: f64,
|
||||||
fill: bool,
|
fill: bool,
|
||||||
nudge_secs: f64,
|
nudge_secs: f64,
|
||||||
_current_time_us: i64,
|
_current_time_us: SyncTime,
|
||||||
engine_time: f64,
|
engine_time: f64,
|
||||||
lookahead_secs: f64,
|
lookahead_secs: f64,
|
||||||
#[cfg(feature = "desktop")] mouse_x: f64,
|
#[cfg(feature = "desktop")] mouse_x: f64,
|
||||||
@@ -840,15 +835,7 @@ 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 beat_int = (beat * 4.0 * speed_mult).floor() as i64;
|
let steps_to_fire = substeps_crossed(prev_beat, beat, speed_mult);
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
for _ in 0..steps_to_fire {
|
for _ in 0..steps_to_fire {
|
||||||
result.any_step_fired = true;
|
result.any_step_fired = true;
|
||||||
@@ -863,8 +850,7 @@ impl SequencerState {
|
|||||||
if step.active && has_script {
|
if step.active && has_script {
|
||||||
let pattern_key = (active.bank, active.pattern);
|
let pattern_key = (active.bank, active.pattern);
|
||||||
let is_muted = self.muted.contains(&pattern_key)
|
let is_muted = self.muted.contains(&pattern_key)
|
||||||
|| (!self.soloed.is_empty()
|
|| (!self.soloed.is_empty() && !self.soloed.contains(&pattern_key));
|
||||||
&& !self.soloed.contains(&pattern_key));
|
|
||||||
|
|
||||||
if !is_muted {
|
if !is_muted {
|
||||||
let source_idx = pattern.resolve_source(step_idx);
|
let source_idx = pattern.resolve_source(step_idx);
|
||||||
@@ -941,11 +927,7 @@ impl SequencerState {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_variables(
|
fn read_variables(&self, completed: &[PatternId], any_step_fired: bool) -> VariableReads {
|
||||||
&self,
|
|
||||||
completed: &[PatternId],
|
|
||||||
any_step_fired: bool,
|
|
||||||
) -> VariableReads {
|
|
||||||
let stopped = &self.buf_stopped;
|
let stopped = &self.buf_stopped;
|
||||||
let needs_access = !completed.is_empty() || !stopped.is_empty() || any_step_fired;
|
let needs_access = !completed.is_empty() || !stopped.is_empty() || any_step_fired;
|
||||||
if !needs_access {
|
if !needs_access {
|
||||||
@@ -1037,8 +1019,7 @@ impl SequencerState {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn sequencer_loop(
|
fn sequencer_loop(
|
||||||
cmd_rx: Receiver<SeqCommand>,
|
cmd_rx: Receiver<SeqCommand>,
|
||||||
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
|
dispatch_tx: Sender<TimedCommand>,
|
||||||
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
|
|
||||||
link: Arc<LinkState>,
|
link: Arc<LinkState>,
|
||||||
playing: Arc<std::sync::atomic::AtomicBool>,
|
playing: Arc<std::sync::atomic::AtomicBool>,
|
||||||
variables: Variables,
|
variables: Variables,
|
||||||
@@ -1105,8 +1086,8 @@ fn sequencer_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let state = link.capture_app_state();
|
let state = link.capture_app_state();
|
||||||
let current_time_us = link.clock_micros();
|
let current_time_us = link.clock_micros() as SyncTime;
|
||||||
let beat = state.beat_at_time(current_time_us, quantum);
|
let beat = state.beat_at_time(current_time_us as i64, quantum);
|
||||||
let tempo = state.tempo();
|
let tempo = state.tempo();
|
||||||
|
|
||||||
let sr = sample_rate.load(Ordering::Relaxed) as f64;
|
let sr = sample_rate.load(Ordering::Relaxed) as f64;
|
||||||
@@ -1139,10 +1120,16 @@ fn sequencer_loop(
|
|||||||
|
|
||||||
let output = seq_state.tick(input);
|
let output = seq_state.tick(input);
|
||||||
|
|
||||||
|
// Dispatch commands via the dispatcher thread
|
||||||
for tsc in output.audio_commands {
|
for tsc in output.audio_commands {
|
||||||
if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) {
|
if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) {
|
||||||
match midi_tx.load().try_send(midi_cmd.clone()) {
|
// Queue MIDI command for immediate dispatch
|
||||||
Ok(()) => {
|
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 (
|
if let (
|
||||||
MidiCommand::NoteOn {
|
MidiCommand::NoteOn {
|
||||||
device,
|
device,
|
||||||
@@ -1153,72 +1140,33 @@ fn sequencer_loop(
|
|||||||
Some(dur_secs),
|
Some(dur_secs),
|
||||||
) = (&midi_cmd, dur)
|
) = (&midi_cmd, dur)
|
||||||
{
|
{
|
||||||
let dur_us = (dur_secs * 1_000_000.0) as i64;
|
let off_time_us = current_time_us + (dur_secs * 1_000_000.0) as SyncTime;
|
||||||
seq_state.active_notes.insert(
|
let _ = dispatch_tx.send(TimedCommand {
|
||||||
(*device, *channel, *note),
|
command: DispatchCommand::Midi(MidiCommand::NoteOff {
|
||||||
ActiveNote {
|
device: *device,
|
||||||
off_time_us: current_time_us + dur_us,
|
channel: *channel,
|
||||||
start_time_us: current_time_us,
|
note: *note,
|
||||||
},
|
}),
|
||||||
);
|
target_time_us: off_time_us,
|
||||||
}
|
});
|
||||||
}
|
|
||||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
|
|
||||||
seq_state.dropped_events += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let cmd = AudioCommand::Evaluate {
|
// Queue audio command
|
||||||
|
let _ = dispatch_tx.send(TimedCommand {
|
||||||
|
command: DispatchCommand::Audio {
|
||||||
cmd: tsc.cmd,
|
cmd: tsc.cmd,
|
||||||
time: tsc.time,
|
time: tsc.time,
|
||||||
};
|
},
|
||||||
match audio_tx.load().try_send(cmd) {
|
target_time_us: current_time_us,
|
||||||
Ok(()) => {}
|
});
|
||||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
|
|
||||||
seq_state.dropped_events += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_NOTE_DURATION_US: i64 = 30_000_000; // 30 second safety timeout
|
// Handle MIDI flush request
|
||||||
|
|
||||||
if output.flush_midi_notes {
|
if output.flush_midi_notes {
|
||||||
for ((device, channel, note), _) in seq_state.active_notes.drain() {
|
let _ = dispatch_tx.send(TimedCommand {
|
||||||
let _ = midi_tx.load().try_send(MidiCommand::NoteOff {
|
command: DispatchCommand::FlushMidi,
|
||||||
device,
|
target_time_us: current_time_us,
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,16 +1176,34 @@ fn sequencer_loop(
|
|||||||
|
|
||||||
shared_state.store(Arc::new(output.shared_state));
|
shared_state.store(Arc::new(output.shared_state));
|
||||||
|
|
||||||
// Adaptive sleep: calculate time until next substep boundary
|
// Calculate time until next substep based on active patterns
|
||||||
// At max speed (8x), substeps occur every beat/32
|
let next_event_us = {
|
||||||
// Sleep for most of that time, leaving 500μs margin for processing
|
let mut min_micros = SyncTime::MAX;
|
||||||
let beats_per_sec = tempo / 60.0;
|
for id in seq_state.audio_state.active_patterns.keys() {
|
||||||
let max_speed = 8.0; // Maximum speed multiplier from speed.clamp()
|
let speed = seq_state
|
||||||
let secs_per_substep = 1.0 / (beats_per_sec * 4.0 * max_speed);
|
.speed_overrides
|
||||||
let substep_us = (secs_per_substep * 1_000_000.0) as u64;
|
.get(&(id.bank, id.pattern))
|
||||||
// Sleep for most of the substep duration, clamped to reasonable bounds
|
.copied()
|
||||||
let sleep_us = substep_us.saturating_sub(500).clamp(50, 2000);
|
.or_else(|| {
|
||||||
precise_sleep(sleep_us);
|
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));
|
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>)> {
|
fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
|
||||||
if !cmd.starts_with("/midi/") {
|
if !cmd.starts_with("/midi/") {
|
||||||
return None;
|
return None;
|
||||||
@@ -2131,7 +2112,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should fire steps continuously without gaps
|
// 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]
|
#[test]
|
||||||
|
|||||||
225
src/engine/timing.rs
Normal file
225
src/engine/timing.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,8 @@ fn convert_event(event: &egui::Event) -> Option<KeyEvent> {
|
|||||||
}
|
}
|
||||||
let mods = convert_modifiers(*modifiers);
|
let mods = convert_modifiers(*modifiers);
|
||||||
// For character keys without ctrl/alt, let Event::Text handle it
|
// 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;
|
return None;
|
||||||
}
|
}
|
||||||
let code = convert_key(*key)?;
|
let code = convert_key(*key)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user