lookahead
This commit is contained in:
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Mute/solo for patterns: stage with `m`/`x`, commit with `c`. Solo mutes all other patterns. Clear with `M`/`X`.
|
- 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
|
## [0.0.4] - 2026-02-02
|
||||||
|
|
||||||
|
|||||||
@@ -436,6 +436,25 @@ fn check_quantization_boundary(
|
|||||||
|
|
||||||
type StepKey = (usize, usize, usize);
|
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 {
|
struct RunsCounter {
|
||||||
counts: HashMap<StepKey, usize>,
|
counts: HashMap<StepKey, usize>,
|
||||||
}
|
}
|
||||||
@@ -557,6 +576,10 @@ pub(crate) struct SequencerState {
|
|||||||
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
|
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)>,
|
||||||
|
// Lookahead scheduling state
|
||||||
|
scheduled_steps: HashMap<(usize, usize, usize, i64), ScheduledStep>,
|
||||||
|
pending_commands: Vec<PendingCommand>,
|
||||||
|
last_tempo: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SequencerState {
|
impl SequencerState {
|
||||||
@@ -583,6 +606,9 @@ impl SequencerState {
|
|||||||
active_notes: HashMap::new(),
|
active_notes: HashMap::new(),
|
||||||
muted: std::collections::HashSet::new(),
|
muted: std::collections::HashSet::new(),
|
||||||
soloed: 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();
|
Arc::make_mut(&mut self.step_traces).clear();
|
||||||
self.runs_counter.counts.clear();
|
self.runs_counter.counts.clear();
|
||||||
self.audio_state.flush_midi_notes = true;
|
self.audio_state.flush_midi_notes = true;
|
||||||
|
self.scheduled_steps.clear();
|
||||||
|
self.pending_commands.clear();
|
||||||
}
|
}
|
||||||
SeqCommand::Shutdown => {}
|
SeqCommand::Shutdown => {}
|
||||||
}
|
}
|
||||||
@@ -785,12 +813,65 @@ impl SequencerState {
|
|||||||
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
||||||
bank != pending.id.bank || pattern != pending.id.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.push(pending.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stopped
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn execute_steps(
|
fn execute_steps(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -813,6 +894,12 @@ impl SequencerState {
|
|||||||
any_step_fired: false,
|
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();
|
self.speed_overrides.clear();
|
||||||
{
|
{
|
||||||
let vars = self.variables.lock().unwrap();
|
let vars = self.variables.lock().unwrap();
|
||||||
@@ -826,7 +913,13 @@ impl SequencerState {
|
|||||||
|
|
||||||
let muted_snapshot = self.muted.clone();
|
let muted_snapshot = self.muted.clone();
|
||||||
let soloed_snapshot = self.soloed.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() {
|
for (_id, active) in self.audio_state.active_patterns.iter_mut() {
|
||||||
let Some(pattern) = self.pattern_cache.get(active.bank, active.pattern) else {
|
let Some(pattern) = self.pattern_cache.get(active.bank, active.pattern) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -837,75 +930,86 @@ 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 beat_int = (beat * 4.0 * speed_mult).floor() as i64;
|
||||||
let prev_beat_int = (prev_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;
|
result.any_step_fired = true;
|
||||||
let step_idx = active.step_index % pattern.length;
|
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) {
|
// Skip evaluation if already done via lookahead
|
||||||
let resolved_script = pattern.resolve_script(step_idx);
|
if !self.scheduled_steps.contains_key(&sched_key) {
|
||||||
let has_script = resolved_script
|
if let Some(step) = pattern.steps.get(step_idx) {
|
||||||
.map(|s| !s.trim().is_empty())
|
let resolved_script = pattern.resolve_script(step_idx);
|
||||||
.unwrap_or(false);
|
let has_script = resolved_script
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
if step.active && has_script {
|
if step.active && has_script {
|
||||||
let key = (active.bank, active.pattern);
|
let pattern_key = (active.bank, active.pattern);
|
||||||
let is_muted = muted_snapshot.contains(&key)
|
let is_muted = muted_snapshot.contains(&pattern_key)
|
||||||
|| (!soloed_snapshot.is_empty() && !soloed_snapshot.contains(&key));
|
|| (!soloed_snapshot.is_empty()
|
||||||
|
&& !soloed_snapshot.contains(&pattern_key));
|
||||||
|
|
||||||
let source_idx = pattern.resolve_source(step_idx);
|
if !is_muted {
|
||||||
let runs = self.runs_counter.get_and_increment(
|
let source_idx = pattern.resolve_source(step_idx);
|
||||||
active.bank,
|
let runs = self.runs_counter.get_and_increment(
|
||||||
active.pattern,
|
active.bank,
|
||||||
source_idx,
|
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),
|
|
||||||
);
|
);
|
||||||
|
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 {
|
if let Some(script) = resolved_script {
|
||||||
let event_time = if lookahead_secs > 0.0 {
|
let mut trace = ExecutionTrace::default();
|
||||||
Some(engine_time + lookahead_secs)
|
if let Ok(cmds) = self
|
||||||
} else {
|
.script_engine
|
||||||
None
|
.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 {
|
let event_time = if lookahead_secs > 0.0 {
|
||||||
self.event_count += 1;
|
Some(engine_time + lookahead_secs)
|
||||||
self.buf_audio_commands.push(TimestampedCommand {
|
} else {
|
||||||
cmd,
|
None
|
||||||
time: event_time,
|
};
|
||||||
});
|
|
||||||
|
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;
|
let next_step = active.step_index + 1;
|
||||||
if next_step >= pattern.length {
|
if next_step >= pattern.length {
|
||||||
active.iter += 1;
|
active.iter += 1;
|
||||||
@@ -923,8 +1028,144 @@ impl SequencerState {
|
|||||||
}
|
}
|
||||||
active.step_index = next_step % pattern.length;
|
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
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1955,4 +2196,178 @@ mod tests {
|
|||||||
let output = state.tick(tick_at(1.0, true));
|
let output = state.tick(tick_at(1.0, true));
|
||||||
assert_eq!(output.new_tempo, Some(140.0));
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user