Compare commits
6 Commits
cd223592a7
...
1facc72a67
| Author | SHA1 | Date | |
|---|---|---|---|
| 1facc72a67 | |||
| 726ea16e92 | |||
| 154cac6547 | |||
| 3380e454df | |||
| 660f48216a | |||
| fb1f73ebd6 |
16
Cargo.toml
16
Cargo.toml
@@ -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"
|
||||
|
||||
@@ -14,3 +14,4 @@ desktop = []
|
||||
[dependencies]
|
||||
rand = "0.8"
|
||||
parking_lot = "0.12"
|
||||
arc-swap = "1"
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>>>;
|
||||
|
||||
@@ -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(
|
||||
"_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)?;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
182
src/engine/dispatcher.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
114
src/engine/realtime.rs
Normal 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));
|
||||
}
|
||||
@@ -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
162
src/engine/timing.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user