From 19555be975526050d436db9ee072994daf8fed6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Mon, 2 Feb 2026 19:12:32 +0100 Subject: [PATCH] lookahead --- CHANGELOG.md | 1 + src/engine/sequencer.rs | 531 +++++++++++++++++++++++++++++++++++----- 2 files changed, 474 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b2a8b4..56bab03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added - Mute/solo for patterns: stage with `m`/`x`, commit with `c`. Solo mutes all other patterns. Clear with `M`/`X`. +- Lookahead scheduling: scripts are pre-evaluated ahead of time and audio commands are scheduled at precise beat positions, improving timing accuracy under CPU load. ## [0.0.4] - 2026-02-02 diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 4c8ddb7..ba415e3 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -436,6 +436,25 @@ fn check_quantization_boundary( type StepKey = (usize, usize, usize); +/// Tracks a step that has been pre-evaluated via lookahead scheduling. +/// Used to prevent duplicate evaluation when the step's actual fire time arrives. +struct ScheduledStep { + target_beat: f64, + tempo_at_schedule: f64, +} + +/// An audio command scheduled for future emission. +/// Commands are held here until their target_beat passes, then emitted to the audio engine. +struct PendingCommand { + cmd: TimestampedCommand, + target_beat: f64, + bank: usize, + pattern: usize, +} + +/// Key for tracking scheduled steps: (bank, pattern, step_index, beat_int) +type ScheduledStepKey = (usize, usize, usize, i64); + struct RunsCounter { counts: HashMap, } @@ -557,6 +576,10 @@ pub(crate) struct SequencerState { active_notes: HashMap<(u8, u8, u8), ActiveNote>, muted: std::collections::HashSet<(usize, usize)>, soloed: std::collections::HashSet<(usize, usize)>, + // Lookahead scheduling state + scheduled_steps: HashMap<(usize, usize, usize, i64), ScheduledStep>, + pending_commands: Vec, + last_tempo: f64, } impl SequencerState { @@ -583,6 +606,9 @@ impl SequencerState { active_notes: HashMap::new(), muted: std::collections::HashSet::new(), soloed: std::collections::HashSet::new(), + scheduled_steps: HashMap::new(), + pending_commands: Vec::new(), + last_tempo: 120.0, } } @@ -667,6 +693,8 @@ impl SequencerState { Arc::make_mut(&mut self.step_traces).clear(); self.runs_counter.counts.clear(); self.audio_state.flush_midi_notes = true; + self.scheduled_steps.clear(); + self.pending_commands.clear(); } SeqCommand::Shutdown => {} } @@ -785,12 +813,65 @@ impl SequencerState { Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| { bank != pending.id.bank || pattern != pending.id.pattern }); + // Clear scheduled steps and pending commands for this pattern + let (b, p) = (pending.id.bank, pending.id.pattern); + self.scheduled_steps + .retain(|&(bank, pattern, _, _), _| bank != b || pattern != p); + self.pending_commands + .retain(|cmd| cmd.bank != b || cmd.pattern != p); stopped.push(pending.id); } } stopped } + /// Convert a logical beat position to engine time for audio scheduling. + fn beat_to_engine_time( + target_beat: f64, + current_beat: f64, + engine_time: f64, + tempo: f64, + ) -> f64 { + let beats_ahead = target_beat - current_beat; + let secs_ahead = beats_ahead * 60.0 / tempo; + engine_time + secs_ahead + } + + /// Reschedule all pending commands when tempo changes. + fn reschedule_for_tempo_change( + &mut self, + new_tempo: f64, + current_beat: f64, + engine_time: f64, + ) { + for pending in &mut self.pending_commands { + if pending.cmd.time.is_some() { + pending.cmd.time = Some(Self::beat_to_engine_time( + pending.target_beat, + current_beat, + engine_time, + new_tempo, + )); + } + } + for step in self.scheduled_steps.values_mut() { + step.tempo_at_schedule = new_tempo; + } + self.last_tempo = new_tempo; + } + + /// Main step execution with lookahead scheduling support. + /// + /// This function handles two timing modes: + /// 1. **Immediate firing**: When a beat boundary is crossed (`beat_int != prev_beat_int`), + /// the current step fires. If already pre-evaluated via lookahead, we skip evaluation. + /// 2. **Lookahead pre-evaluation**: When `lookahead_secs > 0`, we pre-evaluate future steps + /// and queue their commands with precise timestamps for later emission. + /// + /// The lookahead scheduling improves timing accuracy by: + /// - Evaluating scripts BEFORE their logical fire time + /// - Scheduling audio commands at exact beat positions using engine time + /// - Allowing the audio engine to play sounds at the precise moment #[allow(clippy::too_many_arguments)] fn execute_steps( &mut self, @@ -813,6 +894,12 @@ impl SequencerState { any_step_fired: false, }; + // Reschedule pending commands if tempo changed + if (tempo - self.last_tempo).abs() > 0.001 { + self.reschedule_for_tempo_change(tempo, beat, engine_time); + } + + // Load speed overrides from variables self.speed_overrides.clear(); { let vars = self.variables.lock().unwrap(); @@ -826,7 +913,13 @@ impl SequencerState { let muted_snapshot = self.muted.clone(); let soloed_snapshot = self.soloed.clone(); + let lookahead_beats = if tempo > 0.0 { + lookahead_secs * tempo / 60.0 + } else { + 0.0 + }; + // Process each active pattern for (_id, active) in self.audio_state.active_patterns.iter_mut() { let Some(pattern) = self.pattern_cache.get(active.bank, active.pattern) else { continue; @@ -837,75 +930,86 @@ 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; + let step_fires = beat_int != prev_beat_int && prev_beat >= 0.0; - if beat_int != prev_beat_int && prev_beat >= 0.0 { + // === IMMEDIATE STEP EXECUTION === + // Fire the current step if a beat boundary was crossed + if step_fires { result.any_step_fired = true; let step_idx = active.step_index % pattern.length; + let sched_key: ScheduledStepKey = + (active.bank, active.pattern, step_idx, beat_int); - if let Some(step) = pattern.steps.get(step_idx) { - let resolved_script = pattern.resolve_script(step_idx); - let has_script = resolved_script - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); + // Skip evaluation if already done via lookahead + if !self.scheduled_steps.contains_key(&sched_key) { + if let Some(step) = pattern.steps.get(step_idx) { + let resolved_script = pattern.resolve_script(step_idx); + let has_script = resolved_script + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); - if step.active && has_script { - let key = (active.bank, active.pattern); - let is_muted = muted_snapshot.contains(&key) - || (!soloed_snapshot.is_empty() && !soloed_snapshot.contains(&key)); + if step.active && has_script { + let pattern_key = (active.bank, active.pattern); + let is_muted = muted_snapshot.contains(&pattern_key) + || (!soloed_snapshot.is_empty() + && !soloed_snapshot.contains(&pattern_key)); - let source_idx = pattern.resolve_source(step_idx); - let runs = self.runs_counter.get_and_increment( - active.bank, - active.pattern, - source_idx, - ); - let ctx = StepContext { - step: step_idx, - beat, - bank: active.bank, - pattern: active.pattern, - tempo, - phase: beat % quantum, - slot: 0, - runs, - iter: active.iter, - speed: speed_mult, - fill, - nudge_secs, - cc_access: self.cc_access.clone(), - #[cfg(feature = "desktop")] - mouse_x, - #[cfg(feature = "desktop")] - mouse_y, - #[cfg(feature = "desktop")] - mouse_down, - }; - if let Some(script) = resolved_script { - let mut trace = ExecutionTrace::default(); - if let Ok(cmds) = self - .script_engine - .evaluate_with_trace(script, &ctx, &mut trace) - { - Arc::make_mut(&mut self.step_traces).insert( - (active.bank, active.pattern, source_idx), - std::mem::take(&mut trace), + if !is_muted { + let source_idx = pattern.resolve_source(step_idx); + let runs = self.runs_counter.get_and_increment( + active.bank, + active.pattern, + source_idx, ); + let ctx = StepContext { + step: step_idx, + beat, + bank: active.bank, + pattern: active.pattern, + tempo, + phase: beat % quantum, + slot: 0, + runs, + iter: active.iter, + speed: speed_mult, + fill, + nudge_secs, + cc_access: self.cc_access.clone(), + #[cfg(feature = "desktop")] + mouse_x, + #[cfg(feature = "desktop")] + mouse_y, + #[cfg(feature = "desktop")] + mouse_down, + }; - if !is_muted { - let event_time = if lookahead_secs > 0.0 { - Some(engine_time + lookahead_secs) - } else { - None - }; + if let Some(script) = resolved_script { + let mut trace = ExecutionTrace::default(); + if let Ok(cmds) = self + .script_engine + .evaluate_with_trace(script, &ctx, &mut trace) + { + Arc::make_mut(&mut self.step_traces).insert( + (active.bank, active.pattern, source_idx), + std::mem::take(&mut trace), + ); - for cmd in cmds { - self.event_count += 1; - self.buf_audio_commands.push(TimestampedCommand { - cmd, - time: event_time, - }); + let event_time = if lookahead_secs > 0.0 { + Some(engine_time + lookahead_secs) + } else { + None + }; + + for cmd in cmds { + self.event_count += 1; + self.buf_audio_commands.push(TimestampedCommand { + cmd, + time: event_time, + }); + } } } } @@ -913,6 +1017,7 @@ impl SequencerState { } } + // Advance step index let next_step = active.step_index + 1; if next_step >= pattern.length { active.iter += 1; @@ -923,8 +1028,144 @@ impl SequencerState { } active.step_index = next_step % pattern.length; } + + // === LOOKAHEAD PRE-EVALUATION === + // Pre-evaluate future steps within the lookahead window + if lookahead_secs > 0.0 { + let future_beat = beat + lookahead_beats; + let future_beat_int = (future_beat * 4.0 * speed_mult).floor() as i64; + let start_beat_int = beat_int + 1; + + let mut lookahead_step = active.step_index; + let mut lookahead_iter = active.iter; + + for target_beat_int in start_beat_int..=future_beat_int { + let step_idx = lookahead_step % pattern.length; + let sched_key: ScheduledStepKey = + (active.bank, active.pattern, step_idx, target_beat_int); + + // Skip if already scheduled + if self.scheduled_steps.contains_key(&sched_key) { + let next = lookahead_step + 1; + if next >= pattern.length { + lookahead_iter += 1; + } + lookahead_step = next % pattern.length; + continue; + } + + // Calculate the logical beat time for this step + let target_beat = target_beat_int as f64 / (4.0 * speed_mult); + + if let Some(step) = pattern.steps.get(step_idx) { + let resolved_script = pattern.resolve_script(step_idx); + let has_script = resolved_script + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if step.active && has_script { + let pattern_key = (active.bank, active.pattern); + let is_muted = muted_snapshot.contains(&pattern_key) + || (!soloed_snapshot.is_empty() + && !soloed_snapshot.contains(&pattern_key)); + + if !is_muted { + let source_idx = pattern.resolve_source(step_idx); + let runs = self.runs_counter.get_and_increment( + active.bank, + active.pattern, + source_idx, + ); + + let ctx = StepContext { + step: step_idx, + beat: target_beat, + bank: active.bank, + pattern: active.pattern, + tempo, + phase: target_beat % quantum, + slot: 0, + runs, + iter: lookahead_iter, + speed: speed_mult, + fill, + nudge_secs, + cc_access: self.cc_access.clone(), + #[cfg(feature = "desktop")] + mouse_x, + #[cfg(feature = "desktop")] + mouse_y, + #[cfg(feature = "desktop")] + mouse_down, + }; + + if let Some(script) = resolved_script { + let mut trace = ExecutionTrace::default(); + if let Ok(cmds) = self + .script_engine + .evaluate_with_trace(script, &ctx, &mut trace) + { + Arc::make_mut(&mut self.step_traces).insert( + (active.bank, active.pattern, source_idx), + std::mem::take(&mut trace), + ); + + let event_time = Some(Self::beat_to_engine_time( + target_beat, + beat, + engine_time, + tempo, + )); + + for cmd in cmds { + self.event_count += 1; + self.pending_commands.push(PendingCommand { + cmd: TimestampedCommand { cmd, time: event_time }, + target_beat, + bank: active.bank, + pattern: active.pattern, + }); + } + } + } + } + } + } + + // Mark step as scheduled + self.scheduled_steps.insert( + sched_key, + ScheduledStep { + target_beat, + tempo_at_schedule: tempo, + }, + ); + + // Advance for next iteration + let next = lookahead_step + 1; + if next >= pattern.length { + lookahead_iter += 1; + } + lookahead_step = next % pattern.length; + } + } } + // === EMIT READY COMMANDS === + // Move commands whose target_beat has passed from pending to output + let (ready, still_pending): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending_commands) + .into_iter() + .partition(|p| p.target_beat <= beat); + self.pending_commands = still_pending; + + for pending in ready { + self.buf_audio_commands.push(pending.cmd); + } + + // Cleanup stale scheduled_steps (more than 1 beat in the past) + self.scheduled_steps + .retain(|_, s| s.target_beat > beat - 1.0); + result } @@ -1955,4 +2196,178 @@ mod tests { let output = state.tick(tick_at(1.0, true)); assert_eq!(output.new_tempo, Some(140.0)); } + + fn tick_with_lookahead(beat: f64, lookahead_secs: f64) -> TickInput { + TickInput { + commands: Vec::new(), + playing: true, + beat, + tempo: 120.0, + quantum: 4.0, + fill: false, + nudge_secs: 0.0, + current_time_us: 0, + engine_time: beat * 0.5, // At 120 BPM, 1 beat = 0.5 seconds + lookahead_secs, + #[cfg(feature = "desktop")] + mouse_x: 0.5, + #[cfg(feature = "desktop")] + mouse_y: 0.5, + #[cfg(feature = "desktop")] + mouse_down: 0.0, + } + } + + fn pattern_with_sound(length: usize) -> PatternSnapshot { + PatternSnapshot { + speed: Default::default(), + length, + steps: (0..length) + .map(|_| StepSnapshot { + active: true, + script: "sine sound 500 freq .".into(), + source: None, + }) + .collect(), + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + } + } + + #[test] + fn test_lookahead_pre_evaluates_future_steps() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: pattern_with_sound(4), + }], + 0.0, + )); + + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 0.5, + )); + + // With 100ms lookahead at 120 BPM = 0.2 beats lookahead + // At beat 0.75, future_beat = 0.95 + // beat_int = 3, future_beat_int = 3 + // next_beat_int = 4 > future_beat_int, so no lookahead yet + let output = state.tick(tick_with_lookahead(0.75, 0.1)); + // Step fired (step 1), commands emitted immediately + assert!(output.shared_state.active_patterns.iter().any(|p| p.step_index == 2)); + + // With 500ms lookahead = 1 beat lookahead + // At beat 1.0, future_beat = 2.0 + // beat_int = 4, future_beat_int = 8 + // Should pre-evaluate steps at beat_ints 5, 6, 7, 8 + let _output = state.tick(tick_with_lookahead(1.0, 0.5)); + + // Check that scheduled_steps contains the pre-evaluated steps + // At beat 1.0, step_index is 3 (step 2 just fired) + // Lookahead will schedule steps: 3@5, 0@6, 1@7, 2@8 + assert!(state.scheduled_steps.contains_key(&(0, 0, 3, 5))); + assert!(state.scheduled_steps.contains_key(&(0, 0, 0, 6))); + assert!(state.scheduled_steps.contains_key(&(0, 0, 1, 7))); + assert!(state.scheduled_steps.contains_key(&(0, 0, 2, 8))); + + // Pending commands should exist for future steps + assert!(!state.pending_commands.is_empty()); + } + + #[test] + fn test_lookahead_commands_emit_at_correct_time() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: pattern_with_sound(4), + }], + 0.0, + )); + + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 0.5, + )); + + // Pre-evaluate with 1 beat lookahead + state.tick(tick_with_lookahead(0.75, 0.5)); + + // Commands for step 2 (at beat 1.0) should be in pending_commands + let pending_for_step2: Vec<_> = state + .pending_commands + .iter() + .filter(|p| (p.target_beat - 1.0).abs() < 0.01) + .collect(); + assert!(!pending_for_step2.is_empty()); + + // Advance to beat 1.0 - pending commands should be emitted + let output = state.tick(tick_with_lookahead(1.0, 0.5)); + // The commands should have been moved to buf_audio_commands + assert!(!output.audio_commands.is_empty()); + } + + #[test] + fn test_lookahead_tempo_change_reschedules() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: pattern_with_sound(4), + }], + 0.0, + )); + + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 0.5, + )); + + // Pre-evaluate with lookahead + state.tick(tick_with_lookahead(0.75, 0.5)); + + // Record original event times + let original_times: Vec<_> = state + .pending_commands + .iter() + .map(|p| p.cmd.time) + .collect(); + assert!(!original_times.is_empty()); + + // Simulate tempo change by ticking with different tempo + let mut input = tick_with_lookahead(0.8, 0.5); + input.tempo = 140.0; // Changed from 120 + state.tick(input); + + // Event times should have been rescheduled + // (The exact times depend on the reschedule algorithm) + // At minimum, scheduled_steps should have updated tempo + for step in state.scheduled_steps.values() { + // Tempo should be updated for all scheduled steps + assert!((step.tempo_at_schedule - 140.0).abs() < 0.01); + } + } }