WIP: not sure
This commit is contained in:
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user