Files
Cagire/src/engine/sequencer.rs
2026-01-29 01:10:53 +01:00

1399 lines
43 KiB
Rust

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