6 Commits

Author SHA1 Message Date
1facc72a67 Fix Linux audio: enable JACK support and RT priority for audio callback
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-03 14:04:34 +01:00
726ea16e92 Wip 2026-02-03 13:52:36 +01:00
154cac6547 Again 2026-02-03 03:25:31 +01:00
3380e454df Again 2026-02-03 03:08:13 +01:00
660f48216a Still searching... 2026-02-03 02:53:34 +01:00
fb1f73ebd6 WIP: not sure 2026-02-03 02:31:55 +01:00
18 changed files with 766 additions and 275 deletions

View File

@@ -37,11 +37,11 @@ required-features = ["desktop"]
default = []
desktop = [
"cagire-forth/desktop",
"egui",
"eframe",
"egui_ratatui",
"soft_ratatui",
"image",
"dep:egui",
"dep:eframe",
"dep:egui_ratatui",
"dep:soft_ratatui",
"dep:image",
]
[dependencies]
@@ -53,7 +53,7 @@ doux = { git = "https://github.com/sova-org/doux", features = ["native"] }
rusty_link = "0.4"
ratatui = "0.30"
crossterm = "0.29"
cpal = "0.17"
cpal = { version = "0.17", features = ["jack"] }
clap = { version = "4", features = ["derive"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
@@ -71,9 +71,6 @@ midir = "0.10"
parking_lot = "0.12"
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
nix = { version = "0.29", features = ["time"] }
# Desktop-only dependencies (behind feature flag)
egui = { version = "0.33", optional = true }
eframe = { version = "0.33", optional = true }
@@ -81,6 +78,7 @@ 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 }
[profile.release]
opt-level = 3
lto = "fat"

View File

@@ -14,3 +14,4 @@ desktop = []
[dependencies]
rand = "0.8"
parking_lot = "0.12"
arc-swap = "1"

View File

@@ -7,6 +7,7 @@ mod words;
pub use types::{
CcAccess, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables,
VariablesMap,
};
pub use vm::Forth;
pub use words::{lookup_word, Word, WordCompile, WORDS};

View File

@@ -1,3 +1,4 @@
use arc_swap::ArcSwap;
use parking_lot::Mutex;
use rand::rngs::StdRng;
use std::collections::HashMap;
@@ -53,7 +54,8 @@ impl StepContext<'_> {
}
}
pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
pub type VariablesMap = HashMap<String, Value>;
pub type Variables = Arc<ArcSwap<VariablesMap>>;
pub type Dictionary = Arc<Mutex<HashMap<String, Vec<Op>>>>;
pub type Rng = Arc<Mutex<StdRng>>;
pub type Stack = Arc<Mutex<Vec<Value>>>;

View File

