2118 lines
67 KiB
Rust
2118 lines
67 KiB
Rust
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<f64> },
|
|
Hush,
|
|
Panic,
|
|
LoadSamples(Vec<doux::sampling::SampleEntry>),
|
|
}
|
|
|
|
#[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<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,
|
|
}
|
|
|
|
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 struct SequencerSnapshot {
|
|
pub active_patterns: Vec<ActivePatternState>,
|
|
step_traces: Arc<StepTracesMap>,
|
|
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<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>>>,
|
|
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 {
|
|
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<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,
|
|
}
|
|
|
|
#[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>>,
|
|
#[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);
|
|
|
|
#[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,
|
|
#[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;
|
|
} 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(crate) 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,
|
|
#[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<f64>,
|
|
}
|
|
|
|
pub(crate) 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,
|
|
}
|
|
|
|
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()?,
|
|
})
|
|
}
|
|
|
|
struct KeyCache {
|
|
speed_keys: [[String; MAX_PATTERNS]; MAX_BANKS],
|
|
chain_keys: [[String; MAX_PATTERNS]; MAX_BANKS],
|
|
}
|
|
|
|
impl KeyCache {
|
|
fn new() -> Self {
|
|
Self {
|
|
speed_keys: std::array::from_fn(|bank| {
|
|
std::array::from_fn(|pattern| format!("__speed_{bank}_{pattern}__"))
|
|
}),
|
|
chain_keys: std::array::from_fn(|bank| {
|
|
std::array::from_fn(|pattern| format!("__chain_{bank}_{pattern}__"))
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn speed_key(&self, bank: usize, pattern: usize) -> &str {
|
|
&self.speed_keys[bank][pattern]
|
|
}
|
|
|
|
fn chain_key(&self, bank: usize, pattern: usize) -> &str {
|
|
&self.chain_keys[bank][pattern]
|
|
}
|
|
}
|
|
|
|
pub(crate) struct SequencerState {
|
|
audio_state: AudioState,
|
|
pattern_cache: PatternCache,
|
|
pending_updates: HashMap<(usize, usize), PatternSnapshot>,
|
|
runs_counter: RunsCounter,
|
|
step_traces: Arc<StepTracesMap>,
|
|
event_count: usize,
|
|
script_engine: ScriptEngine,
|
|
variables: Variables,
|
|
speed_overrides: HashMap<(usize, usize), f64>,
|
|
key_cache: KeyCache,
|
|
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)>,
|
|
}
|
|
|
|
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), 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,
|
|
speed_overrides: HashMap::with_capacity(MAX_PATTERNS),
|
|
key_cache: KeyCache::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<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::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 => {
|
|
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(StdRng::seed_from_u64(0)));
|
|
self.script_engine =
|
|
ScriptEngine::new(Arc::clone(&variables), dict, rng);
|
|
self.variables = variables;
|
|
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 = self.key_cache.speed_key(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 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.clone(),
|
|
speed_key: self.key_cache.speed_key(active.bank, active.pattern),
|
|
chain_key: self.key_cache.chain_key(active.bank, active.pattern),
|
|
#[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();
|
|
for id in completed {
|
|
let chain_key = self.key_cache.chain_key(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 needs_removal = new_tempo.is_some()
|
|
|| completed.iter().any(|id| {
|
|
let chain_key = self.key_cache.chain_key(id.bank, id.pattern);
|
|
vars.contains_key(chain_key)
|
|
})
|
|
|| stopped.iter().any(|id| {
|
|
let chain_key = self.key_cache.chain_key(id.bank, id.pattern);
|
|
vars.contains_key(chain_key)
|
|
});
|
|
|
|
if needs_removal {
|
|
let mut new_vars = (**vars).clone();
|
|
new_vars.remove("__tempo__");
|
|
for id in completed {
|
|
new_vars.remove(self.key_cache.chain_key(id.bank, id.pattern));
|
|
}
|
|
for id in stopped {
|
|
new_vars.remove(self.key_cache.chain_key(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<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>>,
|
|
#[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 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(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<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);
|
|
|
|
match parts[1] {
|
|
"note" => {
|
|
// /midi/note/<note>/vel/<vel>/chan/<chan>/dur/<dur>/dev/<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<f64> = find_param("dur").and_then(|s| s.parse().ok());
|
|
Some((
|
|
MidiCommand::NoteOn {
|
|
device,
|
|
channel: chan,
|
|
note,
|
|
velocity: vel,
|
|
},
|
|
dur,
|
|
))
|
|
}
|
|
"cc" => {
|
|
// /midi/cc/<cc>/<val>/chan/<chan>/dev/<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/<value>/chan/<chan>/dev/<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/<value>/chan/<chan>/dev/<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/<value>/chan/<chan>/dev/<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(
|
|
<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(),
|
|
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<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,
|
|
#[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);
|
|
}
|
|
}
|