Files
Cagire/src/engine/sequencer.rs

2183 lines
70 KiB
Rust

//! Real-time pattern sequencer: evaluates Forth scripts per step and produces audio/MIDI commands.
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::{FollowUp, 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<f64> },
Hush,
Panic,
LoadSamples(Vec<doux::sampling::SampleEntry>),
LoadSoundfont(std::path::PathBuf),
}
#[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)>,
},
ScriptUpdate {
script: String,
speed: crate::model::PatternSpeed,
length: usize,
},
StopAll,
RestartAll,
ResetScriptState,
Shutdown,
}
#[derive(Clone)]
pub struct PatternSnapshot {
pub speed: crate::model::PatternSpeed,
pub length: usize,
pub steps: Vec<StepSnapshot>,
pub sync_mode: SyncMode,
pub follow_up: FollowUp,
}
#[derive(Clone)]
pub struct StepSnapshot {
pub active: bool,
pub script: String,
pub source: Option<u8>,
}
#[derive(Clone, Copy, Default, Debug)]
pub struct ActivePatternState {
pub bank: usize,
pub pattern: usize,
pub step_index: usize,
pub iter: usize,
pub last_step_beat: f64,
}
pub type StepTracesMap = HashMap<(usize, usize, usize), ExecutionTrace>;
#[derive(Clone, Default)]
pub struct SharedSequencerState {
pub active_patterns: Vec<ActivePatternState>,
pub step_traces: Arc<StepTracesMap>,
pub event_count: usize,
pub tempo: f64,
pub beat: f64,
pub script_trace: Option<ExecutionTrace>,
pub print_output: Option<String>,
}
pub struct SequencerSnapshot {
pub active_patterns: Vec<ActivePatternState>,
step_traces: Arc<StepTracesMap>,
pub event_count: usize,
pub tempo: f64,
pub beat: f64,
script_trace: Option<ExecutionTrace>,
pub print_output: Option<String>,
}
impl From<&SharedSequencerState> for SequencerSnapshot {
fn from(s: &SharedSequencerState) -> Self {
Self {
active_patterns: s.active_patterns.clone(),
step_traces: Arc::clone(&s.step_traces),
event_count: s.event_count,
tempo: s.tempo,
beat: s.beat,
script_trace: s.script_trace.clone(),
print_output: s.print_output.clone(),
}
}
}
impl SequencerSnapshot {
#[allow(dead_code)]
pub fn empty() -> Self {
Self {
active_patterns: Vec::new(),
step_traces: Arc::new(HashMap::new()),
event_count: 0,
tempo: 0.0,
beat: 0.0,
script_trace: None,
print_output: None,
}
}
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<usize> {
self.active_patterns
.iter()
.find(|p| p.bank == bank && p.pattern == pattern)
.map(|p| p.step_index)
}
/// Returns smooth progress (0.0..1.0) through the pattern by interpolating
/// between discrete steps using beat timing.
pub fn get_smooth_progress(&self, bank: usize, pattern: usize, length: usize, speed_mult: f64) -> Option<f64> {
let p = self.active_patterns.iter().find(|p| p.bank == bank && p.pattern == pattern)?;
if length == 0 || self.tempo <= 0.0 {
return Some(0.0);
}
let step_duration_beats = 1.0 / (4.0 * speed_mult);
let elapsed = (self.beat - p.last_step_beat).max(0.0);
let phase = (elapsed / step_duration_beats).clamp(0.0, 1.0);
Some((p.step_index as f64 + phase) / length as f64)
}
pub fn get_iter(&self, bank: usize, pattern: usize) -> Option<usize> {
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 fn script_trace(&self) -> Option<&ExecutionTrace> {
self.script_trace.as_ref()
}
}
pub struct SequencerHandle {
pub cmd_tx: Sender<SeqCommand>,
pub audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
pub midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
shared_state: Arc<ArcSwap<SharedSequencerState>>,
thread: JoinHandle<()>,
}
impl SequencerHandle {
pub fn snapshot(&self) -> SequencerSnapshot {
let state = self.shared_state.load();
SequencerSnapshot::from(state.as_ref())
}
pub fn swap_audio_channel(&self) -> Receiver<AudioCommand> {
let (new_tx, new_rx) = unbounded::<AudioCommand>();
self.audio_tx.store(Arc::new(new_tx));
new_rx
}
pub fn swap_midi_channel(&self) -> Receiver<MidiCommand> {
let (new_tx, new_rx) = bounded::<MidiCommand>(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,
last_step_beat: f64,
}
#[derive(Clone, Copy)]
struct PendingPattern {
id: PatternId,
quantization: LaunchQuantization,
sync_mode: SyncMode,
}
struct AudioState {
prev_beat: f64,
active_patterns: HashMap<PatternId, ActivePattern>,
pending_starts: Vec<PendingPattern>,
pending_stops: Vec<PendingPattern>,
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<AtomicU64>,
pub sample_rate: Arc<std::sync::atomic::AtomicU32>,
pub cc_access: Option<Arc<dyn CcAccess>>,
pub variables: Variables,
pub dict: Dictionary,
#[cfg(feature = "desktop")]
pub mouse_x: Arc<AtomicU32>,
#[cfg(feature = "desktop")]
pub mouse_y: Arc<AtomicU32>,
#[cfg(feature = "desktop")]
pub mouse_down: Arc<AtomicU32>,
}
pub fn spawn_sequencer(
link: Arc<LinkState>,
playing: Arc<std::sync::atomic::AtomicBool>,
quantum: f64,
live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>,
config: SequencerConfig,
) -> (
SequencerHandle,
Receiver<AudioCommand>,
Receiver<MidiCommand>,
) {
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
let (audio_tx, audio_rx) = unbounded::<AudioCommand>();
let (midi_tx, midi_rx) = bounded::<MidiCommand>(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::<TimedMidiCommand>();
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<PatternSnapshot>; 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<StepKey, usize>,
}
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 struct TickInput {
pub commands: Vec<SeqCommand>,
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,
pub mouse_x: f64,
pub mouse_y: f64,
pub mouse_down: f64,
}
pub struct TimestampedCommand {
pub cmd: String,
pub time: Option<f64>,
}
pub struct TickOutput {
pub audio_commands: Vec<TimestampedCommand>,
pub new_tempo: Option<f64>,
pub shared_state: SharedSequencerState,
pub flush_midi_notes: bool,
}
struct StepResult {
any_step_fired: bool,
}
fn format_speed_key(buf: &mut String, bank: usize, pattern: usize) -> &str {
use std::fmt::Write;
buf.clear();
write!(buf, "__speed_{bank}_{pattern}__").expect("write to String");
buf
}
pub struct SequencerState {
audio_state: AudioState,
pattern_cache: PatternCache,
pending_updates: HashMap<(usize, usize), PatternSnapshot>,
runs_counter: RunsCounter,
step_traces: StepTracesMap,
event_count: usize,
script_engine: ScriptEngine,
variables: Variables,
dict: Dictionary,
speed_overrides: HashMap<(usize, usize), f64>,
speed_key_buf: String,
buf_audio_commands: Vec<TimestampedCommand>,
buf_activated: Vec<PatternId>,
buf_stopped: Vec<PatternId>,
buf_completed_iterations: Vec<PatternId>,
cc_access: Option<Arc<dyn CcAccess>>,
muted: std::collections::HashSet<(usize, usize)>,
soloed: std::collections::HashSet<(usize, usize)>,
last_tempo: f64,
last_beat: f64,
script_text: String,
script_speed: crate::model::PatternSpeed,
script_length: usize,
script_frontier: f64,
script_step: usize,
script_trace: Option<ExecutionTrace>,
print_output: Option<String>,
}
impl SequencerState {
pub fn new(
variables: Variables,
dict: Dictionary,
rng: Rng,
cc_access: Option<Arc<dyn CcAccess>>,
) -> 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: HashMap::new(),
event_count: 0,
script_engine,
variables,
dict,
speed_overrides: HashMap::with_capacity(MAX_PATTERNS),
speed_key_buf: String::with_capacity(24),
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(),
last_tempo: 0.0,
last_beat: 0.0,
script_text: String::new(),
script_speed: crate::model::PatternSpeed::default(),
script_length: 16,
script_frontier: -1.0,
script_step: 0,
script_trace: None,
print_output: None,
}
}
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<SeqCommand>) {
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::ScriptUpdate { script, speed, length } => {
self.script_text = script;
self.script_speed = speed;
self.script_length = length;
}
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();
self.step_traces.clear();
self.runs_counter.counts.clear();
self.audio_state.flush_midi_notes = true;
}
SeqCommand::RestartAll => {
for active in self.audio_state.active_patterns.values_mut() {
active.step_index = 0;
active.iter = 0;
}
self.audio_state.prev_beat = -1.0;
self.script_frontier = -1.0;
self.script_step = 0;
self.script_trace = None;
self.variables.store(Arc::new(HashMap::new()));
self.dict.lock().clear();
self.speed_overrides.clear();
self.script_engine.clear_global_params();
self.runs_counter.counts.clear();
self.step_traces.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();
self.script_engine.clear_global_params();
}
SeqCommand::Shutdown => {}
}
}
}
pub fn tick(&mut self, input: TickInput) -> TickOutput {
self.process_commands(input.commands);
self.last_tempo = input.tempo;
self.last_beat = input.beat;
if !input.playing {
return self.tick_paused();
}
let frontier = self.audio_state.prev_beat;
let lookahead_end = input.lookahead_end;
if frontier < 0.0 {
self.realign_phaselock_patterns(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,
input.mouse_x,
input.mouse_y,
input.mouse_down,
);
self.execute_periodic_script(
input.beat,
frontier,
lookahead_end,
input.tempo,
input.quantum,
input.fill,
input.nudge_secs,
input.engine_time,
input.mouse_x,
input.mouse_y,
input.mouse_down,
);
let new_tempo = self.read_tempo_variable(steps.any_step_fired);
self.apply_follow_ups();
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,
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);
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.prev_beat = -1.0;
self.script_frontier = -1.0;
self.script_step = 0;
self.script_trace = None;
self.print_output = None;
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 realign_phaselock_patterns(&mut self, beat: f64) {
for (id, active) in &mut self.audio_state.active_patterns {
let Some(pattern) = self.pattern_cache.get(id.bank, id.pattern) else {
continue;
};
if pattern.sync_mode != SyncMode::PhaseLock {
continue;
}
let speed_mult = pattern.speed.multiplier();
let subs_per_beat = 4.0 * speed_mult;
let step = (beat * subs_per_beat).floor() as usize + 1;
active.step_index = step % pattern.length;
}
}
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();
let subs_per_beat = 4.0 * speed_mult;
let first_sub = (prev_beat * subs_per_beat).floor() as usize + 1;
first_sub % 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,
last_step_beat: beat,
},
);
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);
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,
mouse_x: f64,
mouse_y: f64,
mouse_down: f64,
) -> StepResult {
self.buf_audio_commands.clear();
self.buf_completed_iterations.clear();
let mut print_cleared = false;
let mut result = StepResult {
any_step_fired: false,
};
self.speed_overrides.clear();
{
let vars = self.variables.load_full();
for id in self.audio_state.active_patterns.keys() {
let key = format_speed_key(&mut self.speed_key_buf, 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;
active.last_step_beat = step_beat;
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.speed_key_buf, 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,
mouse_x,
mouse_y,
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)
{
self.step_traces.insert(
(active.bank, active.pattern, source_idx),
std::mem::take(&mut trace),
);
if !print_cleared {
self.print_output = None;
print_cleared = true;
}
for cmd in cmds {
if let Some(text) = cmd.strip_prefix("print:") {
match &mut self.print_output {
Some(existing) => {
existing.push(' ');
existing.push_str(text);
}
None => self.print_output = Some(text.to_string()),
}
} else {
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
}
#[allow(clippy::too_many_arguments)]
fn execute_periodic_script(
&mut self,
beat: f64,
frontier: f64,
lookahead_end: f64,
tempo: f64,
quantum: f64,
fill: bool,
nudge_secs: f64,
engine_time: f64,
mouse_x: f64,
mouse_y: f64,
mouse_down: f64,
) {
if self.script_text.trim().is_empty() {
return;
}
let script_frontier = if self.script_frontier < 0.0 {
frontier
} else {
self.script_frontier
};
let speed_mult = self.script_speed.multiplier();
let fire_beats = substeps_in_window(script_frontier, lookahead_end, speed_mult);
for step_beat in fire_beats {
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);
let step_in_cycle = self.script_step % self.script_length;
if step_in_cycle == 0 {
let ctx = StepContext {
step: 0,
beat: step_beat,
bank: 0,
pattern: 0,
tempo,
phase: step_beat % quantum,
slot: 0,
runs: self.script_step / self.script_length,
iter: self.script_step / self.script_length,
speed: speed_mult,
fill,
nudge_secs,
cc_access: self.cc_access.as_deref(),
speed_key: "",
mouse_x,
mouse_y,
mouse_down,
};
let mut trace = ExecutionTrace::default();
if let Ok(cmds) =
self.script_engine
.evaluate_with_trace(&self.script_text, &ctx, &mut trace)
{
self.print_output = None;
for cmd in cmds {
if let Some(text) = cmd.strip_prefix("print:") {
match &mut self.print_output {
Some(existing) => {
existing.push(' ');
existing.push_str(text);
}
None => self.print_output = Some(text.to_string()),
}
} else {
self.event_count += 1;
self.buf_audio_commands.push(TimestampedCommand {
cmd,
time: event_time,
});
}
}
}
self.script_trace = Some(trace);
}
self.script_step += 1;
}
self.script_frontier = lookahead_end;
}
fn read_tempo_variable(&self, any_step_fired: bool) -> Option<f64> {
if !any_step_fired {
return None;
}
let vars = self.variables.load_full();
let new_tempo = vars
.get("__tempo__")
.and_then(|v: &Value| v.as_float().ok());
if new_tempo.is_some() {
let mut new_vars = (*vars).clone();
new_vars.remove("__tempo__");
self.variables.store(Arc::new(new_vars));
}
new_tempo
}
fn apply_follow_ups(&mut self) {
for completed_id in &self.buf_completed_iterations {
let Some(pattern) = self.pattern_cache.get(completed_id.bank, completed_id.pattern) else {
continue;
};
match pattern.follow_up {
FollowUp::Loop => {}
FollowUp::Stop => {
self.audio_state.pending_stops.push(PendingPattern {
id: *completed_id,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
});
}
FollowUp::Chain { bank, pattern } => {
self.audio_state.pending_stops.push(PendingPattern {
id: *completed_id,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
});
let target = PatternId { bank, pattern };
if !self.audio_state.pending_starts.iter().any(|p| p.id == target) {
self.audio_state.pending_starts.push(PendingPattern {
id: target,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
});
}
}
}
}
}
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,
last_step_beat: a.last_step_beat,
})
.collect(),
step_traces: Arc::new(self.step_traces.clone()),
event_count: self.event_count,
tempo: self.last_tempo,
beat: self.last_beat,
script_trace: self.script_trace.clone(),
print_output: self.print_output.clone(),
}
}
}
#[allow(clippy::too_many_arguments)]
fn sequencer_loop(
cmd_rx: Receiver<SeqCommand>,
dispatch_tx: Sender<TimedMidiCommand>,
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
link: Arc<LinkState>,
playing: Arc<std::sync::atomic::AtomicBool>,
quantum: f64,
shared_state: Arc<ArcSwap<SharedSequencerState>>,
live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>,
audio_sample_pos: Arc<AtomicU64>,
sample_rate: Arc<std::sync::atomic::AtomicU32>,
cc_access: Option<Arc<dyn CcAccess>>,
variables: Variables,
dict: Dictionary,
#[cfg(feature = "desktop")] mouse_x: Arc<AtomicU32>,
#[cfg(feature = "desktop")] mouse_y: Arc<AtomicU32>,
#[cfg(feature = "desktop")] mouse_down: Arc<AtomicU32>,
) {
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(not(feature = "desktop"))]
mouse_x: 0.5,
#[cfg(feature = "desktop")]
mouse_y: f32::from_bits(mouse_y.load(Ordering::Relaxed)) as f64,
#[cfg(not(feature = "desktop"))]
mouse_y: 0.5,
#[cfg(feature = "desktop")]
mouse_down: f32::from_bits(mouse_down.load(Ordering::Relaxed)) as f64,
#[cfg(not(feature = "desktop"))]
mouse_down: 0.0,
};
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, delta_secs)) = parse_midi_command(&tsc.cmd) {
let target_time_us =
current_time_us + (delta_secs * 1_000_000.0) as SyncTime;
let _ = dispatch_tx.send(TimedMidiCommand {
command: MidiDispatch::Send(midi_cmd.clone()),
target_time_us,
});
if let (
MidiCommand::NoteOn {
device,
channel,
note,
..
},
Some(dur_secs),
) = (&midi_cmd, dur)
{
let off_time_us = target_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));
}
}
pub fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>, f64)> {
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);
let delta: f64 = find_param("delta").and_then(|s| s.parse().ok()).unwrap_or(0.0);
match parts[1] {
"note" => {
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<f64> = find_param("dur").and_then(|s| s.parse().ok());
Some((
MidiCommand::NoteOn {
device,
channel: chan,
note,
velocity: vel,
},
dur,
delta,
))
}
"cc" => {
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,
delta,
))
}
"bend" => {
let value: u16 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((
MidiCommand::PitchBend {
device,
channel: chan,
value,
},
None,
delta,
))
}
"pressure" => {
let value: u8 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((
MidiCommand::Pressure {
device,
channel: chan,
value,
},
None,
delta,
))
}
"program" => {
let program: u8 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((
MidiCommand::ProgramChange {
device,
channel: chan,
program,
},
None,
delta,
))
}
"clock" => Some((MidiCommand::Clock { device }, None, delta)),
"start" => Some((MidiCommand::Start { device }, None, delta)),
"stop" => Some((MidiCommand::Stop { device }, None, delta)),
"continue" => Some((MidiCommand::Continue { device }, None, delta)),
_ => 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(
<rand::rngs::StdRng as rand::SeedableRng>::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(),
sync_mode: SyncMode::Reset,
follow_up: FollowUp::default(),
}
}
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,
mouse_x: 0.5,
mouse_y: 0.5,
mouse_down: 0.0,
}
}
fn tick_with(commands: Vec<SeqCommand>, 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,
mouse_x: 0.5,
mouse_y: 0.5,
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_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)).expect("pattern in active set");
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)).expect("pattern in active set");
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)).expect("pattern in active set");
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)).expect("pattern in active set");
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)).expect("pattern in active set");
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_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)).expect("pattern in active set");
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)).expect("pattern in active set");
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)).expect("pattern in active set");
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)).expect("pattern in active set");
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)).expect("pattern in active set");
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)).expect("pattern in active set");
assert_eq!(ap.step_index, 0);
}
#[test]
fn test_start_while_paused_is_preserved() {
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 preserved for resume
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 — first tick resets prev_beat from -1 to 2.0
state.tick(tick_at(2.0, true));
// Second tick: prev_beat is now >= 0, so Immediate fires
state.tick(tick_at(2.25, 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(),
sync_mode: SyncMode::Reset,
follow_up: FollowUp::default(),
}
}
#[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);
}
}