@@ -2,12 +2,14 @@ use parking_lot::Mutex;
use rand::rngs::StdRng;
use rand::{Rng as RngTrait, SeedableRng};
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use super::compiler::compile_script;
use super::ops::Op;
use super::types::{
CmdRegister, Dictionary, ExecutionTrace, Rng, Stack, StepContext, Value, Variables,
VariablesMap,
};
pub struct Forth {
@@ -38,7 +40,9 @@ impl Forth {
}
pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<Vec<String>, String> {
self.evaluate_impl(script, ctx, None)
let (outputs, var_writes) = self.evaluate_impl(script, ctx, None)?;
self.apply_var_writes(var_writes);
Ok(outputs)
}
pub fn evaluate_with_trace(
@@ -47,15 +51,37 @@ impl Forth {
ctx: &StepContext,
trace: &mut ExecutionTrace,
) -> Result<Vec<String>, String> {
let (outputs, var_writes) = self.evaluate_impl(script, ctx, Some(trace))?;
self.apply_var_writes(var_writes);
Ok(outputs)
}
pub fn evaluate_raw(
&self,
script: &str,
ctx: &StepContext,
trace: &mut ExecutionTrace,
) -> Result<(Vec<String>, HashMap<String, Value>), String> {
self.evaluate_impl(script, ctx, Some(trace))
}
fn apply_var_writes(&self, writes: HashMap<String, Value>) {
if writes.is_empty() {
return;
}
let mut new_vars = (**self.vars.load()).clone();
for (k, v) in writes {
new_vars.insert(k, v);
}
self.vars.store(Arc::new(new_vars));
}
fn evaluate_impl(
&self,
script: &str,
ctx: &StepContext,
trace: Option<&mut ExecutionTrace>,
) -> Result<Vec<String>, String> {
) -> Result<(Vec<String>, HashMap<String, Value>), String> {
if script.trim().is_empty() {
return Err("empty script".into());
}
@@ -69,14 +95,25 @@ impl Forth {
ops: &[Op],
ctx: &StepContext,
trace: Option<&mut ExecutionTrace>,
) -> Result<Vec<String>, String> {
) -> Result<(Vec<String>, HashMap<String, Value>), String> {
let mut stack = self.stack.lock();
let mut outputs: Vec<String> = Vec::with_capacity(8);
let mut cmd = CmdRegister::new();
let vars_snapshot = self.vars.load();
let mut var_writes: HashMap<String, Value> = HashMap::new();
self.execute_ops(ops, ctx, &mut stack, &mut outputs, &mut cmd, trace)?;
self.execute_ops(
ops,
ctx,
&mut stack,
&mut outputs,
&mut cmd,
trace,
&vars_snapshot,
&mut var_writes,
)?;
Ok(outputs)
Ok((outputs, var_writes))
}
#[allow(clippy::too_many_arguments)]
@@ -89,9 +126,12 @@ impl Forth {
outputs: &mut Vec<String>,
cmd: &mut CmdRegister,
trace: Option<&mut ExecutionTrace>,
vars_snapshot: &VariablesMap,
var_writes: &mut HashMap<String, Value>,
) -> Result<(), String> {
let mut pc = 0;
let trace_cell = std::cell::RefCell::new(trace);
let var_writes_cell = std::cell::RefCell::new(Some(var_writes));
let run_quotation = |quot: Value,
stack: &mut Vec<Value>,
@@ -106,6 +146,8 @@ impl Forth {
}
}
let mut trace_opt = trace_cell.borrow_mut().take();
let mut var_writes_guard = var_writes_cell.borrow_mut();
let vw = var_writes_guard.as_mut().expect("var_writes taken");
self.execute_ops(
&quot_ops,
ctx,
@@ -113,7 +155,10 @@ impl Forth {
outputs,
cmd,
trace_opt.as_deref_mut(),
vars_snapshot,
vw,
)?;
drop(var_writes_guard);
*trace_cell.borrow_mut() = trace_opt;
Ok(())
}
@@ -475,15 +520,25 @@ impl Forth {
Op::Get => {
let name = stack.pop().ok_or("stack underflow")?;
let name = name.as_str()?;
let vars = self.vars.lock();
let val = vars.get(name).cloned().unwrap_or(Value::Int(0, None));
let vw = var_writes_cell.borrow();
let vw_ref = vw.as_ref().expect("var_writes taken");
let val = vw_ref
.get(name)
.or_else(|| vars_snapshot.get(name))
.cloned()
.unwrap_or(Value::Int(0, None));
drop(vw);
stack.push(val);
}
Op::Set => {
let name = stack.pop().ok_or("stack underflow")?;
let name = name.as_str()?.to_string();
let val = stack.pop().ok_or("stack underflow")?;
self.vars.lock().insert(name, val);
var_writes_cell
.borrow_mut()
.as_mut()
.expect("var_writes taken")
.insert(name, val);
}
Op::GetContext(name) => {
@@ -710,16 +765,20 @@ impl Forth {
Op::SetTempo => {
let tempo = stack.pop().ok_or("stack underflow")?.as_float()?;
let clamped = tempo.clamp(20.0, 300.0);
self.vars
.lock()
var_writes_cell
.borrow_mut()
.as_mut()
.expect("var_writes taken")
.insert("__tempo__".to_string(), Value::Float(clamped, None));
}
Op::SetSpeed => {
let speed = stack.pop().ok_or("stack underflow")?.as_float()?;
let clamped = speed.clamp(0.125, 8.0);
self.vars
.lock()
var_writes_cell
.borrow_mut()
.as_mut()
.expect("var_writes taken")
.insert(ctx.speed_key.to_string(), Value::Float(clamped, None));
}
@@ -735,7 +794,11 @@ impl Forth {
use std::fmt::Write;
let mut val = String::with_capacity(8);
let _ = write!(&mut val, "{bank}:{pattern}");
self.vars.lock().insert(ctx.chain_key.to_string(), Value::Str(Arc::from(val), None));
var_writes_cell
.borrow_mut()
.as_mut()
.expect("var_writes taken")
.insert(ctx.chain_key.to_string(), Value::Str(Arc::from(val), None));
}
}
@@ -849,8 +912,10 @@ impl Forth {
return Err("times count must be >= 0".into());
}
for i in 0..count {
self.vars
.lock()
var_writes_cell
.borrow_mut()
.as_mut()
.expect("var_writes taken")
.insert("i".to_string(), Value::Int(i, None));
run_quotation(quot.clone(), stack, outputs, cmd)?;
}

View File

@@ -1,3 +1,4 @@
use arc_swap::ArcSwap;
use parking_lot::Mutex;
use rand::rngs::StdRng;
use rand::SeedableRng;
@@ -60,7 +61,7 @@ impl Default for App {
impl App {
pub fn new() -> Self {
let variables = Arc::new(Mutex::new(HashMap::new()));
let variables = Arc::new(ArcSwap::from_pointee(HashMap::new()));
let dict = Arc::new(Mutex::new(HashMap::new()));
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
let script_engine =
@@ -606,7 +607,7 @@ impl App {
link.set_tempo(tempo);
self.playback.clear_queues();
self.variables.lock().clear();
self.variables.store(Arc::new(HashMap::new()));
self.dict.lock().clear();
for (bank, pattern) in playing {

View File

@@ -554,6 +554,10 @@ fn load_icon() -> egui::IconData {
}
fn main() -> eframe::Result<()> {
// Lock memory BEFORE any threads are spawned to prevent page faults in RT context
#[cfg(unix)]
cagire::engine::realtime::lock_memory();
let args = Args::parse();
let options = NativeOptions {

View File

@@ -1,4 +1,4 @@
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::traits::{DeviceTrait, StreamTrait};
use cpal::Stream;
use crossbeam_channel::Receiver;
use doux::{Engine, EngineMetrics};
@@ -246,14 +246,10 @@ pub fn build_stream(
initial_samples: Vec<doux::sampling::SampleEntry>,
audio_sample_pos: Arc<AtomicU64>,
) -> Result<(Stream, f32, AnalysisHandle), String> {
let host = cpal::default_host();
let device = match &config.output_device {
Some(name) => doux::audio::find_output_device(name)
.ok_or_else(|| format!("Device not found: {name}"))?,
None => host
.default_output_device()
.ok_or("No default output device")?,
None => doux::audio::default_output_device().ok_or("No default output device")?,
};
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
@@ -282,11 +278,17 @@ pub fn build_stream(
let (mut fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer);
let mut cmd_buffer = String::with_capacity(256);
let mut rt_set = false;
let stream = device
.build_output_stream(
&stream_config,
move |data: &mut [f32], _| {
if !rt_set {
super::realtime::set_realtime_priority();
rt_set = true;
}
let buffer_samples = data.len() / channels;
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;

182
src/engine/dispatcher.rs Normal file
View File

@@ -0,0 +1,182 @@
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::realtime::{precise_sleep_us, set_realtime_priority};
use super::sequencer::{AudioCommand, MidiCommand};
use super::timing::SyncTime;
/// 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,
}
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 {}
/// 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<TimedCommand>,
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
link: Arc<LinkState>,
) {
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<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 + SPIN_THRESHOLD_US {
let cmd = queue.pop().unwrap();
wait_until_dispatch(cmd.target_time_us, &link, has_rt);
dispatch_command(cmd.command, &audio_tx, &midi_tx);
} else {
break;
}
}
}
}
/// 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_us(remaining);
}
}
}
/// 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,
});
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timed_command_ordering() {
let mut heap: BinaryHeap<TimedCommand> = BinaryHeap::new();
heap.push(TimedCommand {
command: DispatchCommand::FlushMidi,
target_time_us: 300,
});
heap.push(TimedCommand {
command: DispatchCommand::FlushMidi,
target_time_us: 100,
});
heap.push(TimedCommand {
command: DispatchCommand::FlushMidi,
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);
}
}

View File

@@ -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 {

View File

@@ -1,6 +1,11 @@
mod audio;
mod dispatcher;
mod link;
pub mod realtime;
pub mod sequencer;
mod timing;
pub use timing::{micros_until_next_substep, substeps_crossed, StepTiming, SyncTime};
// AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs
#[allow(unused_imports)]

114
src/engine/realtime.rs Normal file
View File

@@ -0,0 +1,114 @@
use std::sync::atomic::{AtomicBool, Ordering};
static MLOCKALL_CALLED: AtomicBool = AtomicBool::new(false);
static MLOCKALL_SUCCESS: AtomicBool = AtomicBool::new(false);
/// Locks all current and future memory pages to prevent page faults during RT execution.
/// Must be called BEFORE spawning any threads for maximum effectiveness.
/// Returns true if mlockall succeeded, false otherwise (which is common without rtprio).
#[cfg(unix)]
pub fn lock_memory() -> bool {
if MLOCKALL_CALLED.swap(true, Ordering::SeqCst) {
return MLOCKALL_SUCCESS.load(Ordering::SeqCst);
}
let result = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
if result == 0 {
MLOCKALL_SUCCESS.store(true, Ordering::SeqCst);
true
} else {
// Get the actual error for better diagnostics
let errno = std::io::Error::last_os_error();
eprintln!("[cagire] mlockall failed: {errno}");
eprintln!("[cagire] Memory locking disabled. For best RT performance on Linux:");
eprintln!("[cagire] 1. Add user to 'audio' group: sudo usermod -aG audio $USER");
eprintln!("[cagire] 2. Add to /etc/security/limits.conf:");
eprintln!("[cagire] @audio - memlock unlimited");
eprintln!("[cagire] 3. Log out and back in");
false
}
}
#[cfg(not(unix))]
pub fn lock_memory() -> bool {
// Windows: VirtualLock exists but isn't typically needed for audio
true
}
/// Check if memory locking is active.
#[allow(dead_code)]
pub fn is_memory_locked() -> bool {
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.
///
/// 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 {
use thread_priority::{set_current_thread_priority, ThreadPriority};
set_current_thread_priority(ThreadPriority::Max).is_ok()
}
/// High-precision sleep using clock_nanosleep on Linux.
/// Uses monotonic clock for jitter-free sleeping.
#[cfg(target_os = "linux")]
pub fn precise_sleep_us(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"))]
pub fn precise_sleep_us(micros: u64) {
std::thread::sleep(std::time::Duration::from_micros(micros));
}

View File

@@ -1,17 +1,15 @@
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;
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 super::LinkState;
use super::dispatcher::{dispatcher_loop, DispatchCommand, TimedCommand};
use super::realtime::{precise_sleep_us, set_realtime_priority};
use super::{micros_until_next_substep, substeps_crossed, LinkState, StepTiming, SyncTime};
use crate::model::{
CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables,
};
@@ -301,10 +299,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 +312,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 +421,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 +453,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 +466,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 +532,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 +548,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 +576,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 +749,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 +795,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,
@@ -821,10 +810,10 @@ impl SequencerState {
self.speed_overrides.clear();
{
let vars = self.variables.lock();
let vars = self.variables.load();
for id in self.audio_state.active_patterns.keys() {
let key = self.key_cache.speed_key(id.bank, id.pattern);
if let Some(v) = vars.get(key).and_then(|v| v.as_float().ok()) {
if let Some(v) = vars.get(key).and_then(|v: &Value| v.as_float().ok()) {
self.speed_overrides.insert((id.bank, id.pattern), v);
}
}
@@ -840,15 +829,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 +844,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 +921,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 {
@@ -955,8 +931,8 @@ impl SequencerState {
};
}
let mut vars = self.variables.lock();
let new_tempo = vars.remove("__tempo__").and_then(|v| v.as_float().ok());
let vars = self.variables.load();
let new_tempo = vars.get("__tempo__").and_then(|v: &Value| v.as_float().ok());
let mut chain_transitions = Vec::new();
for id in completed {
@@ -966,12 +942,29 @@ impl SequencerState {
chain_transitions.push((*id, target));
}
}
vars.remove(chain_key);
}
for id in stopped {
let chain_key = self.key_cache.chain_key(id.bank, id.pattern);
vars.remove(chain_key);
// Remove consumed variables (tempo and chain keys)
let needs_removal = new_tempo.is_some()
|| completed.iter().any(|id| {
let chain_key = self.key_cache.chain_key(id.bank, id.pattern);
vars.contains_key(chain_key)
})
|| stopped.iter().any(|id| {
let chain_key = self.key_cache.chain_key(id.bank, id.pattern);
vars.contains_key(chain_key)
});
if needs_removal {
let mut new_vars = (**vars).clone();
new_vars.remove("__tempo__");
for id in completed {
new_vars.remove(self.key_cache.chain_key(id.bank, id.pattern));
}
for id in stopped {
new_vars.remove(self.key_cache.chain_key(id.bank, id.pattern));
}
self.variables.store(Arc::new(new_vars));
}
VariableReads {
@@ -1037,8 +1030,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,
@@ -1058,39 +1050,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);
@@ -1105,8 +1075,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 +1109,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,61 +1165,64 @@ 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);
}
}
/// High-precision sleep using timerfd on Linux, falling back to thread::sleep elsewhere
#[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<Option<TimerFd>> = const { std::cell::RefCell::new(None) };
}
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);
// 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);
}
// Clear the timer by reading (discard the count)
let _ = timer.wait();
} else {
thread::sleep(duration);
}
});
// If no active patterns, default to 1ms for command responsiveness
if min_micros == SyncTime::MAX {
1000
} else {
min_micros.max(50) // Minimum 50μs to prevent excessive CPU usage
}
};
let target_time_us = current_time_us + next_event_us;
wait_until(target_time_us, &link, has_rt);
}
}
#[cfg(not(target_os = "linux"))]
fn precise_sleep(micros: u64) {
thread::sleep(Duration::from_micros(micros));
/// Spin-wait threshold in microseconds. With RT priority, we sleep most of the
/// wait time and spin-wait for the final portion for precision. Without RT priority,
/// spinning is counterproductive and we sleep the entire duration.
const SPIN_THRESHOLD_US: SyncTime = 100;
/// Two-phase wait: sleep most of the time, optionally spin-wait for final precision.
/// With RT priority: sleep + spin for precision
/// Without RT priority: sleep only (spinning wastes CPU without benefit)
fn wait_until(target_us: SyncTime, link: &LinkState, has_rt_priority: bool) {
let current = link.clock_micros() as SyncTime;
let remaining = target_us.saturating_sub(current);
if has_rt_priority {
// With RT priority: sleep most, spin for final precision
if remaining > SPIN_THRESHOLD_US {
precise_sleep_us(remaining - SPIN_THRESHOLD_US);
}
while (link.clock_micros() as SyncTime) < target_us {
std::hint::spin_loop();
}
} else {
// Without RT priority: sleep the entire time (spin-waiting is counterproductive)
if remaining > 0 {
precise_sleep_us(remaining);
}
}
}
fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
@@ -1393,10 +1333,11 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
#[cfg(test)]
mod tests {
use super::*;
use arc_swap::ArcSwap;
use parking_lot::Mutex;
fn make_state() -> SequencerState {
let variables: Variables = Arc::new(Mutex::new(HashMap::new()));
let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new()));
let dict: Dictionary = Arc::new(Mutex::new(HashMap::new()));
let rng: Rng = Arc::new(Mutex::new(
<rand::rngs::StdRng as rand::SeedableRng>::seed_from_u64(0),
@@ -1573,11 +1514,12 @@ mod tests {
// Set chain variable
{
let mut vars = state.variables.lock();
let mut vars = (**state.variables.load()).clone();
vars.insert(
"__chain_0_0__".to_string(),
Value::Str(std::sync::Arc::from("0:1"), None),
);
state.variables.store(Arc::new(vars));
}
// Pattern 0 completes iteration AND gets stopped immediately in the same tick.
@@ -1822,11 +1764,12 @@ mod tests {
// Set chain: 0:0 -> 0:1
{
let mut vars = state.variables.lock();
let mut vars = (**state.variables.load()).clone();
vars.insert(
"__chain_0_0__".to_string(),
Value::Str(std::sync::Arc::from("0:1"), None),
);
state.variables.store(Arc::new(vars));
}
// Pattern 0 (length 1) completes iteration at beat=1.0 AND
@@ -2073,8 +2016,9 @@ mod tests {
// Script fires at beat 1.0 (step 0). Set __tempo__ as if the script did.
{
let mut vars = state.variables.lock();
let mut vars = (**state.variables.load()).clone();
vars.insert("__tempo__".to_string(), Value::Float(140.0, None));
state.variables.store(Arc::new(vars));
}
let output = state.tick(tick_at(1.0, true));
@@ -2131,7 +2075,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]

162
src/engine/timing.rs Normal file
View File

@@ -0,0 +1,162 @@
/// Microsecond-precision timestamp for audio synchronization.
pub type SyncTime = u64;
/// Convert beat duration to microseconds at given tempo.
fn beats_to_micros(beats: f64, tempo: f64) -> SyncTime {
if tempo <= 0.0 {
return 0;
}
((beats / tempo) * 60_000_000.0).round() as SyncTime
}
/// Timing boundary types for step and pattern scheduling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepTiming {
/// 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::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
}
}
}
}
/// 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
}
/// 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_zero_tempo() {
assert_eq!(beats_to_micros(1.0, 0.0), 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_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);
}
}

