diff --git a/Cargo.toml b/Cargo.toml index 3446799..8aa67aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,8 +78,6 @@ egui_ratatui = { version = "2.1", optional = true } soft_ratatui = { version = "0.1.3", features = ["unicodefonts"], 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] opt-level = 3 diff --git a/src/engine/dispatcher.rs b/src/engine/dispatcher.rs index 7c401c9..cbf9f1c 100644 --- a/src/engine/dispatcher.rs +++ b/src/engine/dispatcher.rs @@ -3,11 +3,12 @@ use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; use std::cmp::Ordering; use std::collections::BinaryHeap; use std::sync::Arc; +use std::thread; use std::time::Duration; use super::link::LinkState; -use super::sequencer::{AudioCommand, MidiCommand}; -use super::timing::{SyncTime, ACTIVE_WAIT_THRESHOLD_US}; +use super::sequencer::{set_realtime_priority, AudioCommand, MidiCommand}; +use super::timing::SyncTime; /// A command scheduled for dispatch at a specific time. #[derive(Clone)] @@ -47,6 +48,9 @@ impl PartialEq for TimedCommand { impl Eq for TimedCommand {} +/// Spin-wait threshold in microseconds for dispatcher. +const SPIN_THRESHOLD_US: SyncTime = 100; + /// Main dispatcher loop - receives timed commands and dispatches them at the right moment. pub fn dispatcher_loop( cmd_rx: Receiver, @@ -54,6 +58,13 @@ pub fn dispatcher_loop( midi_tx: Arc>>, link: Arc, ) { + let has_rt = set_realtime_priority(); + + #[cfg(target_os = "linux")] + if !has_rt { + eprintln!("[cagire] Warning: Could not set realtime priority for dispatcher thread."); + } + let mut queue: BinaryHeap = BinaryHeap::with_capacity(256); loop { @@ -81,9 +92,9 @@ pub fn dispatcher_loop( // 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 { + if cmd.target_time_us <= current_us + SPIN_THRESHOLD_US { let cmd = queue.pop().unwrap(); - wait_until_dispatch(cmd.target_time_us, &link); + wait_until_dispatch(cmd.target_time_us, &link, has_rt); dispatch_command(cmd.command, &audio_tx, &midi_tx); } else { break; @@ -92,10 +103,41 @@ pub fn dispatcher_loop( } } -/// 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(); +/// High-precision sleep using clock_nanosleep on Linux +#[cfg(target_os = "linux")] +fn precise_sleep(micros: u64) { + let duration_ns = micros * 1000; + let ts = libc::timespec { + tv_sec: (duration_ns / 1_000_000_000) as i64, + tv_nsec: (duration_ns % 1_000_000_000) as i64, + }; + unsafe { + libc::clock_nanosleep(libc::CLOCK_MONOTONIC, 0, &ts, std::ptr::null_mut()); + } +} + +#[cfg(not(target_os = "linux"))] +fn precise_sleep(micros: u64) { + thread::sleep(Duration::from_micros(micros)); +} + +/// 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) { + let current = link.clock_micros() as SyncTime; + let remaining = target_us.saturating_sub(current); + + if has_rt { + // With RT priority: spin-wait for precise timing + while (link.clock_micros() as SyncTime) < target_us { + std::hint::spin_loop(); + } + } else { + // Without RT priority: sleep (spin-waiting is counterproductive) + if remaining > 0 { + precise_sleep(remaining); + } } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index c04416c..f3849cf 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -4,10 +4,7 @@ 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, -}; +pub use timing::{micros_until_next_substep, substeps_crossed, StepTiming, SyncTime}; // AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs #[allow(unused_imports)] diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 3538994..770742b 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -9,13 +9,11 @@ use std::thread::{self, JoinHandle}; use std::time::Duration; #[cfg(not(unix))] use thread_priority::set_current_thread_priority; +#[allow(unused_imports)] use thread_priority::ThreadPriority; use super::dispatcher::{dispatcher_loop, DispatchCommand, TimedCommand}; -use super::{ - micros_until_next_substep, substeps_crossed, LinkState, StepTiming, SyncTime, - ACTIVE_WAIT_THRESHOLD_US, -}; +use super::{micros_until_next_substep, substeps_crossed, LinkState, StepTiming, SyncTime}; use crate::model::{ CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables, }; @@ -1039,39 +1037,17 @@ fn sequencer_loop( ) { use std::sync::atomic::Ordering; - #[cfg(unix)] - { - use thread_priority::unix::{ - set_thread_priority_and_policy, thread_native_id, NormalThreadSchedulePolicy, - RealtimeThreadSchedulePolicy, ThreadSchedulePolicy, - }; + let has_rt = set_realtime_priority(); - let tid = thread_native_id(); - - // Try SCHED_FIFO first (requires CAP_SYS_NICE on Linux) - let fifo = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::Fifo); - if set_thread_priority_and_policy(tid, ThreadPriority::Max, fifo).is_err() { - // Try SCHED_RR (round-robin realtime, sometimes works without caps) - let rr = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::RoundRobin); - if set_thread_priority_and_policy(tid, ThreadPriority::Max, rr).is_err() { - // Fall back to highest normal priority (SCHED_OTHER) - let _ = set_thread_priority_and_policy( - tid, - ThreadPriority::Max, - ThreadSchedulePolicy::Normal(NormalThreadSchedulePolicy::Other), - ); - // Also try nice -20 via libc on Linux - #[cfg(target_os = "linux")] - unsafe { - libc::setpriority(libc::PRIO_PROCESS, 0, -20); - } - } - } - } - - #[cfg(not(unix))] - { - let _ = set_current_thread_priority(ThreadPriority::Max); + #[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 mut seq_state = SequencerState::new(variables, dict, rng, cc_access); @@ -1203,47 +1179,26 @@ fn sequencer_loop( }; let target_time_us = current_time_us + next_event_us; - wait_until(target_time_us, &link); + wait_until(target_time_us, &link, has_rt); } } -/// High-precision sleep using timerfd on Linux, falling back to thread::sleep elsewhere +/// 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; + +/// High-precision sleep using clock_nanosleep on Linux #[cfg(target_os = "linux")] fn precise_sleep(micros: u64) { - use nix::sys::timerfd::{ClockId, Expiration, TimerFd, TimerFlags, TimerSetTimeFlags}; - use std::os::fd::AsRawFd; - - thread_local! { - static TIMER: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + let duration_ns = micros * 1000; + let ts = libc::timespec { + tv_sec: (duration_ns / 1_000_000_000) as i64, + tv_nsec: (duration_ns % 1_000_000_000) as i64, + }; + unsafe { + libc::clock_nanosleep(libc::CLOCK_MONOTONIC, 0, &ts, std::ptr::null_mut()); } - - TIMER.with(|timer_cell| { - let mut timer_ref = timer_cell.borrow_mut(); - let timer = timer_ref.get_or_insert_with(|| { - TimerFd::new(ClockId::CLOCK_MONOTONIC, TimerFlags::empty()) - .expect("Failed to create timerfd") - }); - - let duration = Duration::from_micros(micros); - if timer - .set(Expiration::OneShot(duration), TimerSetTimeFlags::empty()) - .is_ok() - { - // Use poll to wait for the timer instead of read to avoid allocating - let mut pollfd = libc::pollfd { - fd: timer.as_raw_fd(), - events: libc::POLLIN, - revents: 0, - }; - unsafe { - libc::poll(&mut pollfd, 1, -1); - } - // Clear the timer by reading (discard the count) - let _ = timer.wait(); - } else { - thread::sleep(duration); - } - }); } #[cfg(not(target_os = "linux"))] @@ -1251,18 +1206,77 @@ 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) { +/// Attempts to set realtime scheduling priority for the current thread. +/// Returns true if RT priority was successfully set, false otherwise. +/// +/// On Linux, this requires either: +/// - CAP_SYS_NICE capability, or +/// - Configured rtprio limits in /etc/security/limits.conf: +/// @audio - rtprio 95 +/// @audio - memlock unlimited +#[cfg(unix)] +pub fn set_realtime_priority() -> bool { + use thread_priority::unix::{ + set_thread_priority_and_policy, thread_native_id, NormalThreadSchedulePolicy, + RealtimeThreadSchedulePolicy, ThreadSchedulePolicy, + }; + use thread_priority::ThreadPriority; + + let tid = thread_native_id(); + + // Try SCHED_FIFO first (requires CAP_SYS_NICE on Linux) + let fifo = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::Fifo); + if set_thread_priority_and_policy(tid, ThreadPriority::Max, fifo).is_ok() { + return true; + } + + // Try SCHED_RR (round-robin realtime, sometimes works without caps) + let rr = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::RoundRobin); + if set_thread_priority_and_policy(tid, ThreadPriority::Max, rr).is_ok() { + return true; + } + + // Fall back to highest normal priority (SCHED_OTHER) + let _ = set_thread_priority_and_policy( + tid, + ThreadPriority::Max, + ThreadSchedulePolicy::Normal(NormalThreadSchedulePolicy::Other), + ); + + // Also try nice -20 on Linux + #[cfg(target_os = "linux")] + unsafe { + libc::setpriority(libc::PRIO_PROCESS, 0, -20); + } + + false +} + +#[cfg(not(unix))] +pub fn set_realtime_priority() -> bool { + set_current_thread_priority(ThreadPriority::Max).is_ok() +} + +/// 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 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(); + if has_rt_priority { + // With RT priority: sleep most, spin for final precision + if remaining > SPIN_THRESHOLD_US { + precise_sleep(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(remaining); + } } } diff --git a/src/engine/timing.rs b/src/engine/timing.rs index 45d2296..3930d95 100644 --- a/src/engine/timing.rs +++ b/src/engine/timing.rs @@ -1,25 +1,14 @@ /// 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 { +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 { @@ -67,9 +56,6 @@ pub fn substeps_crossed(prev_beat: f64, curr_beat: f64, speed: f64) -> usize { (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 { @@ -94,26 +80,9 @@ mod tests { 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]