use arc_swap::ArcSwap; use crossbeam_channel::{bounded, unbounded, Receiver, Sender}; use parking_lot::Mutex; use rand::rngs::StdRng; use rand::SeedableRng; use std::collections::HashMap; #[cfg(feature = "desktop")] use std::sync::atomic::AtomicU32; use std::sync::atomic::{AtomicI64, AtomicU64}; use std::sync::Arc; use std::thread::{self, JoinHandle}; use super::dispatcher::{dispatcher_loop, MidiDispatch, TimedMidiCommand}; 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, }; 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 { cmd: String, time: Option }, Hush, Panic, LoadSamples(Vec), } #[derive(Clone, Debug)] pub enum MidiCommand { NoteOn { device: u8, channel: u8, note: u8, velocity: u8, }, NoteOff { device: u8, channel: u8, note: u8, }, CC { device: u8, channel: u8, cc: u8, value: u8, }, PitchBend { device: u8, channel: u8, value: u16, }, Pressure { device: u8, channel: u8, value: u8, }, ProgramChange { device: u8, channel: u8, program: u8, }, Clock { device: u8, }, Start { device: u8, }, Stop { device: u8, }, Continue { device: u8, }, } 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, }, SetMuteState { muted: std::collections::HashSet<(usize, usize)>, soloed: std::collections::HashSet<(usize, usize)>, }, StopAll, ResetScriptState, 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, } pub type StepTracesMap = HashMap<(usize, usize, usize), ExecutionTrace>; #[derive(Clone, Default)] pub struct SharedSequencerState { pub active_patterns: Vec, pub step_traces: Arc, pub event_count: usize, } pub struct SequencerSnapshot { pub active_patterns: Vec, step_traces: Arc, pub event_count: 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>>, pub midi_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: Arc::clone(&state.step_traces), event_count: state.event_count, } } pub fn swap_audio_channel(&self) -> Receiver { let (new_tx, new_rx) = unbounded::(); self.audio_tx.store(Arc::new(new_tx)); new_rx } pub fn swap_midi_channel(&self) -> Receiver { let (new_tx, new_rx) = bounded::(256); self.midi_tx.store(Arc::new(new_tx)); new_rx } pub fn shutdown(self) { let _ = self.cmd_tx.send(SeqCommand::Shutdown); if let Err(e) = self.thread.join() { eprintln!("Sequencer thread panicked: {e:?}"); } } } #[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, flush_midi_notes: bool, } impl AudioState { fn new() -> Self { Self { prev_beat: -1.0, active_patterns: HashMap::new(), pending_starts: Vec::new(), pending_stops: Vec::new(), flush_midi_notes: false, } } } pub struct SequencerConfig { pub audio_sample_pos: Arc, pub sample_rate: Arc, pub cc_access: Option>, pub variables: Variables, pub dict: Dictionary, #[cfg(feature = "desktop")] pub mouse_x: Arc, #[cfg(feature = "desktop")] pub mouse_y: Arc, #[cfg(feature = "desktop")] pub mouse_down: Arc, } pub fn spawn_sequencer( link: Arc, playing: Arc, quantum: f64, live_keys: Arc, nudge_us: Arc, config: SequencerConfig, ) -> ( SequencerHandle, Receiver, Receiver, ) { let (cmd_tx, cmd_rx) = bounded::(64); let (audio_tx, audio_rx) = unbounded::(); let (midi_tx, midi_rx) = bounded::(256); let audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx)); let midi_tx = Arc::new(ArcSwap::from_pointee(midi_tx)); // Dispatcher channel — MIDI only (unbounded to avoid blocking the scheduler) let (dispatch_tx, dispatch_rx) = unbounded::(); let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default())); let shared_state_clone = Arc::clone(&shared_state); let variables = config.variables; let dict = config.dict; #[cfg(feature = "desktop")] let mouse_x = config.mouse_x; #[cfg(feature = "desktop")] let mouse_y = config.mouse_y; #[cfg(feature = "desktop")] let mouse_down = config.mouse_down; // Spawn dispatcher thread (MIDI only — audio goes direct to doux) let dispatcher_link = Arc::clone(&link); let dispatcher_midi_tx = Arc::clone(&midi_tx); thread::Builder::new() .name("cagire-dispatcher".into()) .spawn(move || { dispatcher_loop(dispatch_rx, dispatcher_midi_tx, dispatcher_link); }) .expect("Failed to spawn dispatcher thread"); let sequencer_audio_tx = Arc::clone(&audio_tx); let thread = thread::Builder::new() .name("sequencer".into()) .spawn(move || { sequencer_loop( cmd_rx, dispatch_tx, sequencer_audio_tx, link, playing, quantum, shared_state_clone, live_keys, nudge_us, config.audio_sample_pos, config.sample_rate, config.cc_access, variables, dict, #[cfg(feature = "desktop")] mouse_x, #[cfg(feature = "desktop")] mouse_y, #[cfg(feature = "desktop")] mouse_down, ); }) .expect("Failed to spawn sequencer thread"); let handle = SequencerHandle { cmd_tx, audio_tx, midi_tx, shared_state, thread, }; (handle, audio_rx, midi_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 as usize; } 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 { match quantization { LaunchQuantization::Immediate => prev_beat >= 0.0, LaunchQuantization::Beat => StepTiming::NextBeat.crossed(prev_beat, beat, quantum), LaunchQuantization::Bar => StepTiming::NextBar.crossed(prev_beat, beat, quantum), LaunchQuantization::Bars2 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 2.0), LaunchQuantization::Bars4 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 4.0), LaunchQuantization::Bars8 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 8.0), } } 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 } fn clear_pattern(&mut self, bank: usize, pattern: usize) { self.counts .retain(|&(b, p, _), _| b != bank || p != pattern); } } 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, pub nudge_secs: f64, pub current_time_us: SyncTime, pub engine_time: f64, #[cfg(feature = "desktop")] pub mouse_x: f64, #[cfg(feature = "desktop")] pub mouse_y: f64, #[cfg(feature = "desktop")] pub mouse_down: f64, } pub struct TimestampedCommand { pub cmd: String, pub time: Option, } pub(crate) struct TickOutput { pub audio_commands: Vec, pub new_tempo: Option, pub shared_state: SharedSequencerState, pub flush_midi_notes: bool, } struct StepResult { 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()?, }) } struct KeyBuf { speed: String, chain: String, } impl KeyBuf { fn new() -> Self { Self { speed: String::with_capacity(24), chain: String::with_capacity(24), } } } fn format_speed_key(buf: &mut String, bank: usize, pattern: usize) -> &str { use std::fmt::Write; buf.clear(); write!(buf, "__speed_{bank}_{pattern}__").unwrap(); buf } fn format_chain_key(buf: &mut String, bank: usize, pattern: usize) -> &str { use std::fmt::Write; buf.clear(); write!(buf, "__chain_{bank}_{pattern}__").unwrap(); buf } pub(crate) struct SequencerState { audio_state: AudioState, pattern_cache: PatternCache, pending_updates: HashMap<(usize, usize), PatternSnapshot>, runs_counter: RunsCounter, step_traces: Arc, event_count: usize, script_engine: ScriptEngine, variables: Variables, dict: Dictionary, speed_overrides: HashMap<(usize, usize), f64>, key_buf: KeyBuf, buf_audio_commands: Vec, buf_activated: Vec, buf_stopped: Vec, buf_completed_iterations: Vec, cc_access: Option>, muted: std::collections::HashSet<(usize, usize)>, soloed: std::collections::HashSet<(usize, usize)>, } impl SequencerState { pub fn new( variables: Variables, dict: Dictionary, rng: Rng, cc_access: Option>, ) -> Self { let script_engine = ScriptEngine::new(Arc::clone(&variables), Arc::clone(&dict), rng); Self { audio_state: AudioState::new(), pattern_cache: PatternCache::new(), pending_updates: HashMap::new(), runs_counter: RunsCounter::new(), step_traces: Arc::new(HashMap::new()), event_count: 0, script_engine, variables, dict, speed_overrides: HashMap::with_capacity(MAX_PATTERNS), key_buf: KeyBuf::new(), buf_audio_commands: Vec::with_capacity(32), buf_activated: Vec::with_capacity(16), buf_stopped: Vec::with_capacity(16), buf_completed_iterations: Vec::with_capacity(16), cc_access, muted: std::collections::HashSet::new(), soloed: std::collections::HashSet::new(), } } fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool { let key = (bank, pattern); if self.muted.contains(&key) { return true; } if !self.soloed.is_empty() && !self.soloed.contains(&key) { return true; } false } fn process_commands(&mut self, commands: Vec) { for cmd in commands { match cmd { SeqCommand::PatternUpdate { bank, pattern, data, } => { let id = PatternId { bank, pattern }; let is_active = self.audio_state.active_patterns.contains_key(&id); let has_cache = self.pattern_cache.get(bank, pattern).is_some(); if is_active && has_cache { self.pending_updates.insert((bank, pattern), data); } else { 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::SetMuteState { muted, soloed } => { let newly_muted: Vec<(usize, usize)> = self .audio_state .active_patterns .keys() .filter(|id| { let key = (id.bank, id.pattern); let was_muted = self.is_effectively_muted(id.bank, id.pattern); let now_muted = muted.contains(&key) || (!soloed.is_empty() && !soloed.contains(&key)); !was_muted && now_muted }) .map(|id| (id.bank, id.pattern)) .collect(); self.muted = muted; self.soloed = soloed; if !newly_muted.is_empty() { self.audio_state.flush_midi_notes = true; } } SeqCommand::StopAll => { // Flush pending updates so cache stays current for future launches for ((bank, pattern), snapshot) in self.pending_updates.drain() { self.pattern_cache.set(bank, pattern, snapshot); } self.audio_state.active_patterns.clear(); self.audio_state.pending_starts.clear(); self.audio_state.pending_stops.clear(); Arc::make_mut(&mut self.step_traces).clear(); self.runs_counter.counts.clear(); self.audio_state.flush_midi_notes = true; } SeqCommand::ResetScriptState => { // Clear shared state instead of replacing - preserves sharing with app self.variables.store(Arc::new(HashMap::new())); self.dict.lock().clear(); self.speed_overrides.clear(); } SeqCommand::Shutdown => {} } } } pub fn tick(&mut self, input: TickInput) -> TickOutput { self.process_commands(input.commands); if !input.playing { return self.tick_paused(); } let frontier = self.audio_state.prev_beat; let lookahead_end = input.lookahead_end; self.activate_pending(lookahead_end, frontier, input.quantum); self.deactivate_pending(lookahead_end, frontier, input.quantum); let steps = self.execute_steps( input.beat, frontier, lookahead_end, input.tempo, input.quantum, input.fill, input.nudge_secs, input.current_time_us, input.engine_time, #[cfg(feature = "desktop")] input.mouse_x, #[cfg(feature = "desktop")] input.mouse_y, #[cfg(feature = "desktop")] input.mouse_down, ); 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 = lookahead_end; let flush = std::mem::take(&mut self.audio_state.flush_midi_notes); TickOutput { audio_commands: std::mem::take(&mut self.buf_audio_commands), new_tempo: vars.new_tempo, shared_state: self.build_shared_state(), flush_midi_notes: flush, } } fn tick_paused(&mut self) -> TickOutput { for pending in self.audio_state.pending_stops.drain(..) { self.audio_state.active_patterns.remove(&pending.id); Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| { bank != pending.id.bank || pattern != pending.id.pattern }); let key = (pending.id.bank, pending.id.pattern); if let Some(snapshot) = self.pending_updates.remove(&key) { self.pattern_cache.set(key.0, key.1, snapshot); } } self.audio_state.pending_starts.clear(); self.audio_state.prev_beat = -1.0; self.buf_audio_commands.clear(); let flush = std::mem::take(&mut self.audio_state.flush_midi_notes); TickOutput { audio_commands: std::mem::take(&mut self.buf_audio_commands), new_tempo: None, shared_state: self.build_shared_state(), flush_midi_notes: flush, } } fn activate_pending(&mut self, beat: f64, prev_beat: f64, quantum: f64) { self.buf_activated.clear(); 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.runs_counter .clear_pattern(pending.id.bank, pending.id.pattern); self.audio_state.active_patterns.insert( pending.id, ActivePattern { bank: pending.id.bank, pattern: pending.id.pattern, step_index: start_step, iter: 0, }, ); self.buf_activated.push(pending.id); } } let activated = &self.buf_activated; self.audio_state .pending_starts .retain(|p| !activated.contains(&p.id)); } fn deactivate_pending(&mut self, beat: f64, prev_beat: f64, quantum: f64) { self.buf_stopped.clear(); 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); Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| { bank != pending.id.bank || pattern != pending.id.pattern }); // Flush pending update so cache stays current for future launches let key = (pending.id.bank, pending.id.pattern); if let Some(snapshot) = self.pending_updates.remove(&key) { self.pattern_cache.set(key.0, key.1, snapshot); } self.buf_stopped.push(pending.id); } } let stopped = &self.buf_stopped; self.audio_state .pending_stops .retain(|p| !stopped.contains(&p.id)); } #[allow(clippy::too_many_arguments)] fn execute_steps( &mut self, beat: f64, frontier: f64, lookahead_end: f64, tempo: f64, quantum: f64, fill: bool, nudge_secs: f64, _current_time_us: SyncTime, engine_time: f64, #[cfg(feature = "desktop")] mouse_x: f64, #[cfg(feature = "desktop")] mouse_y: f64, #[cfg(feature = "desktop")] mouse_down: f64, ) -> StepResult { self.buf_audio_commands.clear(); self.buf_completed_iterations.clear(); let mut result = StepResult { any_step_fired: false, }; self.speed_overrides.clear(); { let vars = self.variables.load(); for id in self.audio_state.active_patterns.keys() { let key = format_speed_key(&mut self.key_buf.speed, id.bank, id.pattern); if let Some(v) = vars.get(key).and_then(|v: &Value| v.as_float().ok()) { self.speed_overrides.insert((id.bank, id.pattern), v); } } } 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 = self .speed_overrides .get(&(active.bank, active.pattern)) .copied() .unwrap_or_else(|| pattern.speed.multiplier()); 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; let beat_delta = step_beat - beat; let time_delta = if tempo > 0.0 { (beat_delta / tempo) * 60.0 } else { 0.0 }; let event_time = Some(engine_time + time_delta); 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 = self.muted.contains(&pattern_key) || (!self.soloed.is_empty() && !self.soloed.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 speed_key = format_speed_key(&mut self.key_buf.speed, active.bank, active.pattern); let chain_key = format_chain_key(&mut self.key_buf.chain, active.bank, active.pattern); let ctx = StepContext { step: step_idx, beat: step_beat, bank: active.bank, pattern: active.pattern, tempo, phase: step_beat % quantum, slot: 0, runs, iter: active.iter, speed: speed_mult, fill, nudge_secs, cc_access: self.cc_access.as_deref(), speed_key, chain_key, #[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), ); for cmd in cmds { self.event_count += 1; self.buf_audio_commands.push(TimestampedCommand { cmd, time: event_time, }); } } } } } } let next_step = active.step_index + 1; if next_step >= pattern.length { active.iter += 1; self.buf_completed_iterations.push(PatternId { bank: active.bank, pattern: active.pattern, }); } active.step_index = next_step % pattern.length; } } // Apply deferred updates for patterns that just completed an iteration for completed_id in &self.buf_completed_iterations { let key = (completed_id.bank, completed_id.pattern); if let Some(snapshot) = self.pending_updates.remove(&key) { self.pattern_cache.set(key.0, key.1, snapshot); } } result } fn read_variables(&self, completed: &[PatternId], any_step_fired: bool) -> VariableReads { let stopped = &self.buf_stopped; 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 vars = self.variables.load(); let new_tempo = vars .get("__tempo__") .and_then(|v: &Value| v.as_float().ok()); let mut chain_transitions = Vec::new(); let mut buf = String::with_capacity(24); for id in completed { let chain_key = format_chain_key(&mut buf, 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)); } } } // Remove consumed variables (tempo and chain keys) let mut needs_removal = new_tempo.is_some(); if !needs_removal { for id in completed.iter().chain(stopped.iter()) { if vars.contains_key(format_chain_key(&mut buf, id.bank, id.pattern)) { needs_removal = true; break; } } } if needs_removal { let mut new_vars = (**vars).clone(); new_vars.remove("__tempo__"); for id in completed { new_vars.remove(format_chain_key(&mut buf, id.bank, id.pattern)); } for id in stopped { new_vars.remove(format_chain_key(&mut buf, id.bank, id.pattern)); } self.variables.store(Arc::new(new_vars)); } 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: Arc::clone(&self.step_traces), event_count: self.event_count, } } } #[allow(clippy::too_many_arguments)] fn sequencer_loop( cmd_rx: Receiver, dispatch_tx: Sender, audio_tx: Arc>>, link: Arc, playing: Arc, quantum: f64, shared_state: Arc>, live_keys: Arc, nudge_us: Arc, audio_sample_pos: Arc, sample_rate: Arc, cc_access: Option>, variables: Variables, dict: Dictionary, #[cfg(feature = "desktop")] mouse_x: Arc, #[cfg(feature = "desktop")] mouse_y: Arc, #[cfg(feature = "desktop")] mouse_down: Arc, ) { use std::sync::atomic::Ordering; set_realtime_priority(); 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; } commands.push(cmd); } let state = link.capture_app_state(); let current_time_us = link.clock_micros() as SyncTime; 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 { audio_samples as f64 / sr } else { 0.0 }; let input = TickInput { commands, playing: playing.load(Ordering::Relaxed), beat, lookahead_end, tempo, quantum, fill: live_keys.fill(), nudge_secs: nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0, current_time_us, engine_time, #[cfg(feature = "desktop")] mouse_x: f32::from_bits(mouse_x.load(Ordering::Relaxed)) as f64, #[cfg(feature = "desktop")] mouse_y: f32::from_bits(mouse_y.load(Ordering::Relaxed)) as f64, #[cfg(feature = "desktop")] mouse_down: f32::from_bits(mouse_down.load(Ordering::Relaxed)) as f64, }; let output = seq_state.tick(input); // Route commands: audio direct to doux, MIDI through dispatcher for tsc in output.audio_commands { if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) { let _ = dispatch_tx.send(TimedMidiCommand { command: MidiDispatch::Send(midi_cmd.clone()), target_time_us: current_time_us, }); if let ( MidiCommand::NoteOn { device, channel, note, .. }, Some(dur_secs), ) = (&midi_cmd, dur) { let off_time_us = current_time_us + (dur_secs * 1_000_000.0) as SyncTime; let _ = dispatch_tx.send(TimedMidiCommand { command: MidiDispatch::Send(MidiCommand::NoteOff { device: *device, channel: *channel, note: *note, }), target_time_us: off_time_us, }); } } else { let _ = audio_tx.load().send(AudioCommand::Evaluate { cmd: tsc.cmd, time: tsc.time, }); } } if output.flush_midi_notes { let _ = dispatch_tx.send(TimedMidiCommand { command: MidiDispatch::FlushAll, target_time_us: current_time_us, }); } if let Some(t) = output.new_tempo { link.set_tempo(t); } shared_state.store(Arc::new(output.shared_state)); } } fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option)> { if !cmd.starts_with("/midi/") { return None; } let mut parts: [&str; 16] = [""; 16]; let mut count = 0; for part in cmd.split('/').filter(|s| !s.is_empty()) { if count < 16 { parts[count] = part; count += 1; } } if count < 2 { return None; } let parts = &parts[..count]; let find_param = |key: &str| -> Option<&str> { parts .iter() .position(|&s| s == key) .and_then(|i| parts.get(i + 1).copied()) }; let device: u8 = find_param("dev").and_then(|s| s.parse().ok()).unwrap_or(0); match parts[1] { "note" => { // /midi/note//vel//chan//dur//dev/ let note: u8 = parts.get(2)?.parse().ok()?; let vel: u8 = find_param("vel")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?; let dur: Option = find_param("dur").and_then(|s| s.parse().ok()); Some(( MidiCommand::NoteOn { device, channel: chan, note, velocity: vel, }, dur, )) } "cc" => { // /midi/cc///chan//dev/ let cc: u8 = parts.get(2)?.parse().ok()?; let val: u8 = parts.get(3)?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?; Some(( MidiCommand::CC { device, channel: chan, cc, value: val, }, None, )) } "bend" => { // /midi/bend//chan//dev/ let value: u16 = parts.get(2)?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?; Some(( MidiCommand::PitchBend { device, channel: chan, value, }, None, )) } "pressure" => { // /midi/pressure//chan//dev/ let value: u8 = parts.get(2)?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?; Some(( MidiCommand::Pressure { device, channel: chan, value, }, None, )) } "program" => { // /midi/program//chan//dev/ let program: u8 = parts.get(2)?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?; Some(( MidiCommand::ProgramChange { device, channel: chan, program, }, None, )) } "clock" => Some((MidiCommand::Clock { device }, None)), "start" => Some((MidiCommand::Start { device }, None)), "stop" => Some((MidiCommand::Stop { device }, None)), "continue" => Some((MidiCommand::Continue { device }, None)), _ => None, } } #[cfg(test)] mod tests { use super::*; use arc_swap::ArcSwap; use parking_lot::Mutex; fn make_state() -> SequencerState { 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( ::seed_from_u64(0), )); SequencerState::new(variables, dict, rng, None) } 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, lookahead_end: beat, tempo: 120.0, quantum: 4.0, fill: false, nudge_secs: 0.0, current_time_us: 0, engine_time: 0.0, #[cfg(feature = "desktop")] mouse_x: 0.5, #[cfg(feature = "desktop")] mouse_y: 0.5, #[cfg(feature = "desktop")] mouse_down: 0.0, } } fn tick_with(commands: Vec, beat: f64) -> TickInput { TickInput { commands, playing: true, beat, lookahead_end: beat, tempo: 120.0, quantum: 4.0, fill: false, nudge_secs: 0.0, current_time_us: 0, engine_time: 0.0, #[cfg(feature = "desktop")] mouse_x: 0.5, #[cfg(feature = "desktop")] mouse_y: 0.5, #[cfg(feature = "desktop")] mouse_down: 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.load()).clone(); vars.insert( "__chain_0_0__".to_string(), 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. // 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, )); // beat_int at 0.5 is 2, prev_beat_int at 0.0 is 0 // steps_to_fire = 2-0 = 2, firing steps 0 and 1, wrapping to 0 let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 0); assert_eq!(ap.iter, 1); // beat_int at 0.75 is 3, prev is 2, fires 1 step (step 0), advances to 1 state.tick(tick_at(0.75, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 1); assert_eq!(ap.iter, 1); // beat_int at 1.0 is 4, prev is 3, fires 1 step (step 1), wraps to 0 state.tick(tick_at(1.0, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 0); assert_eq!(ap.iter, 2); } #[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, )); // At 2x speed: beat_int at 0.5 is (0.5*4*2)=4, prev at 0.0 is 0 // Fires 4 steps (0,1,2,3), advancing to step 4 let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 4); // beat_int at 0.625 is (0.625*4*2)=5, prev is 4, fires 1 step state.tick(tick_at(0.625, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 5); } #[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.load()).clone(); vars.insert( "__chain_0_0__".to_string(), 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 // 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, )); // beat_int at 0.5 is 2, prev at 0.0 is 0, fires 2 steps (0,1), step_index=2 let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 2); // beat_int at 0.75 is 3, prev is 2, fires 1 step (2), step_index=3 state.tick(tick_at(0.75, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 3); // beat_int at 1.0 is 4, prev is 3, fires 1 step (3), wraps to step_index=0 state.tick(tick_at(1.0, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 0); // Update pattern to length 2 while running — deferred until iteration boundary // beat=1.25: update is deferred (pattern active), still length 4 // step_index=0 fires, advances to 1 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, 1); // still length 4 // Advance through remaining steps of original length-4 pattern state.tick(tick_at(1.5, true)); // step 1→2 state.tick(tick_at(1.75, true)); // step 2→3 state.tick(tick_at(2.0, true)); // step 3→wraps to 0, iteration completes, update applies // Now length=2 is applied. Next tick uses new length. // beat=2.25: step 0 fires, advances to 1 state.tick(tick_at(2.25, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 1); // beat=2.5: step 1 fires, wraps to 0 (length 2) state.tick(tick_at(2.5, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 0); } #[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.load()).clone(); 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)); assert_eq!(output.new_tempo, Some(140.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_continuous_step_firing() { let mut state = make_state(); state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: pattern_with_sound(16), }], 0.0, )); state.tick(tick_with( vec![SeqCommand::PatternStart { bank: 0, pattern: 0, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }], 0.5, )); // Tick through many bars, counting steps let mut step_count = 0; for i in 1..400 { let beat = 0.5 + (i as f64) * 0.25; let output = state.tick(tick_at(beat, true)); if !output.audio_commands.is_empty() { step_count += 1; } } // Should fire steps continuously without gaps assert!( step_count > 350, "Expected continuous steps, got {step_count}" ); } #[test] fn test_multiple_patterns_fire_together() { let mut state = make_state(); state.tick(tick_with( vec![ SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: pattern_with_sound(4), }, SeqCommand::PatternUpdate { bank: 0, pattern: 1, 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, }, SeqCommand::PatternStart { bank: 0, pattern: 1, quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, }, ], 0.5, )); // Both patterns should be active assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); assert!(state.audio_state.active_patterns.contains_key(&pid(0, 1))); // Tick and verify both produce commands let output = state.tick(tick_at(1.0, true)); // Should have commands from both patterns (2 patterns * 1 command each) assert!(output.audio_commands.len() >= 2); } }