Fix: simpler scheduling

This commit is contained in:
2026-02-03 15:55:43 +01:00
parent 266a625cf3
commit 3284354f40
4 changed files with 125 additions and 103 deletions

View File

@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
- Mute/solo for patterns: stage with `m`/`x`, commit with `c`. Solo mutes all other patterns. Clear with `M`/`X`. - Mute/solo for patterns: stage with `m`/`x`, commit with `c`. Solo mutes all other patterns. Clear with `M`/`X`.
- Lookahead scheduling: scripts are pre-evaluated ahead of time and audio commands are scheduled at precise beat positions, improving timing accuracy under CPU load. - Lookahead scheduling: scripts are pre-evaluated ahead of time and audio commands are scheduled at precise beat positions, improving timing accuracy under CPU load.
- Realtime thread scheduling (`SCHED_FIFO`) for sequencer thread on Unix systems, improving timing reliability. - Realtime thread scheduling (`SCHED_FIFO`) for sequencer thread on Unix systems, improving timing reliability.
- Deep into the Linux hellscape: trying to get reliable performance, better stability, etc.
## [0.0.4] - 2026-02-02 ## [0.0.4] - 2026-02-02

View File

@@ -7,52 +7,50 @@ use std::time::Duration;
use super::link::LinkState; use super::link::LinkState;
use super::realtime::{precise_sleep_us, set_realtime_priority}; use super::realtime::{precise_sleep_us, set_realtime_priority};
use super::sequencer::{AudioCommand, MidiCommand}; use super::sequencer::MidiCommand;
use super::timing::SyncTime; use super::timing::SyncTime;
/// A command scheduled for dispatch at a specific time. /// A MIDI command scheduled for dispatch at a specific time.
#[derive(Clone)] #[derive(Clone)]
pub struct TimedCommand { pub struct TimedMidiCommand {
pub command: DispatchCommand, pub command: MidiDispatch,
pub target_time_us: SyncTime, pub target_time_us: SyncTime,
} }
/// Commands the dispatcher can send to audio/MIDI threads. /// MIDI commands the dispatcher can send.
#[derive(Clone)] #[derive(Clone)]
pub enum DispatchCommand { pub enum MidiDispatch {
Audio { cmd: String, time: Option<f64> }, Send(MidiCommand),
Midi(MidiCommand), FlushAll,
FlushMidi,
} }
impl Ord for TimedCommand { impl Ord for TimedMidiCommand {
fn cmp(&self, other: &Self) -> Ordering { fn cmp(&self, other: &Self) -> Ordering {
// Reverse ordering for min-heap (earliest time first)
other.target_time_us.cmp(&self.target_time_us) other.target_time_us.cmp(&self.target_time_us)
} }
} }
impl PartialOrd for TimedCommand { impl PartialOrd for TimedMidiCommand {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> { fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other)) Some(self.cmp(other))
} }
} }
impl PartialEq for TimedCommand { impl PartialEq for TimedMidiCommand {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.target_time_us == other.target_time_us self.target_time_us == other.target_time_us
} }
} }
impl Eq for TimedCommand {} impl Eq for TimedMidiCommand {}
/// Spin-wait threshold in microseconds for dispatcher.
const SPIN_THRESHOLD_US: SyncTime = 100; const SPIN_THRESHOLD_US: SyncTime = 100;
/// Main dispatcher loop - receives timed commands and dispatches them at the right moment. /// Dispatcher loop — handles MIDI timing only.
/// Audio commands bypass the dispatcher entirely and go straight to doux's
/// sample-accurate scheduler via the audio thread channel.
pub fn dispatcher_loop( pub fn dispatcher_loop(
cmd_rx: Receiver<TimedCommand>, cmd_rx: Receiver<TimedMidiCommand>,
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>, midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
link: Arc<LinkState>, link: Arc<LinkState>,
) { ) {
@@ -63,37 +61,33 @@ pub fn dispatcher_loop(
eprintln!("[cagire] Warning: Could not set realtime priority for dispatcher thread."); eprintln!("[cagire] Warning: Could not set realtime priority for dispatcher thread.");
} }
let mut queue: BinaryHeap<TimedCommand> = BinaryHeap::with_capacity(256); let mut queue: BinaryHeap<TimedMidiCommand> = BinaryHeap::with_capacity(256);
loop { loop {
let current_us = link.clock_micros() as SyncTime; let current_us = link.clock_micros() as SyncTime;
// Calculate timeout based on next queued event
let timeout_us = queue let timeout_us = queue
.peek() .peek()
.map(|cmd| cmd.target_time_us.saturating_sub(current_us)) .map(|cmd| cmd.target_time_us.saturating_sub(current_us))
.unwrap_or(100_000) // 100ms default when idle .unwrap_or(100_000)
.max(100); // Minimum 100μs to prevent busy-looping .max(100);
// Receive new commands (with timeout)
match cmd_rx.recv_timeout(Duration::from_micros(timeout_us)) { match cmd_rx.recv_timeout(Duration::from_micros(timeout_us)) {
Ok(cmd) => queue.push(cmd), Ok(cmd) => queue.push(cmd),
Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break, Err(RecvTimeoutError::Disconnected) => break,
} }
// Drain any additional pending commands
while let Ok(cmd) = cmd_rx.try_recv() { while let Ok(cmd) = cmd_rx.try_recv() {
queue.push(cmd); queue.push(cmd);
} }
// Dispatch ready commands
let current_us = link.clock_micros() as SyncTime; let current_us = link.clock_micros() as SyncTime;
while let Some(cmd) = queue.peek() { while let Some(cmd) = queue.peek() {
if cmd.target_time_us <= current_us + SPIN_THRESHOLD_US { if cmd.target_time_us <= current_us + SPIN_THRESHOLD_US {
let cmd = queue.pop().unwrap(); let cmd = queue.pop().unwrap();
wait_until_dispatch(cmd.target_time_us, &link, has_rt); wait_until_dispatch(cmd.target_time_us, &link, has_rt);
dispatch_command(cmd.command, &audio_tx, &midi_tx); dispatch_midi(cmd.command, &midi_tx);
} else { } else {
break; break;
} }
@@ -101,44 +95,25 @@ pub fn dispatcher_loop(
} }
} }
/// Wait until the target time for dispatch.
/// With RT priority: spin-wait for precision
/// Without RT priority: sleep (spinning wastes CPU without benefit)
fn wait_until_dispatch(target_us: SyncTime, link: &LinkState, has_rt: bool) { fn wait_until_dispatch(target_us: SyncTime, link: &LinkState, has_rt: bool) {
let current = link.clock_micros() as SyncTime; let current = link.clock_micros() as SyncTime;
let remaining = target_us.saturating_sub(current); let remaining = target_us.saturating_sub(current);
if has_rt { if has_rt {
// With RT priority: spin-wait for precise timing
while (link.clock_micros() as SyncTime) < target_us { while (link.clock_micros() as SyncTime) < target_us {
std::hint::spin_loop(); std::hint::spin_loop();
} }
} else { } else if remaining > 0 {
// Without RT priority: sleep (spin-waiting is counterproductive) precise_sleep_us(remaining);
if remaining > 0 {
precise_sleep_us(remaining);
}
} }
} }
/// Route a command to the appropriate output channel. fn dispatch_midi(cmd: MidiDispatch, midi_tx: &Arc<ArcSwap<Sender<MidiCommand>>>) {
fn dispatch_command(
cmd: DispatchCommand,
audio_tx: &Arc<ArcSwap<Sender<AudioCommand>>>,
midi_tx: &Arc<ArcSwap<Sender<MidiCommand>>>,
) {
match cmd { match cmd {
DispatchCommand::Audio { cmd, time } => { MidiDispatch::Send(midi_cmd) => {
let _ = audio_tx
.load()
.try_send(AudioCommand::Evaluate { cmd, time });
}
DispatchCommand::Midi(midi_cmd) => {
let _ = midi_tx.load().try_send(midi_cmd); let _ = midi_tx.load().try_send(midi_cmd);
} }
DispatchCommand::FlushMidi => { MidiDispatch::FlushAll => {
// Send All Notes Off (CC 123) on all 16 channels for all 4 devices
for dev in 0..4u8 { for dev in 0..4u8 {
for chan in 0..16u8 { for chan in 0..16u8 {
let _ = midi_tx.load().try_send(MidiCommand::CC { let _ = midi_tx.load().try_send(MidiCommand::CC {
@@ -159,22 +134,21 @@ mod tests {
#[test] #[test]
fn test_timed_command_ordering() { fn test_timed_command_ordering() {
let mut heap: BinaryHeap<TimedCommand> = BinaryHeap::new(); let mut heap: BinaryHeap<TimedMidiCommand> = BinaryHeap::new();
heap.push(TimedCommand { heap.push(TimedMidiCommand {
command: DispatchCommand::FlushMidi, command: MidiDispatch::FlushAll,
target_time_us: 300, target_time_us: 300,
}); });
heap.push(TimedCommand { heap.push(TimedMidiCommand {
command: DispatchCommand::FlushMidi, command: MidiDispatch::FlushAll,
target_time_us: 100, target_time_us: 100,
}); });
heap.push(TimedCommand { heap.push(TimedMidiCommand {
command: DispatchCommand::FlushMidi, command: MidiDispatch::FlushAll,
target_time_us: 200, 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, 100);
assert_eq!(heap.pop().unwrap().target_time_us, 200); assert_eq!(heap.pop().unwrap().target_time_us, 200);
assert_eq!(heap.pop().unwrap().target_time_us, 300); assert_eq!(heap.pop().unwrap().target_time_us, 300);

View File

@@ -42,6 +42,54 @@ pub fn is_memory_locked() -> bool {
MLOCKALL_SUCCESS.load(Ordering::Relaxed) MLOCKALL_SUCCESS.load(Ordering::Relaxed)
} }
/// Attempts to set realtime scheduling priority for the current thread.
/// Returns true if RT priority was successfully set, false otherwise.
#[cfg(target_os = "macos")]
pub fn set_realtime_priority() -> bool {
// macOS: use THREAD_TIME_CONSTRAINT_POLICY for true RT scheduling.
// This is the same mechanism CoreAudio uses for its audio threads.
// SCHED_FIFO/RR require root on macOS, but time constraint policy does not.
unsafe {
let thread = libc::pthread_self();
#[repr(C)]
struct ThreadTimeConstraintPolicy {
period: u32,
computation: u32,
constraint: u32,
preemptible: i32,
}
const THREAD_TIME_CONSTRAINT_POLICY_ID: u32 = 2;
const THREAD_TIME_CONSTRAINT_POLICY_COUNT: u32 = 4;
// ~1ms period at ~1GHz mach_absolute_time ticks (typical for audio)
let policy = ThreadTimeConstraintPolicy {
period: 1_000_000,
computation: 500_000,
constraint: 1_000_000,
preemptible: 1,
};
extern "C" {
fn thread_policy_set(
thread: libc::pthread_t,
flavor: u32,
policy_info: *const ThreadTimeConstraintPolicy,
count: u32,
) -> i32;
}
let result = thread_policy_set(
thread,
THREAD_TIME_CONSTRAINT_POLICY_ID,
&policy,
THREAD_TIME_CONSTRAINT_POLICY_COUNT,
);
result == 0
}
}
/// Attempts to set realtime scheduling priority for the current thread. /// Attempts to set realtime scheduling priority for the current thread.
/// Returns true if RT priority was successfully set, false otherwise. /// Returns true if RT priority was successfully set, false otherwise.
/// ///
@@ -50,7 +98,7 @@ pub fn is_memory_locked() -> bool {
/// - Configured rtprio limits in /etc/security/limits.conf: /// - Configured rtprio limits in /etc/security/limits.conf:
/// @audio - rtprio 95 /// @audio - rtprio 95
/// @audio - memlock unlimited /// @audio - memlock unlimited
#[cfg(unix)] #[cfg(target_os = "linux")]
pub fn set_realtime_priority() -> bool { pub fn set_realtime_priority() -> bool {
use thread_priority::unix::{ use thread_priority::unix::{
set_thread_priority_and_policy, thread_native_id, NormalThreadSchedulePolicy, set_thread_priority_and_policy, thread_native_id, NormalThreadSchedulePolicy,
@@ -60,27 +108,22 @@ pub fn set_realtime_priority() -> bool {
let tid = thread_native_id(); let tid = thread_native_id();
// Try SCHED_FIFO first (requires CAP_SYS_NICE on Linux)
let fifo = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::Fifo); let fifo = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::Fifo);
if set_thread_priority_and_policy(tid, ThreadPriority::Max, fifo).is_ok() { if set_thread_priority_and_policy(tid, ThreadPriority::Max, fifo).is_ok() {
return true; return true;
} }
// Try SCHED_RR (round-robin realtime, sometimes works without caps)
let rr = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::RoundRobin); let rr = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::RoundRobin);
if set_thread_priority_and_policy(tid, ThreadPriority::Max, rr).is_ok() { if set_thread_priority_and_policy(tid, ThreadPriority::Max, rr).is_ok() {
return true; return true;
} }
// Fall back to highest normal priority (SCHED_OTHER)
let _ = set_thread_priority_and_policy( let _ = set_thread_priority_and_policy(
tid, tid,
ThreadPriority::Max, ThreadPriority::Max,
ThreadSchedulePolicy::Normal(NormalThreadSchedulePolicy::Other), ThreadSchedulePolicy::Normal(NormalThreadSchedulePolicy::Other),
); );
// Also try nice -20 on Linux
#[cfg(target_os = "linux")]
unsafe { unsafe {
libc::setpriority(libc::PRIO_PROCESS, 0, -20); libc::setpriority(libc::PRIO_PROCESS, 0, -20);
} }
@@ -88,7 +131,12 @@ pub fn set_realtime_priority() -> bool {
false false
} }
#[cfg(not(unix))] #[cfg(not(any(unix, target_os = "windows")))]
pub fn set_realtime_priority() -> bool {
false
}
#[cfg(target_os = "windows")]
pub fn set_realtime_priority() -> bool { pub fn set_realtime_priority() -> bool {
use thread_priority::{set_current_thread_priority, ThreadPriority}; use thread_priority::{set_current_thread_priority, ThreadPriority};
set_current_thread_priority(ThreadPriority::Max).is_ok() set_current_thread_priority(ThreadPriority::Max).is_ok()

View File

@@ -7,7 +7,7 @@ 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 super::dispatcher::{dispatcher_loop, DispatchCommand, TimedCommand}; use super::dispatcher::{dispatcher_loop, MidiDispatch, TimedMidiCommand};
use super::realtime::{precise_sleep_us, set_realtime_priority}; use super::realtime::{precise_sleep_us, set_realtime_priority};
use super::{micros_until_next_substep, substeps_crossed, LinkState, StepTiming, SyncTime}; use super::{micros_until_next_substep, substeps_crossed, LinkState, StepTiming, SyncTime};
use crate::model::{ use crate::model::{
@@ -299,8 +299,8 @@ 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) // Dispatcher channel — MIDI only (unbounded to avoid blocking the scheduler)
let (dispatch_tx, dispatch_rx) = unbounded::<TimedCommand>(); let (dispatch_tx, dispatch_rx) = unbounded::<TimedMidiCommand>();
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);
@@ -312,28 +312,24 @@ 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 // Spawn dispatcher thread (MIDI only — audio goes direct to doux)
let dispatcher_link = Arc::clone(&link); let dispatcher_link = Arc::clone(&link);
let dispatcher_audio_tx = Arc::clone(&audio_tx);
let dispatcher_midi_tx = Arc::clone(&midi_tx); let dispatcher_midi_tx = Arc::clone(&midi_tx);
thread::Builder::new() thread::Builder::new()
.name("cagire-dispatcher".into()) .name("cagire-dispatcher".into())
.spawn(move || { .spawn(move || {
dispatcher_loop( dispatcher_loop(dispatch_rx, dispatcher_midi_tx, dispatcher_link);
dispatch_rx,
dispatcher_audio_tx,
dispatcher_midi_tx,
dispatcher_link,
);
}) })
.expect("Failed to spawn dispatcher thread"); .expect("Failed to spawn dispatcher thread");
let sequencer_audio_tx = Arc::clone(&audio_tx);
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,
dispatch_tx, dispatch_tx,
sequencer_audio_tx,
link, link,
playing, playing,
variables, variables,
@@ -830,11 +826,24 @@ impl SequencerState {
.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 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 _ in 0..steps_to_fire { for step_offset in 0..steps_to_fire {
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 time_delta = if tempo > 0.0 {
(beat_delta / tempo) * 60.0
} else {
0.0
};
let event_time = Some(engine_time + lookahead_secs + time_delta);
if let Some(step) = pattern.steps.get(step_idx) { if let Some(step) = pattern.steps.get(step_idx) {
let resolved_script = pattern.resolve_script(step_idx); let resolved_script = pattern.resolve_script(step_idx);
let has_script = resolved_script let has_script = resolved_script
@@ -887,12 +896,6 @@ impl SequencerState {
std::mem::take(&mut trace), std::mem::take(&mut trace),
); );
let event_time = if lookahead_secs > 0.0 {
Some(engine_time + lookahead_secs)
} else {
None
};
for cmd in cmds { for cmd in cmds {
self.event_count += 1; self.event_count += 1;
self.buf_audio_commands.push(TimestampedCommand { self.buf_audio_commands.push(TimestampedCommand {
@@ -932,7 +935,9 @@ impl SequencerState {
} }
let vars = self.variables.load(); let vars = self.variables.load();
let new_tempo = vars.get("__tempo__").and_then(|v: &Value| v.as_float().ok()); let new_tempo = vars
.get("__tempo__")
.and_then(|v: &Value| v.as_float().ok());
let mut chain_transitions = Vec::new(); let mut chain_transitions = Vec::new();
for id in completed { for id in completed {
@@ -1030,7 +1035,8 @@ 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>,
dispatch_tx: Sender<TimedCommand>, dispatch_tx: Sender<TimedMidiCommand>,
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
link: Arc<LinkState>, link: Arc<LinkState>,
playing: Arc<std::sync::atomic::AtomicBool>, playing: Arc<std::sync::atomic::AtomicBool>,
variables: Variables, variables: Variables,
@@ -1109,16 +1115,14 @@ fn sequencer_loop(
let output = seq_state.tick(input); let output = seq_state.tick(input);
// Dispatch commands via the dispatcher thread // Route commands: audio direct to doux, MIDI through dispatcher
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) {
// Queue MIDI command for immediate dispatch let _ = dispatch_tx.send(TimedMidiCommand {
let _ = dispatch_tx.send(TimedCommand { command: MidiDispatch::Send(midi_cmd.clone()),
command: DispatchCommand::Midi(midi_cmd.clone()),
target_time_us: current_time_us, target_time_us: current_time_us,
}); });
// Schedule note-off if duration specified
if let ( if let (
MidiCommand::NoteOn { MidiCommand::NoteOn {
device, device,
@@ -1130,8 +1134,8 @@ fn sequencer_loop(
) = (&midi_cmd, dur) ) = (&midi_cmd, dur)
{ {
let off_time_us = current_time_us + (dur_secs * 1_000_000.0) as SyncTime; let off_time_us = current_time_us + (dur_secs * 1_000_000.0) as SyncTime;
let _ = dispatch_tx.send(TimedCommand { let _ = dispatch_tx.send(TimedMidiCommand {
command: DispatchCommand::Midi(MidiCommand::NoteOff { command: MidiDispatch::Send(MidiCommand::NoteOff {
device: *device, device: *device,
channel: *channel, channel: *channel,
note: *note, note: *note,
@@ -1140,21 +1144,17 @@ fn sequencer_loop(
}); });
} }
} else { } else {
// Queue audio command // Audio direct to doux — sample-accurate scheduling via /time/ parameter
let _ = dispatch_tx.send(TimedCommand { let _ = audio_tx.load().try_send(AudioCommand::Evaluate {
command: DispatchCommand::Audio { cmd: tsc.cmd,
cmd: tsc.cmd, time: tsc.time,
time: tsc.time,
},
target_time_us: current_time_us,
}); });
} }
} }
// Handle MIDI flush request
if output.flush_midi_notes { if output.flush_midi_notes {
let _ = dispatch_tx.send(TimedCommand { let _ = dispatch_tx.send(TimedMidiCommand {
command: DispatchCommand::FlushMidi, command: MidiDispatch::FlushAll,
target_time_us: current_time_us, target_time_us: current_time_us,
}); });
} }
@@ -1201,7 +1201,6 @@ fn sequencer_loop(
/// spinning is counterproductive and we sleep the entire duration. /// spinning is counterproductive and we sleep the entire duration.
const SPIN_THRESHOLD_US: SyncTime = 100; const SPIN_THRESHOLD_US: SyncTime = 100;
/// Two-phase wait: sleep most of the time, optionally spin-wait for final precision. /// Two-phase wait: sleep most of the time, optionally spin-wait for final precision.
/// With RT priority: sleep + spin for precision /// With RT priority: sleep + spin for precision
/// Without RT priority: sleep only (spinning wastes CPU without benefit) /// Without RT priority: sleep only (spinning wastes CPU without benefit)