diff --git a/CHANGELOG.md b/CHANGELOG.md index fe56219..aece68b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,10 @@ All notable changes to this project will be documented in this file. - TachyonFX based animations -### Removed -- Removed lookahead from sequencer; event timing now uses `engine_time + time_delta` directly. - ### Changed +- Sequencer rewritten with prospective lookahead scheduling. Instead of sleeping until a substep, waking late, and detecting past events, the sequencer now pre-computes all events within a ~20ms forward window. Events arrive at doux with positive time deltas, scheduled before they need to fire. Sleep+spin-wait replaced by `recv_timeout(3ms)` on the command channel. Timing no longer depends on OS sleep precision. +- `audio_sample_pos` updated at buffer start instead of end, so `engine_time` reflects current playback position. +- Doux grace period increased from 1ms to 50ms as a safety net (events should never be late with lookahead). - Flattened model re-export indirection; `script.rs` now exports only `ScriptEngine`. - Hue rotation step size increased from 1° to 5° for faster adjustment. - Moved catalog data (DOCS, CATEGORIES) from views to `src/model/`, eliminating state-to-view layer inversion. diff --git a/src/engine/audio.rs b/src/engine/audio.rs index 7d15be6..40e89bb 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -305,6 +305,8 @@ pub fn build_stream( let buffer_samples = data.len() / channels; let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64; + audio_sample_pos.fetch_add(buffer_samples as u64, Ordering::Release); + while let Ok(cmd) = audio_rx.try_recv() { match cmd { AudioCommand::Evaluate { cmd, time } => { @@ -335,8 +337,6 @@ pub fn build_stream( engine.process_block(data, &[], &[]); scope_buffer.write(&engine.output); - audio_sample_pos.fetch_add(buffer_samples as u64, Ordering::Relaxed); - // Feed mono mix to analysis thread via ring buffer (non-blocking) for chunk in engine.output.chunks(channels) { let mono = chunk.iter().sum::() / channels as f32; diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 107eb38..f53ed47 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -5,7 +5,7 @@ pub mod realtime; pub mod sequencer; mod timing; -pub use timing::{micros_until_next_substep, substeps_crossed, StepTiming, SyncTime}; +pub use timing::{substeps_in_window, StepTiming, SyncTime}; // AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs #[allow(unused_imports)] diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index f92dfb0..b05f385 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -11,8 +11,8 @@ use std::sync::Arc; use std::thread::{self, JoinHandle}; use super::dispatcher::{dispatcher_loop, MidiDispatch, TimedMidiCommand}; -use super::realtime::{precise_sleep_us, set_realtime_priority}; -use super::{micros_until_next_substep, substeps_crossed, LinkState, StepTiming, SyncTime}; +use super::realtime::set_realtime_priority; +use super::{substeps_in_window, LinkState, StepTiming, SyncTime}; use crate::model::{ CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables, }; @@ -450,6 +450,7 @@ pub(crate) struct TickInput { pub commands: Vec, pub playing: bool, pub beat: f64, + pub lookahead_end: f64, pub tempo: f64, pub quantum: f64, pub fill: bool, @@ -682,15 +683,16 @@ impl SequencerState { return self.tick_paused(); } - let beat = input.beat; - let prev_beat = self.audio_state.prev_beat; + let frontier = self.audio_state.prev_beat; + let lookahead_end = input.lookahead_end; - self.activate_pending(beat, prev_beat, input.quantum); - self.deactivate_pending(beat, prev_beat, input.quantum); + self.activate_pending(lookahead_end, frontier, input.quantum); + self.deactivate_pending(lookahead_end, frontier, input.quantum); let steps = self.execute_steps( - beat, - prev_beat, + input.beat, + frontier, + lookahead_end, input.tempo, input.quantum, input.fill, @@ -708,7 +710,7 @@ impl SequencerState { let vars = self.read_variables(&self.buf_completed_iterations, steps.any_step_fired); self.apply_chain_transitions(vars.chain_transitions); - self.audio_state.prev_beat = beat; + self.audio_state.prev_beat = lookahead_end; let flush = std::mem::take(&mut self.audio_state.flush_midi_notes); TickOutput { @@ -805,7 +807,8 @@ impl SequencerState { fn execute_steps( &mut self, beat: f64, - prev_beat: f64, + frontier: f64, + lookahead_end: f64, tempo: f64, quantum: f64, fill: bool, @@ -843,17 +846,13 @@ impl SequencerState { .get(&(active.bank, active.pattern)) .copied() .unwrap_or_else(|| pattern.speed.multiplier()); - let steps_to_fire = substeps_crossed(prev_beat, beat, speed_mult); - let substeps_per_beat = 4.0 * speed_mult; - let base_substep = (prev_beat * substeps_per_beat).floor() as i64; - for step_offset in 0..steps_to_fire { + let step_beats = substeps_in_window(frontier, lookahead_end, speed_mult); + + for step_beat in step_beats { result.any_step_fired = true; let step_idx = active.step_index % pattern.length; - // Per-step timing: each step gets its exact beat position - let step_substep = base_substep + step_offset as i64 + 1; - let step_beat = step_substep as f64 / substeps_per_beat; let beat_delta = step_beat - beat; let time_delta = if tempo > 0.0 { (beat_delta / tempo) * 60.0 @@ -882,11 +881,11 @@ impl SequencerState { ); let ctx = StepContext { step: step_idx, - beat, + beat: step_beat, bank: active.bank, pattern: active.pattern, tempo, - phase: beat % quantum, + phase: step_beat % quantum, slot: 0, runs, iter: active.iter, @@ -1077,26 +1076,31 @@ fn sequencer_loop( ) { use std::sync::atomic::Ordering; - let has_rt = set_realtime_priority(); - - #[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."); - } + set_realtime_priority(); 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(StdRng::seed_from_u64(0))); let mut seq_state = SequencerState::new(variables, dict, rng, cc_access); + // Lookahead window: ~20ms expressed in beats, recomputed each tick + const LOOKAHEAD_SECS: f64 = 0.02; + // Wake cadence: how long to sleep between scheduling passes + const WAKE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(3); + loop { + // Drain all pending commands, also serves as the sleep mechanism let mut commands = Vec::with_capacity(8); + match cmd_rx.recv_timeout(WAKE_INTERVAL) { + Ok(cmd) => { + if matches!(cmd, SeqCommand::Shutdown) { + return; + } + commands.push(cmd); + } + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => return, + Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} + } while let Ok(cmd) = cmd_rx.try_recv() { if matches!(cmd, SeqCommand::Shutdown) { return; @@ -1109,6 +1113,13 @@ fn sequencer_loop( let beat = state.beat_at_time(current_time_us as i64, quantum); let tempo = state.tempo(); + let lookahead_beats = if tempo > 0.0 { + LOOKAHEAD_SECS * tempo / 60.0 + } else { + 0.0 + }; + let lookahead_end = beat + lookahead_beats; + let sr = sample_rate.load(Ordering::Relaxed) as f64; let audio_samples = audio_sample_pos.load(Ordering::Acquire); let engine_time = if sr > 0.0 { @@ -1120,6 +1131,7 @@ fn sequencer_loop( commands, playing: playing.load(Ordering::Relaxed), beat, + lookahead_end, tempo, quantum, fill: live_keys.fill(), @@ -1165,7 +1177,6 @@ fn sequencer_loop( }); } } else { - // Audio direct to doux — sample-accurate scheduling via /time/ parameter let _ = audio_tx.load().send(AudioCommand::Evaluate { cmd: tsc.cmd, time: tsc.time, @@ -1185,63 +1196,6 @@ fn sequencer_loop( } shared_state.store(Arc::new(output.shared_state)); - - // 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); - } - // 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); - } -} - -/// 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); - } } } @@ -1390,6 +1344,7 @@ mod tests { commands: Vec::new(), playing, beat, + lookahead_end: beat, tempo: 120.0, quantum: 4.0, fill: false, @@ -1410,6 +1365,7 @@ mod tests { commands, playing: true, beat, + lookahead_end: beat, tempo: 120.0, quantum: 4.0, fill: false, diff --git a/src/engine/timing.rs b/src/engine/timing.rs index ea7370b..088db37 100644 --- a/src/engine/timing.rs +++ b/src/engine/timing.rs @@ -1,14 +1,6 @@ /// 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 { @@ -33,33 +25,55 @@ impl StepTiming { } } -/// 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; +/// Return the beat positions of all substeps in the window [frontier, end). +/// Each entry is the exact beat at which that substep fires. +/// Clamped to 64 results max to prevent runaway. +pub fn substeps_in_window(frontier: f64, end: f64, speed: f64) -> Vec { + if frontier < 0.0 || end <= frontier || speed <= 0.0 { + return Vec::new(); } 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) + let first = (frontier * substeps_per_beat).floor() as i64 + 1; + let last = (end * substeps_per_beat).floor() as i64; + let count = (last - first + 1).clamp(0, 64) as usize; + let mut result = Vec::with_capacity(count); + for i in 0..count as i64 { + result.push((first + i) as f64 / substeps_per_beat); + } + result } #[cfg(test)] mod tests { use super::*; + fn beats_to_micros(beats: f64, tempo: f64) -> SyncTime { + if tempo <= 0.0 { + return 0; + } + ((beats / tempo) * 60_000_000.0).round() as SyncTime + } + + 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 + } + + 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) + } + #[test] fn test_beats_to_micros_at_120_bpm() { // At 120 BPM, one beat = 0.5 seconds = 500,000 microseconds @@ -159,4 +173,40 @@ mod tests { fn test_micros_until_next_substep_zero_speed() { assert_eq!(micros_until_next_substep(0.0, 0.0, 120.0), 0); } + + #[test] + fn test_substeps_in_window_basic() { + // At 1x speed, substeps at 0.25, 0.5, 0.75, 1.0... + // Window [0.0, 0.5) should contain 0.25 and 0.5 + let result = substeps_in_window(0.0, 0.5, 1.0); + assert_eq!(result, vec![0.25, 0.5]); + } + + #[test] + fn test_substeps_in_window_2x_speed() { + // At 2x speed, substeps at 0.125, 0.25, 0.375, 0.5... + // Window [0.0, 0.5) should contain 4 substeps + let result = substeps_in_window(0.0, 0.5, 2.0); + assert_eq!(result, vec![0.125, 0.25, 0.375, 0.5]); + } + + #[test] + fn test_substeps_in_window_mid_beat() { + // Window [0.3, 0.6): should contain 0.5 + let result = substeps_in_window(0.3, 0.6, 1.0); + assert_eq!(result, vec![0.5]); + } + + #[test] + fn test_substeps_in_window_empty() { + // Window too small to contain any substep + let result = substeps_in_window(0.1, 0.2, 1.0); + assert!(result.is_empty()); + } + + #[test] + fn test_substeps_in_window_negative_frontier() { + let result = substeps_in_window(-1.0, 0.5, 1.0); + assert!(result.is_empty()); + } }