Feat: introduce follow up actions

This commit is contained in:
2026-02-22 03:59:09 +01:00
parent d3b27e8245
commit e2f3bcd4a9
25 changed files with 203 additions and 307 deletions

View File

@@ -16,7 +16,7 @@ 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::model::{FollowUp, LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS};
use crate::state::LiveKeyState;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
@@ -134,6 +134,7 @@ pub struct PatternSnapshot {
pub steps: Vec<StepSnapshot>,
pub quantization: LaunchQuantization,
pub sync_mode: SyncMode,
pub follow_up: FollowUp,
}
#[derive(Clone)]
@@ -523,33 +524,6 @@ 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 KeyBuf {
speed: String,
chain: String,
}
impl KeyBuf {
fn new() -> Self {
Self {
speed: String::with_capacity(24),
chain: String::with_capacity(24),
}
}
}
fn format_speed_key(buf: &mut String, bank: usize, pattern: usize) -> &str {
use std::fmt::Write;
buf.clear();
@@ -557,13 +531,6 @@ fn format_speed_key(buf: &mut String, bank: usize, pattern: usize) -> &str {
buf
}
fn format_chain_key(buf: &mut String, bank: usize, pattern: usize) -> &str {
use std::fmt::Write;
buf.clear();
write!(buf, "__chain_{bank}_{pattern}__").unwrap();
buf
}
pub struct SequencerState {
audio_state: AudioState,
pattern_cache: PatternCache,
@@ -575,7 +542,7 @@ pub struct SequencerState {
variables: Variables,
dict: Dictionary,
speed_overrides: HashMap<(usize, usize), f64>,
key_buf: KeyBuf,
speed_key_buf: String,
buf_audio_commands: Vec<TimestampedCommand>,
buf_activated: Vec<PatternId>,
buf_stopped: Vec<PatternId>,
@@ -606,7 +573,7 @@ impl SequencerState {
variables,
dict,
speed_overrides: HashMap::with_capacity(MAX_PATTERNS),
key_buf: KeyBuf::new(),
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),
@@ -757,15 +724,15 @@ impl SequencerState {
input.mouse_down,
);
let vars = self.read_variables(&self.buf_completed_iterations, steps.any_step_fired);
self.apply_chain_transitions(vars.chain_transitions);
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: vars.new_tempo,
new_tempo,
shared_state: self.build_shared_state(),
flush_midi_notes: flush,
}
@@ -896,7 +863,7 @@ impl SequencerState {
{
let vars = self.variables.load_full();
for id in self.audio_state.active_patterns.keys() {
let key = format_speed_key(&mut self.key_buf.speed, id.bank, id.pattern);
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);
}
@@ -947,8 +914,7 @@ impl SequencerState {
active.pattern,
source_idx,
);
let speed_key = format_speed_key(&mut self.key_buf.speed, active.bank, active.pattern);
let chain_key = format_chain_key(&mut self.key_buf.chain, active.bank, active.pattern);
let speed_key = format_speed_key(&mut self.speed_key_buf, active.bank, active.pattern);
let ctx = StepContext {
step: step_idx,
beat: step_beat,
@@ -964,7 +930,6 @@ impl SequencerState {
nudge_secs,
cc_access: self.cc_access.as_deref(),
speed_key,
chain_key,
mouse_x,
mouse_y,
mouse_down,
@@ -1016,14 +981,9 @@ impl SequencerState {
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(),
};
fn read_tempo_variable(&self, any_step_fired: bool) -> Option<f64> {
if !any_step_fired {
return None;
}
let vars = self.variables.load_full();
@@ -1031,76 +991,45 @@ impl SequencerState {
.get("__tempo__")
.and_then(|v: &Value| v.as_float().ok());
let mut chain_transitions = Vec::new();
let mut buf = String::with_capacity(24);
for id in completed {
let chain_key = format_chain_key(&mut buf, id.bank, id.pattern);
if let Some(Value::Str(s, _)) = vars.get(chain_key) {
if let Some(target) = parse_chain_target(s) {
chain_transitions.push((*id, target));
}
}
}
// Remove consumed variables (tempo and chain keys)
let mut needs_removal = new_tempo.is_some();
if !needs_removal {
for id in completed.iter().chain(stopped.iter()) {
if vars.contains_key(format_chain_key(&mut buf, id.bank, id.pattern)) {
needs_removal = true;
break;
}
}
}
if needs_removal {
if new_tempo.is_some() {
let mut new_vars = (*vars).clone();
new_vars.remove("__tempo__");
for id in completed {
new_vars.remove(format_chain_key(&mut buf, id.bank, id.pattern));
}
for id in stopped {
new_vars.remove(format_chain_key(&mut buf, id.bank, id.pattern));
}
self.variables.store(Arc::new(new_vars));
}
VariableReads {
new_tempo,
chain_transitions,
}
new_tempo
}
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 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,
});
}
}
}
}
}
@@ -1412,6 +1341,7 @@ mod tests {
.collect(),
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
follow_up: FollowUp::default(),
}
}
@@ -1528,68 +1458,6 @@ mod tests {
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();
@@ -1779,67 +1647,6 @@ mod tests {
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();
@@ -2100,6 +1907,7 @@ mod tests {
.collect(),
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
follow_up: FollowUp::default(),
}
}