View File

@@ -25,7 +25,8 @@ fn convert_event(event: &egui::Event) -> Option<KeyEvent> {
}
let mods = convert_modifiers(*modifiers);
// 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;
}
let code = convert_key(*key)?;

View File

@@ -62,6 +62,10 @@ struct Args {
}
fn main() -> io::Result<()> {
// Lock memory BEFORE any threads are spawned to prevent page faults in RT context
#[cfg(unix)]
engine::realtime::lock_memory();
let args = Args::parse();
let settings = Settings::load();

View File

@@ -1,3 +1,4 @@
use arc_swap::ArcSwap;
use parking_lot::Mutex;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
@@ -63,7 +64,7 @@ fn compute_stack_display(
let result = if script.trim().is_empty() {
"Stack: []".to_string()
} else {
let vars = Arc::new(Mutex::new(HashMap::new()));
let vars = Arc::new(ArcSwap::from_pointee(HashMap::new()));
let dict = Arc::new(Mutex::new(HashMap::new()));
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(42)));
let forth = Forth::new(vars, dict, rng);

View File

@@ -1,3 +1,4 @@
use arc_swap::ArcSwap;
use cagire::forth::{Dictionary, Forth, Rng, StepContext, Value, Variables};
use parking_lot::Mutex;
use rand::rngs::StdRng;
@@ -32,7 +33,7 @@ pub fn ctx_with(f: impl FnOnce(&mut StepContext<'static>)) -> StepContext<'stati
}
pub fn new_vars() -> Variables {
Arc::new(Mutex::new(HashMap::new()))
Arc::new(ArcSwap::from_pointee(HashMap::new()))
}
pub fn new_dict() -> Dictionary {