use arc_swap::ArcSwap; use crossbeam_channel::{bounded, Receiver, Sender, TrySendError}; use std::collections::HashMap; use std::sync::atomic::AtomicI64; use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::Duration; use thread_priority::{set_current_thread_priority, ThreadPriority}; use super::LinkState; use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables}; use crate::model::{LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS}; use crate::state::LiveKeyState; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub struct PatternId { pub bank: usize, pub pattern: usize, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum PatternChange { Start { bank: usize, pattern: usize }, Stop { bank: usize, pattern: usize }, } impl PatternChange { pub fn pattern_id(&self) -> PatternId { match self { PatternChange::Start { bank, pattern } => PatternId { bank: *bank, pattern: *pattern, }, PatternChange::Stop { bank, pattern } => PatternId { bank: *bank, pattern: *pattern, }, } } } pub enum AudioCommand { Evaluate(String), Hush, Panic, LoadSamples(Vec), #[allow(dead_code)] ResetEngine, } pub enum SeqCommand { PatternUpdate { bank: usize, pattern: usize, data: PatternSnapshot, }, PatternStart { bank: usize, pattern: usize, quantization: LaunchQuantization, sync_mode: SyncMode, }, PatternStop { bank: usize, pattern: usize, quantization: LaunchQuantization, }, StopAll, Shutdown, } #[derive(Clone)] pub struct PatternSnapshot { pub speed: crate::model::PatternSpeed, pub length: usize, pub steps: Vec, pub quantization: LaunchQuantization, pub sync_mode: SyncMode, } #[derive(Clone)] pub struct StepSnapshot { pub active: bool, pub script: String, pub source: Option, } #[derive(Clone, Copy, Default, Debug)] pub struct ActivePatternState { pub bank: usize, pub pattern: usize, pub step_index: usize, pub iter: usize, } #[derive(Clone, Default)] pub struct SharedSequencerState { pub active_patterns: Vec, pub step_traces: HashMap<(usize, usize, usize), ExecutionTrace>, pub event_count: usize, pub dropped_events: usize, } pub struct SequencerSnapshot { pub active_patterns: Vec, pub step_traces: HashMap<(usize, usize, usize), ExecutionTrace>, pub event_count: usize, pub dropped_events: usize, } impl SequencerSnapshot { pub fn is_playing(&self, bank: usize, pattern: usize) -> bool { self.active_patterns .iter() .any(|p| p.bank == bank && p.pattern == pattern) } pub fn get_step(&self, bank: usize, pattern: usize) -> Option { self.active_patterns .iter() .find(|p| p.bank == bank && p.pattern == pattern) .map(|p| p.step_index) } pub fn get_iter(&self, bank: usize, pattern: usize) -> Option { self.active_patterns .iter() .find(|p| p.bank == bank && p.pattern == pattern) .map(|p| p.iter) } pub fn get_trace(&self, bank: usize, pattern: usize, step: usize) -> Option<&ExecutionTrace> { self.step_traces.get(&(bank, pattern, step)) } } pub struct SequencerHandle { pub cmd_tx: Sender, pub audio_tx: Arc>>, shared_state: Arc>, thread: JoinHandle<()>, } impl SequencerHandle { pub fn snapshot(&self) -> SequencerSnapshot { let state = self.shared_state.load(); SequencerSnapshot { active_patterns: state.active_patterns.clone(), step_traces: state.step_traces.clone(), event_count: state.event_count, dropped_events: state.dropped_events, } } pub fn swap_audio_channel(&self) -> Receiver { let (new_tx, new_rx) = bounded::(256); self.audio_tx.store(Arc::new(new_tx)); new_rx } pub fn shutdown(self) { let _ = self.cmd_tx.send(SeqCommand::Shutdown); let _ = self.thread.join(); } } #[derive(Clone, Copy, Default)] struct ActivePattern { bank: usize, pattern: usize, step_index: usize, iter: usize, } #[derive(Clone, Copy)] struct PendingPattern { id: PatternId, quantization: LaunchQuantization, sync_mode: SyncMode, } struct AudioState { prev_beat: f64, active_patterns: HashMap, pending_starts: Vec, pending_stops: Vec, } impl AudioState { fn new() -> Self { Self { prev_beat: -1.0, active_patterns: HashMap::new(), pending_starts: Vec::new(), pending_stops: Vec::new(), } } } #[allow(clippy::too_many_arguments)] pub fn spawn_sequencer( link: Arc, playing: Arc, variables: Variables, dict: Dictionary, rng: Rng, quantum: f64, live_keys: Arc, nudge_us: Arc, ) -> (SequencerHandle, Receiver) { let (cmd_tx, cmd_rx) = bounded::(64); let (audio_tx, audio_rx) = bounded::(256); let audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx)); let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default())); let shared_state_clone = Arc::clone(&shared_state); let audio_tx_for_thread = Arc::clone(&audio_tx); let thread = thread::Builder::new() .name("sequencer".into()) .spawn(move || { sequencer_loop( cmd_rx, audio_tx_for_thread, link, playing, variables, dict, rng, quantum, shared_state_clone, live_keys, nudge_us, ); }) .expect("Failed to spawn sequencer thread"); let handle = SequencerHandle { cmd_tx, audio_tx, shared_state, thread, }; (handle, audio_rx) } struct PatternCache { patterns: [[Option; MAX_PATTERNS]; MAX_BANKS], } impl PatternCache { fn new() -> Self { Self { patterns: std::array::from_fn(|_| std::array::from_fn(|_| None)), } } fn get(&self, bank: usize, pattern: usize) -> Option<&PatternSnapshot> { self.patterns .get(bank) .and_then(|b| b.get(pattern)) .and_then(|p| p.as_ref()) } fn set(&mut self, bank: usize, pattern: usize, data: PatternSnapshot) { if bank < MAX_BANKS && pattern < MAX_PATTERNS { self.patterns[bank][pattern] = Some(data); } } } impl PatternSnapshot { fn resolve_source(&self, index: usize) -> usize { let mut current = index; for _ in 0..self.steps.len() { if let Some(step) = self.steps.get(current) { if let Some(source) = step.source { current = source; } else { return current; } } else { return index; } } index } fn resolve_script(&self, index: usize) -> Option<&str> { let source_idx = self.resolve_source(index); self.steps.get(source_idx).map(|s| s.script.as_str()) } } fn check_quantization_boundary( quantization: LaunchQuantization, beat: f64, prev_beat: f64, quantum: f64, ) -> bool { if prev_beat < 0.0 { return false; } match quantization { LaunchQuantization::Immediate => true, LaunchQuantization::Beat => beat.floor() as i64 != prev_beat.floor() as i64, LaunchQuantization::Bar => { let bar = (beat / quantum).floor() as i64; let prev_bar = (prev_beat / quantum).floor() as i64; bar != prev_bar } LaunchQuantization::Bars2 => { let bars2 = (beat / (quantum * 2.0)).floor() as i64; let prev_bars2 = (prev_beat / (quantum * 2.0)).floor() as i64; bars2 != prev_bars2 } LaunchQuantization::Bars4 => { let bars4 = (beat / (quantum * 4.0)).floor() as i64; let prev_bars4 = (prev_beat / (quantum * 4.0)).floor() as i64; bars4 != prev_bars4 } LaunchQuantization::Bars8 => { let bars8 = (beat / (quantum * 8.0)).floor() as i64; let prev_bars8 = (prev_beat / (quantum * 8.0)).floor() as i64; bars8 != prev_bars8 } } } type StepKey = (usize, usize, usize); struct RunsCounter { counts: HashMap, } impl RunsCounter { fn new() -> Self { Self { counts: HashMap::new(), } } fn get_and_increment(&mut self, bank: usize, pattern: usize, step: usize) -> usize { let key = (bank, pattern, step); let count = self.counts.entry(key).or_insert(0); let current = *count; *count += 1; current } } pub(crate) struct TickInput { pub commands: Vec, pub playing: bool, pub beat: f64, pub tempo: f64, pub quantum: f64, pub fill: bool, pub nudge_secs: f64, } pub(crate) struct TickOutput { pub audio_commands: Vec, pub new_tempo: Option, pub shared_state: SharedSequencerState, } struct StepResult { audio_commands: Vec, completed_iterations: Vec, any_step_fired: bool, } struct VariableReads { new_tempo: Option, chain_transitions: Vec<(PatternId, PatternId)>, } fn parse_chain_target(s: &str) -> Option { let (bank, pattern) = s.split_once(':')?; Some(PatternId { bank: bank.parse().ok()?, pattern: pattern.parse().ok()?, }) } pub(crate) struct SequencerState { audio_state: AudioState, pattern_cache: PatternCache, runs_counter: RunsCounter, step_traces: HashMap<(usize, usize, usize), ExecutionTrace>, event_count: usize, dropped_events: usize, script_engine: ScriptEngine, variables: Variables, } impl SequencerState { pub fn new( variables: Variables, dict: Dictionary, rng: Rng, ) -> Self { let script_engine = ScriptEngine::new(Arc::clone(&variables), dict, rng); Self { audio_state: AudioState::new(), pattern_cache: PatternCache::new(), runs_counter: RunsCounter::new(), step_traces: HashMap::new(), event_count: 0, dropped_events: 0, script_engine, variables, } } fn process_commands(&mut self, commands: Vec) { for cmd in commands { match cmd { SeqCommand::PatternUpdate { bank, pattern, data, } => { self.pattern_cache.set(bank, pattern, data); } SeqCommand::PatternStart { bank, pattern, quantization, sync_mode, } => { let id = PatternId { bank, pattern }; self.audio_state.pending_stops.retain(|p| p.id != id); if !self.audio_state.pending_starts.iter().any(|p| p.id == id) { self.audio_state.pending_starts.push(PendingPattern { id, quantization, sync_mode, }); } } SeqCommand::PatternStop { bank, pattern, quantization, } => { let id = PatternId { bank, pattern }; self.audio_state.pending_starts.retain(|p| p.id != id); if !self.audio_state.pending_stops.iter().any(|p| p.id == id) { self.audio_state.pending_stops.push(PendingPattern { id, quantization, sync_mode: SyncMode::Reset, }); } } SeqCommand::StopAll => { self.audio_state.active_patterns.clear(); self.audio_state.pending_starts.clear(); self.audio_state.pending_stops.clear(); self.step_traces.clear(); self.runs_counter.counts.clear(); } SeqCommand::Shutdown => {} } } } pub fn tick(&mut self, input: TickInput) -> TickOutput { self.process_commands(input.commands); if !input.playing { return self.tick_paused(); } let beat = input.beat; let prev_beat = self.audio_state.prev_beat; let activated = self.activate_pending(beat, prev_beat, input.quantum); self.audio_state.pending_starts.retain(|p| !activated.contains(&p.id)); let stopped = self.deactivate_pending(beat, prev_beat, input.quantum); self.audio_state.pending_stops.retain(|p| !stopped.contains(&p.id)); let steps = self.execute_steps(beat, prev_beat, input.tempo, input.quantum, input.fill, input.nudge_secs); let vars = self.read_variables(&steps.completed_iterations, &stopped, steps.any_step_fired); self.apply_chain_transitions(vars.chain_transitions); self.audio_state.prev_beat = beat; TickOutput { audio_commands: steps.audio_commands, new_tempo: vars.new_tempo, shared_state: self.build_shared_state(), } } fn tick_paused(&mut self) -> TickOutput { for pending in self.audio_state.pending_stops.drain(..) { self.audio_state.active_patterns.remove(&pending.id); self.step_traces.retain(|&(bank, pattern, _), _| { bank != pending.id.bank || pattern != pending.id.pattern }); } self.audio_state.pending_starts.clear(); TickOutput { audio_commands: Vec::new(), new_tempo: None, shared_state: self.build_shared_state(), } } fn activate_pending(&mut self, beat: f64, prev_beat: f64, quantum: f64) -> Vec { let mut activated = Vec::new(); for pending in &self.audio_state.pending_starts { if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) { let start_step = match pending.sync_mode { SyncMode::Reset => 0, SyncMode::PhaseLock => { if let Some(pat) = self.pattern_cache.get(pending.id.bank, pending.id.pattern) { let speed_mult = pat.speed.multiplier(); ((beat * 4.0 * speed_mult) as usize) % pat.length } else { 0 } } }; self.audio_state.active_patterns.insert( pending.id, ActivePattern { bank: pending.id.bank, pattern: pending.id.pattern, step_index: start_step, iter: 0, }, ); activated.push(pending.id); } } activated } fn deactivate_pending(&mut self, beat: f64, prev_beat: f64, quantum: f64) -> Vec { let mut stopped = Vec::new(); for pending in &self.audio_state.pending_stops { if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) { self.audio_state.active_patterns.remove(&pending.id); self.step_traces.retain(|&(bank, pattern, _), _| { bank != pending.id.bank || pattern != pending.id.pattern }); stopped.push(pending.id); } } stopped } fn execute_steps( &mut self, beat: f64, prev_beat: f64, tempo: f64, quantum: f64, fill: bool, nudge_secs: f64, ) -> StepResult { let mut result = StepResult { audio_commands: Vec::new(), completed_iterations: Vec::new(), any_step_fired: false, }; let speed_overrides: HashMap<(usize, usize), f64> = { let vars = self.variables.lock().unwrap(); self.audio_state .active_patterns .keys() .filter_map(|id| { let key = format!("__speed_{}_{}__", id.bank, id.pattern); vars.get(&key) .and_then(|v| v.as_float().ok()) .map(|v| ((id.bank, id.pattern), v)) }) .collect() }; for (_id, active) in self.audio_state.active_patterns.iter_mut() { let Some(pattern) = self.pattern_cache.get(active.bank, active.pattern) else { continue; }; let speed_mult = speed_overrides .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; if beat_int != prev_beat_int && prev_beat >= 0.0 { result.any_step_fired = true; let step_idx = active.step_index % pattern.length; 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 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, }; 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) { self.step_traces.insert( (active.bank, active.pattern, source_idx), std::mem::take(&mut trace), ); for cmd in cmds { self.event_count += 1; result.audio_commands.push(cmd); } } } } } let next_step = active.step_index + 1; if next_step >= pattern.length { active.iter += 1; result.completed_iterations.push(PatternId { bank: active.bank, pattern: active.pattern, }); } active.step_index = next_step % pattern.length; } } result } fn read_variables( &self, completed: &[PatternId], stopped: &[PatternId], any_step_fired: bool, ) -> VariableReads { let needs_access = !completed.is_empty() || !stopped.is_empty() || any_step_fired; if !needs_access { return VariableReads { new_tempo: None, chain_transitions: Vec::new(), }; } let mut vars = self.variables.lock().unwrap(); let new_tempo = vars.remove("__tempo__").and_then(|v| v.as_float().ok()); let mut chain_transitions = Vec::new(); for id in completed { let chain_key = format!("__chain_{}_{}__", id.bank, id.pattern); if let Some(Value::Str(s, _)) = vars.get(&chain_key) { if let Some(target) = parse_chain_target(s) { chain_transitions.push((*id, target)); } } vars.remove(&chain_key); } for id in stopped { let chain_key = format!("__chain_{}_{}__", id.bank, id.pattern); vars.remove(&chain_key); } VariableReads { new_tempo, chain_transitions, } } fn apply_chain_transitions(&mut self, transitions: Vec<(PatternId, PatternId)>) { for (source, target) in transitions { if !self.audio_state.pending_stops.iter().any(|p| p.id == source) { self.audio_state.pending_stops.push(PendingPattern { id: source, quantization: LaunchQuantization::Bar, sync_mode: SyncMode::Reset, }); } if !self.audio_state.pending_starts.iter().any(|p| p.id == target) { let (quant, sync) = self .pattern_cache .get(target.bank, target.pattern) .map(|p| (p.quantization, p.sync_mode)) .unwrap_or((LaunchQuantization::Bar, SyncMode::Reset)); self.audio_state.pending_starts.push(PendingPattern { id: target, quantization: quant, sync_mode: sync, }); } } } fn build_shared_state(&self) -> SharedSequencerState { SharedSequencerState { active_patterns: self .audio_state .active_patterns .values() .map(|a| ActivePatternState { bank: a.bank, pattern: a.pattern, step_index: a.step_index, iter: a.iter, }) .collect(), step_traces: self.step_traces.clone(), event_count: self.event_count, dropped_events: self.dropped_events, } } } #[allow(clippy::too_many_arguments)] fn sequencer_loop( cmd_rx: Receiver, audio_tx: Arc>>, link: Arc, playing: Arc, variables: Variables, dict: Dictionary, rng: Rng, quantum: f64, shared_state: Arc>, live_keys: Arc, nudge_us: Arc, ) { use std::sync::atomic::Ordering; let _ = set_current_thread_priority(ThreadPriority::Max); let mut seq_state = SequencerState::new(variables, dict, rng); loop { let mut commands = Vec::new(); while let Ok(cmd) = cmd_rx.try_recv() { if matches!(cmd, SeqCommand::Shutdown) { return; } commands.push(cmd); } let state = link.capture_app_state(); let time = link.clock_micros(); let beat = state.beat_at_time(time, quantum); let tempo = state.tempo(); let input = TickInput { commands, playing: playing.load(Ordering::Relaxed), beat, tempo, quantum, fill: live_keys.fill(), nudge_secs: nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0, }; let output = seq_state.tick(input); for cmd in output.audio_commands { match audio_tx.load().try_send(AudioCommand::Evaluate(cmd)) { Ok(()) => {} Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => { // Lags one tick in shared state: build_shared_state() already ran seq_state.dropped_events += 1; } } } if let Some(t) = output.new_tempo { link.set_tempo(t); } shared_state.store(Arc::new(output.shared_state)); thread::sleep(Duration::from_micros(200)); } } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; fn make_state() -> SequencerState { let variables: Variables = Arc::new(Mutex::new(HashMap::new())); let dict: Dictionary = Arc::new(Mutex::new(HashMap::new())); let rng: Rng = Arc::new(Mutex::new( ::seed_from_u64(0), )); SequencerState::new(variables, dict, rng) } fn simple_pattern(length: usize) -> PatternSnapshot { PatternSnapshot { speed: Default::default(), length, steps: (0..length) .map(|_| StepSnapshot { active: true, script: "test".into(), source: None, }) .collect(), quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, } } fn pid(bank: usize, pattern: usize) -> PatternId { PatternId { bank, pattern } } fn tick_at(beat: f64, playing: bool) -> TickInput { TickInput { commands: Vec::new(), playing, beat, tempo: 120.0, quantum: 4.0, fill: false, nudge_secs: 0.0, } } fn tick_with(commands: Vec, beat: f64) -> TickInput { TickInput { commands, playing: true, beat, tempo: 120.0, quantum: 4.0, fill: false, nudge_secs: 0.0, } } #[test] fn test_stop_all_clears_everything() { let mut state = make_state(); state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(4), }], 0.0, )); let output = state.tick(tick_with( vec![ SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(4), }, SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }, ], 1.0, )); assert!(!output.shared_state.active_patterns.is_empty()); let output = state.tick(tick_with(vec![SeqCommand::StopAll], 1.5)); assert!(output.shared_state.active_patterns.is_empty()); } #[test] fn test_stops_apply_while_paused() { let mut state = make_state(); state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(4), }], 0.0, )); state.tick(tick_with( vec![SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }], 1.0, )); assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); let output = state.tick(TickInput { commands: vec![SeqCommand::PatternStop { bank: 0, pattern: 0, quantization: LaunchQuantization::Bar, }], playing: false, ..tick_at(1.5, false) }); assert!(output.shared_state.active_patterns.is_empty()); assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); } #[test] fn test_chain_requires_active_source() { let mut state = make_state(); // Set up: pattern 0 (length 1) chains to pattern 1 state.tick(tick_with( vec![ SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(1), }, SeqCommand::PatternUpdate { bank: 0, pattern: 1, data: simple_pattern(4), }, ], 0.0, )); // Start pattern 0 state.tick(tick_with( vec![SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }], 0.5, )); // Set chain variable { let mut vars = state.variables.lock().unwrap(); vars.insert( "__chain_0_0__".to_string(), Value::Str("0:1".to_string(), None), ); } // Pattern 0 completes iteration AND gets stopped immediately in the same tick. // The stop removes it from active_patterns before chain evaluation, // so the chain guard (active_patterns.contains_key) blocks the transition. let output = state.tick(tick_with( vec![SeqCommand::PatternStop { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, }], 1.0, )); assert!(output.shared_state.active_patterns.is_empty()); assert!(!state.audio_state.pending_starts.iter().any(|p| p.id == pid(0, 1))); } #[test] fn test_pattern_start_cancels_pending_stop() { let mut state = make_state(); state.tick(tick_with( vec![ SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(4), }, SeqCommand::PatternStop { bank: 0, pattern: 0, quantization: LaunchQuantization::Bar, }, ], 0.0, )); assert!(!state.audio_state.pending_stops.is_empty()); state.tick(tick_with( vec![SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }], 0.5, )); assert!(state.audio_state.pending_stops.is_empty()); } #[test] fn test_quantization_boundaries() { assert!(check_quantization_boundary( LaunchQuantization::Immediate, 1.5, 1.0, 4.0 )); assert!(check_quantization_boundary( LaunchQuantization::Beat, 2.0, 1.9, 4.0 )); assert!(!check_quantization_boundary( LaunchQuantization::Beat, 1.5, 1.2, 4.0 )); assert!(check_quantization_boundary( LaunchQuantization::Bar, 4.0, 3.9, 4.0 )); assert!(!check_quantization_boundary( LaunchQuantization::Bar, 3.5, 3.2, 4.0 )); assert!(!check_quantization_boundary( LaunchQuantization::Immediate, 1.0, -1.0, 4.0 )); } #[test] fn test_pattern_lifecycle() { let mut state = make_state(); state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(2), }], 0.0, )); state.tick(tick_with( vec![SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }], 0.5, )); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 1); assert_eq!(ap.iter, 0); state.tick(tick_at(0.75, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 0); assert_eq!(ap.iter, 1); state.tick(tick_at(1.0, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 1); assert_eq!(ap.iter, 1); } #[test] fn test_speed_multiplier_step_advance() { let mut state = make_state(); let mut pat = simple_pattern(8); pat.speed = crate::model::PatternSpeed::DOUBLE; state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: pat }], 0.0, )); state.tick(tick_with( vec![SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }], 0.5, )); state.tick(tick_at(0.625, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 2); } #[test] fn test_start_and_stop_same_pattern_same_tick() { let mut state = make_state(); state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(4), }], 0.0, )); // Start then stop in same batch: stop cancels the start state.tick(tick_with( vec![ SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }, SeqCommand::PatternStop { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, }, ], 1.0, )); assert!(state.audio_state.pending_starts.is_empty()); assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); } #[test] fn test_stop_during_iteration_blocks_chain() { let mut state = make_state(); state.tick(tick_with( vec![ SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(1), }, SeqCommand::PatternUpdate { bank: 0, pattern: 1, data: simple_pattern(4), }, SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }, ], 0.0, )); // Pattern 0 is now pending (will activate next tick when prev_beat >= 0) // Advance so it activates state.tick(tick_at(0.5, true)); assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); // Set chain: 0:0 -> 0:1 { let mut vars = state.variables.lock().unwrap(); vars.insert( "__chain_0_0__".to_string(), Value::Str("0:1".to_string(), None), ); } // Pattern 0 (length 1) completes iteration at beat=1.0 AND // an immediate stop removes it from active_patterns first. // Chain guard should block transition to pattern 1. state.tick(tick_with( vec![SeqCommand::PatternStop { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, }], 1.0, )); assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 1))); assert!(!state.audio_state.pending_starts.iter().any(|p| p.id == pid(0, 1))); } #[test] fn test_multiple_patterns_independent_quantization() { let mut state = make_state(); state.tick(tick_with( vec![ SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(4), }, SeqCommand::PatternUpdate { bank: 0, pattern: 1, data: simple_pattern(4), }, SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Bar, sync_mode: SyncMode::Reset, }, SeqCommand::PatternStart { bank: 0, pattern: 1, quantization: LaunchQuantization::Beat, sync_mode: SyncMode::Reset, }, ], 0.0, )); // Beat boundary at 1.0: Beat-quantized pattern activates, Bar does not state.tick(tick_at(1.0, true)); assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); assert!(state.audio_state.active_patterns.contains_key(&pid(0, 1))); // Bar boundary at 4.0: Bar-quantized pattern also activates state.tick(tick_at(2.0, true)); state.tick(tick_at(3.0, true)); state.tick(tick_at(4.0, true)); assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); assert!(state.audio_state.active_patterns.contains_key(&pid(0, 1))); } #[test] fn test_pattern_update_while_running() { let mut state = make_state(); state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(4), }], 0.0, )); state.tick(tick_with( vec![SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }], 0.5, )); // Advance to step_index=3 state.tick(tick_at(0.75, true)); state.tick(tick_at(1.0, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 3); // Update pattern to length 2 while running — step_index wraps via modulo // beat=1.25: beat_int=5, prev=4, step fires. step_index=3%2=1 fires, advances to (3+1)%2=0 state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(2), }], 1.25, )); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 0); // beat=1.5: beat_int=6, prev=5, step fires. step_index=0 fires, advances to 1 state.tick(tick_at(1.5, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 1); } #[test] fn test_start_while_paused_is_discarded() { let mut state = make_state(); state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(4), }], 0.0, )); // Start while paused: pending_starts gets cleared state.tick(TickInput { commands: vec![SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }], ..tick_at(1.0, false) }); assert!(state.audio_state.pending_starts.is_empty()); // Resume playing — pattern should NOT be active state.tick(tick_at(2.0, true)); assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); } #[test] fn test_resuming_after_pause_preserves_active() { let mut state = make_state(); state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(4), }], 0.0, )); state.tick(tick_with( vec![SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }], 1.0, )); assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); // Pause (no stop commands) state.tick(tick_at(2.0, false)); assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); // Resume state.tick(tick_at(3.0, true)); assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); } #[test] fn test_duplicate_start_commands_ignored() { let mut state = make_state(); state.tick(tick_with( vec![ SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(4), }, SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Bar, sync_mode: SyncMode::Reset, }, SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Bar, sync_mode: SyncMode::Reset, }, ], 0.0, )); let pending_count = state.audio_state.pending_starts .iter() .filter(|p| p.id == pid(0, 0)) .count(); assert_eq!(pending_count, 1); } #[test] fn test_tempo_applies_without_iteration_complete() { let mut state = make_state(); // Pattern of length 16 — won't complete iteration for many ticks state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: simple_pattern(16), }], 0.0, )); state.tick(tick_with( vec![SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }], 0.5, )); // Script fires at beat 1.0 (step 0). Set __tempo__ as if the script did. { let mut vars = state.variables.lock().unwrap(); vars.insert("__tempo__".to_string(), Value::Float(140.0, None)); } let output = state.tick(tick_at(1.0, true)); assert_eq!(output.new_tempo, Some(140.0)); } }