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 = []
|
default = []
|
||||||
desktop = [
|
desktop = [
|
||||||
"cagire-forth/desktop",
|
"cagire-forth/desktop",
|
||||||
"egui",
|
"dep:egui",
|
||||||
"eframe",
|
"dep:eframe",
|
||||||
"egui_ratatui",
|
"dep:egui_ratatui",
|
||||||
"soft_ratatui",
|
"dep:soft_ratatui",
|
||||||
"image",
|
"dep:image",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -53,7 +53,7 @@ doux = { git = "https://github.com/sova-org/doux", features = ["native"] }
|
|||||||
rusty_link = "0.4"
|
rusty_link = "0.4"
|
||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
cpal = "0.17"
|
cpal = { version = "0.17", features = ["jack"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -71,9 +71,6 @@ midir = "0.10"
|
|||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
nix = { version = "0.29", features = ["time"] }
|
|
||||||
|
|
||||||
# Desktop-only dependencies (behind feature flag)
|
# Desktop-only dependencies (behind feature flag)
|
||||||
egui = { version = "0.33", optional = true }
|
egui = { version = "0.33", optional = true }
|
||||||
eframe = { 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 }
|
soft_ratatui = { version = "0.1.3", features = ["unicodefonts"], optional = true }
|
||||||
image = { version = "0.25", default-features = false, features = ["png"], optional = true }
|
image = { version = "0.25", default-features = false, features = ["png"], optional = true }
|
||||||
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "fat"
|
lto = "fat"
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ desktop = []
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
arc-swap = "1"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod words;
|
|||||||
|
|
||||||
pub use types::{
|
pub use types::{
|
||||||
CcAccess, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables,
|
CcAccess, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables,
|
||||||
|
VariablesMap,
|
||||||
};
|
};
|
||||||
pub use vm::Forth;
|
pub use vm::Forth;
|
||||||
pub use words::{lookup_word, Word, WordCompile, WORDS};
|
pub use words::{lookup_word, Word, WordCompile, WORDS};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use arc_swap::ArcSwap;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
use std::collections::HashMap;
|
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 Dictionary = Arc<Mutex<HashMap<String, Vec<Op>>>>;
|
||||||
pub type Rng = Arc<Mutex<StdRng>>;
|
pub type Rng = Arc<Mutex<StdRng>>;
|
||||||
pub type Stack = Arc<Mutex<Vec<Value>>>;
|
pub type Stack = Arc<Mutex<Vec<Value>>>;
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ use parking_lot::Mutex;
|
|||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
use rand::{Rng as RngTrait, SeedableRng};
|
use rand::{Rng as RngTrait, SeedableRng};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::compiler::compile_script;
|
use super::compiler::compile_script;
|
||||||
use super::ops::Op;
|
use super::ops::Op;
|
||||||
use super::types::{
|
use super::types::{
|
||||||
CmdRegister, Dictionary, ExecutionTrace, Rng, Stack, StepContext, Value, Variables,
|
CmdRegister, Dictionary, ExecutionTrace, Rng, Stack, StepContext, Value, Variables,
|
||||||
|
VariablesMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct Forth {
|
pub struct Forth {
|
||||||
@@ -38,7 +40,9 @@ impl Forth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<Vec<String>, String> {
|
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(
|
pub fn evaluate_with_trace(
|
||||||
@@ -47,15 +51,37 @@ impl Forth {
|
|||||||
ctx: &StepContext,
|
ctx: &StepContext,
|
||||||
trace: &mut ExecutionTrace,
|
trace: &mut ExecutionTrace,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> 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))
|
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(
|
fn evaluate_impl(
|
||||||
&self,
|
&self,
|
||||||
script: &str,
|
script: &str,
|
||||||
ctx: &StepContext,
|
ctx: &StepContext,
|
||||||
trace: Option<&mut ExecutionTrace>,
|
trace: Option<&mut ExecutionTrace>,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<(Vec<String>, HashMap<String, Value>), String> {
|
||||||
if script.trim().is_empty() {
|
if script.trim().is_empty() {
|
||||||
return Err("empty script".into());
|
return Err("empty script".into());
|
||||||
}
|
}
|
||||||
@@ -69,14 +95,25 @@ impl Forth {
|
|||||||
ops: &[Op],
|
ops: &[Op],
|
||||||
ctx: &StepContext,
|
ctx: &StepContext,
|
||||||
trace: Option<&mut ExecutionTrace>,
|
trace: Option<&mut ExecutionTrace>,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<(Vec<String>, HashMap<String, Value>), String> {
|
||||||
let mut stack = self.stack.lock();
|
let mut stack = self.stack.lock();
|
||||||
let mut outputs: Vec<String> = Vec::with_capacity(8);
|
let mut outputs: Vec<String> = Vec::with_capacity(8);
|
||||||
let mut cmd = CmdRegister::new();
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -89,9 +126,12 @@ impl Forth {
|
|||||||
outputs: &mut Vec<String>,
|
outputs: &mut Vec<String>,
|
||||||
cmd: &mut CmdRegister,
|
cmd: &mut CmdRegister,
|
||||||
trace: Option<&mut ExecutionTrace>,
|
trace: Option<&mut ExecutionTrace>,
|
||||||
|
vars_snapshot: &VariablesMap,
|
||||||
|
var_writes: &mut HashMap<String, Value>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut pc = 0;
|
let mut pc = 0;
|
||||||
let trace_cell = std::cell::RefCell::new(trace);
|
let trace_cell = std::cell::RefCell::new(trace);
|
||||||
|
let var_writes_cell = std::cell::RefCell::new(Some(var_writes));
|
||||||
|
|
||||||
let run_quotation = |quot: Value,
|
let run_quotation = |quot: Value,
|
||||||
stack: &mut Vec<Value>,
|
stack: &mut Vec<Value>,
|
||||||
@@ -106,6 +146,8 @@ impl Forth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut trace_opt = trace_cell.borrow_mut().take();
|
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(
|
self.execute_ops(
|
||||||
"_ops,
|
"_ops,
|
||||||
ctx,
|
ctx,
|
||||||
@@ -113,7 +155,10 @@ impl Forth {
|
|||||||
outputs,
|
outputs,
|
||||||
cmd,
|
cmd,
|
||||||
trace_opt.as_deref_mut(),
|
trace_opt.as_deref_mut(),
|
||||||
|
vars_snapshot,
|
||||||
|
vw,
|
||||||
)?;
|
)?;
|
||||||
|
drop(var_writes_guard);
|
||||||
*trace_cell.borrow_mut() = trace_opt;
|
*trace_cell.borrow_mut() = trace_opt;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -475,15 +520,25 @@ impl Forth {
|
|||||||
Op::Get => {
|
Op::Get => {
|
||||||
let name = stack.pop().ok_or("stack underflow")?;
|
let name = stack.pop().ok_or("stack underflow")?;
|
||||||
let name = name.as_str()?;
|
let name = name.as_str()?;
|
||||||
let vars = self.vars.lock();
|
let vw = var_writes_cell.borrow();
|
||||||
let val = vars.get(name).cloned().unwrap_or(Value::Int(0, None));
|
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);
|
stack.push(val);
|
||||||
}
|
}
|
||||||
Op::Set => {
|
Op::Set => {
|
||||||
let name = stack.pop().ok_or("stack underflow")?;
|
let name = stack.pop().ok_or("stack underflow")?;
|
||||||
let name = name.as_str()?.to_string();
|
let name = name.as_str()?.to_string();
|
||||||
let val = stack.pop().ok_or("stack underflow")?;
|
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) => {
|
Op::GetContext(name) => {
|
||||||
@@ -710,16 +765,20 @@ impl Forth {
|
|||||||
Op::SetTempo => {
|
Op::SetTempo => {
|
||||||
let tempo = stack.pop().ok_or("stack underflow")?.as_float()?;
|
let tempo = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||||
let clamped = tempo.clamp(20.0, 300.0);
|
let clamped = tempo.clamp(20.0, 300.0);
|
||||||
self.vars
|
var_writes_cell
|
||||||
.lock()
|
.borrow_mut()
|
||||||
|
.as_mut()
|
||||||
|
.expect("var_writes taken")
|
||||||
.insert("__tempo__".to_string(), Value::Float(clamped, None));
|
.insert("__tempo__".to_string(), Value::Float(clamped, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
Op::SetSpeed => {
|
Op::SetSpeed => {
|
||||||
let speed = stack.pop().ok_or("stack underflow")?.as_float()?;
|
let speed = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||||
let clamped = speed.clamp(0.125, 8.0);
|
let clamped = speed.clamp(0.125, 8.0);
|
||||||
self.vars
|
var_writes_cell
|
||||||
.lock()
|
.borrow_mut()
|
||||||
|
.as_mut()
|
||||||
|
.expect("var_writes taken")
|
||||||
.insert(ctx.speed_key.to_string(), Value::Float(clamped, None));
|
.insert(ctx.speed_key.to_string(), Value::Float(clamped, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +794,11 @@ impl Forth {
|
|||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
let mut val = String::with_capacity(8);
|
let mut val = String::with_capacity(8);
|
||||||
let _ = write!(&mut val, "{bank}:{pattern}");
|
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());
|
return Err("times count must be >= 0".into());
|
||||||
}
|
}
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
self.vars
|
var_writes_cell
|
||||||
.lock()
|
.borrow_mut()
|
||||||
|
.as_mut()
|
||||||
|
.expect("var_writes taken")
|
||||||
.insert("i".to_string(), Value::Int(i, None));
|
.insert("i".to_string(), Value::Int(i, None));
|
||||||
run_quotation(quot.clone(), stack, outputs, cmd)?;
|
run_quotation(quot.clone(), stack, outputs, cmd)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use arc_swap::ArcSwap;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
@@ -60,7 +61,7 @@ impl Default for App {
|
|||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new() -> Self {
|
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 dict = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
|
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
|
||||||
let script_engine =
|
let script_engine =
|
||||||
@@ -606,7 +607,7 @@ impl App {
|
|||||||
link.set_tempo(tempo);
|
link.set_tempo(tempo);
|
||||||
|
|
||||||
self.playback.clear_queues();
|
self.playback.clear_queues();
|
||||||
self.variables.lock().clear();
|
self.variables.store(Arc::new(HashMap::new()));
|
||||||
self.dict.lock().clear();
|
self.dict.lock().clear();
|
||||||
|
|
||||||
for (bank, pattern) in playing {
|
for (bank, pattern) in playing {
|
||||||
|
|||||||
@@ -554,6 +554,10 @@ fn load_icon() -> egui::IconData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
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 args = Args::parse();
|
||||||
|
|
||||||
let options = NativeOptions {
|
let options = NativeOptions {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||||
use cpal::Stream;
|
use cpal::Stream;
|
||||||
use crossbeam_channel::Receiver;
|
use crossbeam_channel::Receiver;
|
||||||
use doux::{Engine, EngineMetrics};
|
use doux::{Engine, EngineMetrics};
|
||||||
@@ -246,14 +246,10 @@ pub fn build_stream(
|
|||||||
initial_samples: Vec<doux::sampling::SampleEntry>,
|
initial_samples: Vec<doux::sampling::SampleEntry>,
|
||||||
audio_sample_pos: Arc<AtomicU64>,
|
audio_sample_pos: Arc<AtomicU64>,
|
||||||
) -> Result<(Stream, f32, AnalysisHandle), String> {
|
) -> Result<(Stream, f32, AnalysisHandle), String> {
|
||||||
let host = cpal::default_host();
|
|
||||||
|
|
||||||
let device = match &config.output_device {
|
let device = match &config.output_device {
|
||||||
Some(name) => doux::audio::find_output_device(name)
|
Some(name) => doux::audio::find_output_device(name)
|
||||||
.ok_or_else(|| format!("Device not found: {name}"))?,
|
.ok_or_else(|| format!("Device not found: {name}"))?,
|
||||||
None => host
|
None => doux::audio::default_output_device().ok_or("No default output device")?,
|
||||||
.default_output_device()
|
|
||||||
.ok_or("No default output device")?,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
|
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 fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer);
|
||||||
|
|
||||||
let mut cmd_buffer = String::with_capacity(256);
|
let mut cmd_buffer = String::with_capacity(256);
|
||||||
|
let mut rt_set = false;
|
||||||
|
|
||||||
let stream = device
|
let stream = device
|
||||||
.build_output_stream(
|
.build_output_stream(
|
||||||
&stream_config,
|
&stream_config,
|
||||||
move |data: &mut [f32], _| {
|
move |data: &mut [f32], _| {
|
||||||
|
if !rt_set {
|
||||||
|
super::realtime::set_realtime_priority();
|
||||||
|
rt_set = true;
|
||||||
|
}
|
||||||
|
|
||||||
let buffer_samples = data.len() / channels;
|
let buffer_samples = data.len() / channels;
|
||||||
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
|
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 {
|
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) {
|
pub fn set_quantum(&self, quantum: f64) {
|
||||||
let clamped = quantum.clamp(1.0, 16.0);
|
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 {
|
pub fn clock_micros(&self) -> i64 {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
mod audio;
|
mod audio;
|
||||||
|
mod dispatcher;
|
||||||
mod link;
|
mod link;
|
||||||
|
pub mod realtime;
|
||||||
pub mod sequencer;
|
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
|
// AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs
|
||||||
#[allow(unused_imports)]
|
#[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 arc_swap::ArcSwap;
|
||||||
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
|
use crossbeam_channel::{bounded, unbounded, Receiver, Sender};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
use std::sync::atomic::AtomicU32;
|
use std::sync::atomic::AtomicU32;
|
||||||
use std::sync::atomic::{AtomicI64, AtomicU64};
|
use std::sync::atomic::{AtomicI64, AtomicU64};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use 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::{
|
use crate::model::{
|
||||||
CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables,
|
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 audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx));
|
||||||
let midi_tx = Arc::new(ArcSwap::from_pointee(midi_tx));
|
let midi_tx = Arc::new(ArcSwap::from_pointee(midi_tx));
|
||||||
|
|
||||||
|
// Dispatcher channel (unbounded to avoid blocking the scheduler)
|
||||||
|
let (dispatch_tx, dispatch_rx) = unbounded::<TimedCommand>();
|
||||||
|
|
||||||
let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default()));
|
let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default()));
|
||||||
let shared_state_clone = Arc::clone(&shared_state);
|
let shared_state_clone = Arc::clone(&shared_state);
|
||||||
let audio_tx_for_thread = Arc::clone(&audio_tx);
|
|
||||||
let midi_tx_for_thread = Arc::clone(&midi_tx);
|
|
||||||
|
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
let mouse_x = config.mouse_x;
|
let mouse_x = config.mouse_x;
|
||||||
@@ -313,13 +312,28 @@ pub fn spawn_sequencer(
|
|||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
let mouse_down = config.mouse_down;
|
let mouse_down = config.mouse_down;
|
||||||
|
|
||||||
|
// Spawn dispatcher thread
|
||||||
|
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()
|
let thread = thread::Builder::new()
|
||||||
.name("sequencer".into())
|
.name("sequencer".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
sequencer_loop(
|
sequencer_loop(
|
||||||
cmd_rx,
|
cmd_rx,
|
||||||
audio_tx_for_thread,
|
dispatch_tx,
|
||||||
midi_tx_for_thread,
|
|
||||||
link,
|
link,
|
||||||
playing,
|
playing,
|
||||||
variables,
|
variables,
|
||||||
@@ -407,32 +421,13 @@ fn check_quantization_boundary(
|
|||||||
prev_beat: f64,
|
prev_beat: f64,
|
||||||
quantum: f64,
|
quantum: f64,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if prev_beat < 0.0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match quantization {
|
match quantization {
|
||||||
LaunchQuantization::Immediate => true,
|
LaunchQuantization::Immediate => prev_beat >= 0.0,
|
||||||
LaunchQuantization::Beat => beat.floor() as i64 != prev_beat.floor() as i64,
|
LaunchQuantization::Beat => StepTiming::NextBeat.crossed(prev_beat, beat, quantum),
|
||||||
LaunchQuantization::Bar => {
|
LaunchQuantization::Bar => StepTiming::NextBar.crossed(prev_beat, beat, quantum),
|
||||||
let bar = (beat / quantum).floor() as i64;
|
LaunchQuantization::Bars2 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 2.0),
|
||||||
let prev_bar = (prev_beat / quantum).floor() as i64;
|
LaunchQuantization::Bars4 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 4.0),
|
||||||
bar != prev_bar
|
LaunchQuantization::Bars8 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 8.0),
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +453,8 @@ impl RunsCounter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn clear_pattern(&mut self, bank: usize, pattern: usize) {
|
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 quantum: f64,
|
||||||
pub fill: bool,
|
pub fill: bool,
|
||||||
pub nudge_secs: f64,
|
pub nudge_secs: f64,
|
||||||
pub current_time_us: i64,
|
pub current_time_us: SyncTime,
|
||||||
pub engine_time: f64,
|
pub engine_time: f64,
|
||||||
pub lookahead_secs: f64,
|
pub lookahead_secs: f64,
|
||||||
#[cfg(feature = "desktop")]
|
#[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 {
|
pub(crate) struct SequencerState {
|
||||||
audio_state: AudioState,
|
audio_state: AudioState,
|
||||||
pattern_cache: PatternCache,
|
pattern_cache: PatternCache,
|
||||||
@@ -558,7 +548,6 @@ pub(crate) struct SequencerState {
|
|||||||
buf_stopped: Vec<PatternId>,
|
buf_stopped: Vec<PatternId>,
|
||||||
buf_completed_iterations: Vec<PatternId>,
|
buf_completed_iterations: Vec<PatternId>,
|
||||||
cc_access: Option<Arc<dyn CcAccess>>,
|
cc_access: Option<Arc<dyn CcAccess>>,
|
||||||
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
|
|
||||||
muted: std::collections::HashSet<(usize, usize)>,
|
muted: std::collections::HashSet<(usize, usize)>,
|
||||||
soloed: std::collections::HashSet<(usize, usize)>,
|
soloed: std::collections::HashSet<(usize, usize)>,
|
||||||
}
|
}
|
||||||
@@ -587,7 +576,6 @@ impl SequencerState {
|
|||||||
buf_stopped: Vec::with_capacity(16),
|
buf_stopped: Vec::with_capacity(16),
|
||||||
buf_completed_iterations: Vec::with_capacity(16),
|
buf_completed_iterations: Vec::with_capacity(16),
|
||||||
cc_access,
|
cc_access,
|
||||||
active_notes: HashMap::new(),
|
|
||||||
muted: std::collections::HashSet::new(),
|
muted: std::collections::HashSet::new(),
|
||||||
soloed: 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(
|
self.audio_state.active_patterns.insert(
|
||||||
pending.id,
|
pending.id,
|
||||||
ActivePattern {
|
ActivePattern {
|
||||||
@@ -806,7 +795,7 @@ impl SequencerState {
|
|||||||
quantum: f64,
|
quantum: f64,
|
||||||
fill: bool,
|
fill: bool,
|
||||||
nudge_secs: f64,
|
nudge_secs: f64,
|
||||||
_current_time_us: i64,
|
_current_time_us: SyncTime,
|
||||||
engine_time: f64,
|
engine_time: f64,
|
||||||
lookahead_secs: f64,
|
lookahead_secs: f64,
|
||||||
#[cfg(feature = "desktop")] mouse_x: f64,
|
#[cfg(feature = "desktop")] mouse_x: f64,
|
||||||
@@ -821,10 +810,10 @@ impl SequencerState {
|
|||||||
|
|
||||||
self.speed_overrides.clear();
|
self.speed_overrides.clear();
|
||||||
{
|
{
|
||||||
let vars = self.variables.lock();
|
let vars = self.variables.load();
|
||||||
for id in self.audio_state.active_patterns.keys() {
|
for id in self.audio_state.active_patterns.keys() {
|
||||||
let key = self.key_cache.speed_key(id.bank, id.pattern);
|
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);
|
self.speed_overrides.insert((id.bank, id.pattern), v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,15 +829,7 @@ impl SequencerState {
|
|||||||
.get(&(active.bank, active.pattern))
|
.get(&(active.bank, active.pattern))
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or_else(|| pattern.speed.multiplier());
|
.unwrap_or_else(|| pattern.speed.multiplier());
|
||||||
let beat_int = (beat * 4.0 * speed_mult).floor() as i64;
|
let steps_to_fire = substeps_crossed(prev_beat, beat, speed_mult);
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
for _ in 0..steps_to_fire {
|
for _ in 0..steps_to_fire {
|
||||||
result.any_step_fired = true;
|
result.any_step_fired = true;
|
||||||
@@ -863,8 +844,7 @@ impl SequencerState {
|
|||||||
if step.active && has_script {
|
if step.active && has_script {
|
||||||
let pattern_key = (active.bank, active.pattern);
|
let pattern_key = (active.bank, active.pattern);
|
||||||
let is_muted = self.muted.contains(&pattern_key)
|
let is_muted = self.muted.contains(&pattern_key)
|
||||||
|| (!self.soloed.is_empty()
|
|| (!self.soloed.is_empty() && !self.soloed.contains(&pattern_key));
|
||||||
&& !self.soloed.contains(&pattern_key));
|
|
||||||
|
|
||||||
if !is_muted {
|
if !is_muted {
|
||||||
let source_idx = pattern.resolve_source(step_idx);
|
let source_idx = pattern.resolve_source(step_idx);
|
||||||
@@ -941,11 +921,7 @@ impl SequencerState {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_variables(
|
fn read_variables(&self, completed: &[PatternId], any_step_fired: bool) -> VariableReads {
|
||||||
&self,
|
|
||||||
completed: &[PatternId],
|
|
||||||
any_step_fired: bool,
|
|
||||||
) -> VariableReads {
|
|
||||||
let stopped = &self.buf_stopped;
|
let stopped = &self.buf_stopped;
|
||||||
let needs_access = !completed.is_empty() || !stopped.is_empty() || any_step_fired;
|
let needs_access = !completed.is_empty() || !stopped.is_empty() || any_step_fired;
|
||||||
if !needs_access {
|
if !needs_access {
|
||||||
@@ -955,8 +931,8 @@ impl SequencerState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut vars = self.variables.lock();
|
let vars = self.variables.load();
|
||||||
let new_tempo = vars.remove("__tempo__").and_then(|v| v.as_float().ok());
|
let new_tempo = vars.get("__tempo__").and_then(|v: &Value| v.as_float().ok());
|
||||||
|
|
||||||
let mut chain_transitions = Vec::new();
|
let mut chain_transitions = Vec::new();
|
||||||
for id in completed {
|
for id in completed {
|
||||||
@@ -966,12 +942,29 @@ impl SequencerState {
|
|||||||
chain_transitions.push((*id, target));
|
chain_transitions.push((*id, target));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
vars.remove(chain_key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for id in stopped {
|
// Remove consumed variables (tempo and chain keys)
|
||||||
let chain_key = self.key_cache.chain_key(id.bank, id.pattern);
|
let needs_removal = new_tempo.is_some()
|
||||||
vars.remove(chain_key);
|
|| 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 {
|
VariableReads {
|
||||||
@@ -1037,8 +1030,7 @@ impl SequencerState {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn sequencer_loop(
|
fn sequencer_loop(
|
||||||
cmd_rx: Receiver<SeqCommand>,
|
cmd_rx: Receiver<SeqCommand>,
|
||||||
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
|
dispatch_tx: Sender<TimedCommand>,
|
||||||
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
|
|
||||||
link: Arc<LinkState>,
|
link: Arc<LinkState>,
|
||||||
playing: Arc<std::sync::atomic::AtomicBool>,
|
playing: Arc<std::sync::atomic::AtomicBool>,
|
||||||
variables: Variables,
|
variables: Variables,
|
||||||
@@ -1058,39 +1050,17 @@ fn sequencer_loop(
|
|||||||
) {
|
) {
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
#[cfg(unix)]
|
let has_rt = set_realtime_priority();
|
||||||
{
|
|
||||||
use thread_priority::unix::{
|
|
||||||
set_thread_priority_and_policy, thread_native_id, NormalThreadSchedulePolicy,
|
|
||||||
RealtimeThreadSchedulePolicy, ThreadSchedulePolicy,
|
|
||||||
};
|
|
||||||
|
|
||||||
let tid = thread_native_id();
|
#[cfg(target_os = "linux")]
|
||||||
|
if !has_rt {
|
||||||
// Try SCHED_FIFO first (requires CAP_SYS_NICE on Linux)
|
eprintln!("[cagire] Warning: Could not set realtime priority for sequencer thread.");
|
||||||
let fifo = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::Fifo);
|
eprintln!("[cagire] For best performance on Linux, configure rtprio limits:");
|
||||||
if set_thread_priority_and_policy(tid, ThreadPriority::Max, fifo).is_err() {
|
eprintln!("[cagire] Add your user to 'audio' group: sudo usermod -aG audio $USER");
|
||||||
// Try SCHED_RR (round-robin realtime, sometimes works without caps)
|
eprintln!("[cagire] Edit /etc/security/limits.conf and add:");
|
||||||
let rr = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::RoundRobin);
|
eprintln!("[cagire] @audio - rtprio 95");
|
||||||
if set_thread_priority_and_policy(tid, ThreadPriority::Max, rr).is_err() {
|
eprintln!("[cagire] @audio - memlock unlimited");
|
||||||
// Fall back to highest normal priority (SCHED_OTHER)
|
eprintln!("[cagire] Then log out and back in.");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut seq_state = SequencerState::new(variables, dict, rng, cc_access);
|
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 state = link.capture_app_state();
|
||||||
let current_time_us = link.clock_micros();
|
let current_time_us = link.clock_micros() as SyncTime;
|
||||||
let beat = state.beat_at_time(current_time_us, quantum);
|
let beat = state.beat_at_time(current_time_us as i64, quantum);
|
||||||
let tempo = state.tempo();
|
let tempo = state.tempo();
|
||||||
|
|
||||||
let sr = sample_rate.load(Ordering::Relaxed) as f64;
|
let sr = sample_rate.load(Ordering::Relaxed) as f64;
|
||||||
@@ -1139,87 +1109,54 @@ fn sequencer_loop(
|
|||||||
|
|
||||||
let output = seq_state.tick(input);
|
let output = seq_state.tick(input);
|
||||||
|
|
||||||
|
// Dispatch commands via the dispatcher thread
|
||||||
for tsc in output.audio_commands {
|
for tsc in output.audio_commands {
|
||||||
if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) {
|
if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) {
|
||||||
match midi_tx.load().try_send(midi_cmd.clone()) {
|
// Queue MIDI command for immediate dispatch
|
||||||
Ok(()) => {
|
let _ = dispatch_tx.send(TimedCommand {
|
||||||
if let (
|
command: DispatchCommand::Midi(midi_cmd.clone()),
|
||||||
MidiCommand::NoteOn {
|
target_time_us: current_time_us,
|
||||||
device,
|
});
|
||||||
channel,
|
|
||||||
note,
|
// Schedule note-off if duration specified
|
||||||
..
|
if let (
|
||||||
},
|
MidiCommand::NoteOn {
|
||||||
Some(dur_secs),
|
device,
|
||||||
) = (&midi_cmd, dur)
|
channel,
|
||||||
{
|
note,
|
||||||
let dur_us = (dur_secs * 1_000_000.0) as i64;
|
..
|
||||||
seq_state.active_notes.insert(
|
},
|
||||||
(*device, *channel, *note),
|
Some(dur_secs),
|
||||||
ActiveNote {
|
) = (&midi_cmd, dur)
|
||||||
off_time_us: current_time_us + dur_us,
|
{
|
||||||
start_time_us: current_time_us,
|
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,
|
||||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
|
note: *note,
|
||||||
seq_state.dropped_events += 1;
|
}),
|
||||||
}
|
target_time_us: off_time_us,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let cmd = AudioCommand::Evaluate {
|
// Queue audio command
|
||||||
cmd: tsc.cmd,
|
let _ = dispatch_tx.send(TimedCommand {
|
||||||
time: tsc.time,
|
command: DispatchCommand::Audio {
|
||||||
};
|
cmd: tsc.cmd,
|
||||||
match audio_tx.load().try_send(cmd) {
|
time: tsc.time,
|
||||||
Ok(()) => {}
|
},
|
||||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
|
target_time_us: current_time_us,
|
||||||
seq_state.dropped_events += 1;
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_NOTE_DURATION_US: i64 = 30_000_000; // 30 second safety timeout
|
// Handle MIDI flush request
|
||||||
|
|
||||||
if output.flush_midi_notes {
|
if output.flush_midi_notes {
|
||||||
for ((device, channel, note), _) in seq_state.active_notes.drain() {
|
let _ = dispatch_tx.send(TimedCommand {
|
||||||
let _ = midi_tx.load().try_send(MidiCommand::NoteOff {
|
command: DispatchCommand::FlushMidi,
|
||||||
device,
|
target_time_us: current_time_us,
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(t) = output.new_tempo {
|
if let Some(t) = output.new_tempo {
|
||||||
@@ -1228,61 +1165,64 @@ fn sequencer_loop(
|
|||||||
|
|
||||||
shared_state.store(Arc::new(output.shared_state));
|
shared_state.store(Arc::new(output.shared_state));
|
||||||
|
|
||||||
// Adaptive sleep: calculate time until next substep boundary
|
// Calculate time until next substep based on active patterns
|
||||||
// At max speed (8x), substeps occur every beat/32
|
let next_event_us = {
|
||||||
// Sleep for most of that time, leaving 500μs margin for processing
|
let mut min_micros = SyncTime::MAX;
|
||||||
let beats_per_sec = tempo / 60.0;
|
for id in seq_state.audio_state.active_patterns.keys() {
|
||||||
let max_speed = 8.0; // Maximum speed multiplier from speed.clamp()
|
let speed = seq_state
|
||||||
let secs_per_substep = 1.0 / (beats_per_sec * 4.0 * max_speed);
|
.speed_overrides
|
||||||
let substep_us = (secs_per_substep * 1_000_000.0) as u64;
|
.get(&(id.bank, id.pattern))
|
||||||
// Sleep for most of the substep duration, clamped to reasonable bounds
|
.copied()
|
||||||
let sleep_us = substep_us.saturating_sub(500).clamp(50, 2000);
|
.or_else(|| {
|
||||||
precise_sleep(sleep_us);
|
seq_state
|
||||||
}
|
.pattern_cache
|
||||||
}
|
.get(id.bank, id.pattern)
|
||||||
|
.map(|p| p.speed.multiplier())
|
||||||
/// High-precision sleep using timerfd on Linux, falling back to thread::sleep elsewhere
|
})
|
||||||
#[cfg(target_os = "linux")]
|
.unwrap_or(1.0);
|
||||||
fn precise_sleep(micros: u64) {
|
let micros = micros_until_next_substep(beat, speed, tempo);
|
||||||
use nix::sys::timerfd::{ClockId, Expiration, TimerFd, TimerFlags, TimerSetTimeFlags};
|
min_micros = min_micros.min(micros);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
// Clear the timer by reading (discard the count)
|
// If no active patterns, default to 1ms for command responsiveness
|
||||||
let _ = timer.wait();
|
if min_micros == SyncTime::MAX {
|
||||||
} else {
|
1000
|
||||||
thread::sleep(duration);
|
} 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"))]
|
/// Spin-wait threshold in microseconds. With RT priority, we sleep most of the
|
||||||
fn precise_sleep(micros: u64) {
|
/// wait time and spin-wait for the final portion for precision. Without RT priority,
|
||||||
thread::sleep(Duration::from_micros(micros));
|
/// 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>)> {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
fn make_state() -> SequencerState {
|
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 dict: Dictionary = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let rng: Rng = Arc::new(Mutex::new(
|
let rng: Rng = Arc::new(Mutex::new(
|
||||||
<rand::rngs::StdRng as rand::SeedableRng>::seed_from_u64(0),
|
<rand::rngs::StdRng as rand::SeedableRng>::seed_from_u64(0),
|
||||||
@@ -1573,11 +1514,12 @@ mod tests {
|
|||||||
|
|
||||||
// Set chain variable
|
// Set chain variable
|
||||||
{
|
{
|
||||||
let mut vars = state.variables.lock();
|
let mut vars = (**state.variables.load()).clone();
|
||||||
vars.insert(
|
vars.insert(
|
||||||
"__chain_0_0__".to_string(),
|
"__chain_0_0__".to_string(),
|
||||||
Value::Str(std::sync::Arc::from("0:1"), None),
|
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.
|
// Pattern 0 completes iteration AND gets stopped immediately in the same tick.
|
||||||
@@ -1822,11 +1764,12 @@ mod tests {
|
|||||||
|
|
||||||
// Set chain: 0:0 -> 0:1
|
// Set chain: 0:0 -> 0:1
|
||||||
{
|
{
|
||||||
let mut vars = state.variables.lock();
|
let mut vars = (**state.variables.load()).clone();
|
||||||
vars.insert(
|
vars.insert(
|
||||||
"__chain_0_0__".to_string(),
|
"__chain_0_0__".to_string(),
|
||||||
Value::Str(std::sync::Arc::from("0:1"), None),
|
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
|
// 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.
|
// 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));
|
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));
|
let output = state.tick(tick_at(1.0, true));
|
||||||
@@ -2131,7 +2075,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should fire steps continuously without gaps
|
// 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]
|
#[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);
|
let mods = convert_modifiers(*modifiers);
|
||||||
// For character keys without ctrl/alt, let Event::Text handle it
|
// 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;
|
return None;
|
||||||
}
|
}
|
||||||
let code = convert_key(*key)?;
|
let code = convert_key(*key)?;
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ struct Args {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> io::Result<()> {
|
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 args = Args::parse();
|
||||||
let settings = Settings::load();
|
let settings = Settings::load();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use arc_swap::ArcSwap;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -63,7 +64,7 @@ fn compute_stack_display(
|
|||||||
let result = if script.trim().is_empty() {
|
let result = if script.trim().is_empty() {
|
||||||
"Stack: []".to_string()
|
"Stack: []".to_string()
|
||||||
} else {
|
} 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 dict = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(42)));
|
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(42)));
|
||||||
let forth = Forth::new(vars, dict, rng);
|
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 cagire::forth::{Dictionary, Forth, Rng, StepContext, Value, Variables};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rand::rngs::StdRng;
|
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 {
|
pub fn new_vars() -> Variables {
|
||||||
Arc::new(Mutex::new(HashMap::new()))
|
Arc::new(ArcSwap::from_pointee(HashMap::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_dict() -> Dictionary {
|
pub fn new_dict() -> Dictionary {
|
||||||
|
|||||||
Reference in New Issue
Block a user