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

@@ -94,7 +94,6 @@ pub enum Op {
Triangle, Triangle,
Range, Range,
Perlin, Perlin,
Chain,
Loop, Loop,
Degree(&'static [i64]), Degree(&'static [i64]),
Oct, Oct,

View File

@@ -59,7 +59,6 @@ pub struct StepContext<'a> {
pub nudge_secs: f64, pub nudge_secs: f64,
pub cc_access: Option<&'a dyn CcAccess>, pub cc_access: Option<&'a dyn CcAccess>,
pub speed_key: &'a str, pub speed_key: &'a str,
pub chain_key: &'a str,
pub mouse_x: f64, pub mouse_x: f64,
pub mouse_y: f64, pub mouse_y: f64,
pub mouse_down: f64, pub mouse_down: f64,

View File

@@ -992,26 +992,6 @@ impl Forth {
.insert(ctx.speed_key.to_string(), Value::Float(clamped, None)); .insert(ctx.speed_key.to_string(), Value::Float(clamped, None));
} }
Op::Chain => {
let pattern = pop_int(stack)? - 1;
let bank = pop_int(stack)? - 1;
if bank < 0 || pattern < 0 {
return Err("chain: bank and pattern must be >= 1".into());
}
if bank as usize == ctx.bank && pattern as usize == ctx.pattern {
// chaining to self is a no-op
} else {
use std::fmt::Write;
let mut val = String::with_capacity(8);
let _ = write!(&mut val, "{bank}:{pattern}");
var_writes_cell
.borrow_mut()
.as_mut()
.expect("var_writes taken")
.insert(ctx.chain_key.to_string(), Value::Str(Arc::from(val), None));
}
}
Op::Loop => { Op::Loop => {
let beats = pop_float(stack)?; let beats = pop_float(stack)?;
if ctx.tempo == 0.0 || ctx.speed == 0.0 { if ctx.tempo == 0.0 || ctx.speed == 0.0 {

View File

@@ -89,7 +89,6 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"triangle" => Op::Triangle, "triangle" => Op::Triangle,
"range" => Op::Range, "range" => Op::Range,
"perlin" => Op::Perlin, "perlin" => Op::Perlin,
"chain" => Op::Chain,
"loop" => Op::Loop, "loop" => Op::Loop,
"oct" => Op::Oct, "oct" => Op::Oct,
"clear" => Op::ClearCmd, "clear" => Op::ClearCmd,

View File

@@ -254,16 +254,6 @@ pub(super) const WORDS: &[Word] = &[
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
Word {
name: "chain",
aliases: &[],
category: "Time",
stack: "(bank pattern --)",
desc: "Chain to bank/pattern (1-indexed) when current pattern ends",
example: "1 4 chain",
compile: Simple,
varargs: false,
},
Word { Word {
name: "at", name: "at",
aliases: &[], aliases: &[],

View File

@@ -7,4 +7,4 @@ pub const MAX_STEPS: usize = 1024;
pub const DEFAULT_LENGTH: usize = 16; pub const DEFAULT_LENGTH: usize = 16;
pub use file::{load, save, FileError}; pub use file::{load, save, FileError};
pub use project::{Bank, LaunchQuantization, Pattern, PatternSpeed, Project, Step, SyncMode}; pub use project::{Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, Step, SyncMode};

View File

@@ -206,6 +206,44 @@ impl SyncMode {
} }
} }
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
pub enum FollowUp {
#[default]
Loop,
Stop,
Chain { bank: usize, pattern: usize },
}
impl FollowUp {
pub fn label(&self) -> &'static str {
match self {
Self::Loop => "Loop",
Self::Stop => "Stop",
Self::Chain { .. } => "Chain",
}
}
pub fn next_mode(&self) -> Self {
match self {
Self::Loop => Self::Stop,
Self::Stop => Self::Chain { bank: 0, pattern: 0 },
Self::Chain { .. } => Self::Loop,
}
}
pub fn prev_mode(&self) -> Self {
match self {
Self::Loop => Self::Chain { bank: 0, pattern: 0 },
Self::Stop => Self::Loop,
Self::Chain { .. } => Self::Stop,
}
}
}
fn is_default_follow_up(f: &FollowUp) -> bool {
*f == FollowUp::default()
}
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Step { pub struct Step {
pub active: bool, pub active: bool,
@@ -245,6 +283,7 @@ pub struct Pattern {
pub name: Option<String>, pub name: Option<String>,
pub quantization: LaunchQuantization, pub quantization: LaunchQuantization,
pub sync_mode: SyncMode, pub sync_mode: SyncMode,
pub follow_up: FollowUp,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -280,6 +319,8 @@ struct SparsePattern {
quantization: LaunchQuantization, quantization: LaunchQuantization,
#[serde(default, skip_serializing_if = "is_default_sync_mode")] #[serde(default, skip_serializing_if = "is_default_sync_mode")]
sync_mode: SyncMode, sync_mode: SyncMode,
#[serde(default, skip_serializing_if = "is_default_follow_up")]
follow_up: FollowUp,
} }
fn is_default_quantization(q: &LaunchQuantization) -> bool { fn is_default_quantization(q: &LaunchQuantization) -> bool {
@@ -302,6 +343,8 @@ struct LegacyPattern {
quantization: LaunchQuantization, quantization: LaunchQuantization,
#[serde(default)] #[serde(default)]
sync_mode: SyncMode, sync_mode: SyncMode,
#[serde(default)]
follow_up: FollowUp,
} }
impl Serialize for Pattern { impl Serialize for Pattern {
@@ -327,6 +370,7 @@ impl Serialize for Pattern {
name: self.name.clone(), name: self.name.clone(),
quantization: self.quantization, quantization: self.quantization,
sync_mode: self.sync_mode, sync_mode: self.sync_mode,
follow_up: self.follow_up,
}; };
sparse.serialize(serializer) sparse.serialize(serializer)
} }
@@ -361,6 +405,7 @@ impl<'de> Deserialize<'de> for Pattern {
name: sparse.name, name: sparse.name,
quantization: sparse.quantization, quantization: sparse.quantization,
sync_mode: sparse.sync_mode, sync_mode: sparse.sync_mode,
follow_up: sparse.follow_up,
}) })
} }
PatternFormat::Legacy(legacy) => Ok(Pattern { PatternFormat::Legacy(legacy) => Ok(Pattern {
@@ -370,6 +415,7 @@ impl<'de> Deserialize<'de> for Pattern {
name: legacy.name, name: legacy.name,
quantization: legacy.quantization, quantization: legacy.quantization,
sync_mode: legacy.sync_mode, sync_mode: legacy.sync_mode,
follow_up: legacy.follow_up,
}), }),
} }
} }
@@ -384,6 +430,7 @@ impl Default for Pattern {
name: None, name: None,
quantization: LaunchQuantization::default(), quantization: LaunchQuantization::default(),
sync_mode: SyncMode::default(), sync_mode: SyncMode::default(),
follow_up: FollowUp::default(),
} }
} }
} }

View File

@@ -27,9 +27,18 @@ Each pattern is an independent sequence of steps with its own properties:
| Speed | Playback rate (`1/8x` to `8x`) | `1x` | | Speed | Playback rate (`1/8x` to `8x`) | `1x` |
| Quantization | When the pattern launches | `Bar` | | Quantization | When the pattern launches | `Bar` |
| Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` | | Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` |
| Follow Up | What happens when the pattern finishes an iteration | `Loop` |
Press `e` in the patterns view to edit these settings. Press `e` in the patterns view to edit these settings.
### Follow Up
The follow-up action determines what happens when a pattern reaches the end of its steps:
- **Loop** — the pattern repeats indefinitely. This is the default behavior.
- **Stop** — the pattern plays once and stops.
- **Chain** — the pattern plays once, then starts another pattern. Use `Left`/`Right` to set the target bank and pattern in the edit view.
## Patterns View ## Patterns View
Access the patterns view with `F2` (or `Ctrl+Up` from the sequencer). The view shows all banks and patterns in a grid. Indicators show pattern state: Access the patterns view with `F2` (or `Ctrl+Up` from the sequencer). The view shows all banks and patterns in a grid. Indicators show pattern state:

View File

@@ -217,6 +217,7 @@ impl Plugin for CagirePlugin {
.collect(), .collect(),
quantization: pat.quantization, quantization: pat.quantization,
sync_mode: pat.sync_mode, sync_mode: pat.sync_mode,
follow_up: pat.follow_up,
}; };
let _ = self.bridge.cmd_tx.send(SeqCommand::PatternUpdate { let _ = self.bridge.cmd_tx.send(SeqCommand::PatternUpdate {
bank: bank_idx, bank: bank_idx,

View File

@@ -180,6 +180,7 @@ impl App {
speed, speed,
quantization, quantization,
sync_mode, sync_mode,
follow_up,
} => { } => {
self.playback.staged_prop_changes.insert( self.playback.staged_prop_changes.insert(
(bank, pattern), (bank, pattern),
@@ -189,6 +190,7 @@ impl App {
speed, speed,
quantization, quantization,
sync_mode, sync_mode,
follow_up,
}, },
); );
self.ui.set_status(format!( self.ui.set_status(format!(

View File

@@ -189,6 +189,7 @@ impl App {
speed: pat.speed, speed: pat.speed,
quantization: pat.quantization, quantization: pat.quantization,
sync_mode: pat.sync_mode, sync_mode: pat.sync_mode,
follow_up: pat.follow_up,
}; };
} }

View File

@@ -31,7 +31,6 @@ impl App {
nudge_secs: 0.0, nudge_secs: 0.0,
cc_access: None, cc_access: None,
speed_key: "", speed_key: "",
chain_key: "",
mouse_x: 0.5, mouse_x: 0.5,
mouse_y: 0.5, mouse_y: 0.5,
mouse_down: 0.0, mouse_down: 0.0,

View File

@@ -54,6 +54,7 @@ impl App {
.collect(), .collect(),
quantization: pat.quantization, quantization: pat.quantization,
sync_mode: pat.sync_mode, sync_mode: pat.sync_mode,
follow_up: pat.follow_up,
}; };
let _ = cmd_tx.send(SeqCommand::PatternUpdate { let _ = cmd_tx.send(SeqCommand::PatternUpdate {
bank, bank,

View File

@@ -85,6 +85,7 @@ impl App {
pat.speed = props.speed; pat.speed = props.speed;
pat.quantization = props.quantization; pat.quantization = props.quantization;
pat.sync_mode = props.sync_mode; pat.sync_mode = props.sync_mode;
pat.follow_up = props.follow_up;
self.project_state.mark_dirty(bank, pattern); self.project_state.mark_dirty(bank, pattern);
} }

View File

@@ -1,6 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
use crate::page::Page; use crate::page::Page;
use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, SettingKind}; use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, SettingKind};
@@ -144,6 +144,7 @@ pub enum AppCommand {
speed: PatternSpeed, speed: PatternSpeed,
quantization: LaunchQuantization, quantization: LaunchQuantization,
sync_mode: SyncMode, sync_mode: SyncMode,
follow_up: FollowUp,
}, },
// Page navigation // Page navigation

View File

@@ -16,7 +16,7 @@ use super::{substeps_in_window, LinkState, StepTiming, SyncTime};
use crate::model::{ use crate::model::{
CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables, 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; use crate::state::LiveKeyState;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
@@ -134,6 +134,7 @@ pub struct PatternSnapshot {
pub steps: Vec<StepSnapshot>, pub steps: Vec<StepSnapshot>,
pub quantization: LaunchQuantization, pub quantization: LaunchQuantization,
pub sync_mode: SyncMode, pub sync_mode: SyncMode,
pub follow_up: FollowUp,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -523,33 +524,6 @@ struct StepResult {
any_step_fired: bool, 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 { fn format_speed_key(buf: &mut String, bank: usize, pattern: usize) -> &str {
use std::fmt::Write; use std::fmt::Write;
buf.clear(); buf.clear();
@@ -557,13 +531,6 @@ fn format_speed_key(buf: &mut String, bank: usize, pattern: usize) -> &str {
buf 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 { pub struct SequencerState {
audio_state: AudioState, audio_state: AudioState,
pattern_cache: PatternCache, pattern_cache: PatternCache,
@@ -575,7 +542,7 @@ pub struct SequencerState {
variables: Variables, variables: Variables,
dict: Dictionary, dict: Dictionary,
speed_overrides: HashMap<(usize, usize), f64>, speed_overrides: HashMap<(usize, usize), f64>,
key_buf: KeyBuf, speed_key_buf: String,
buf_audio_commands: Vec<TimestampedCommand>, buf_audio_commands: Vec<TimestampedCommand>,
buf_activated: Vec<PatternId>, buf_activated: Vec<PatternId>,
buf_stopped: Vec<PatternId>, buf_stopped: Vec<PatternId>,
@@ -606,7 +573,7 @@ impl SequencerState {
variables, variables,
dict, dict,
speed_overrides: HashMap::with_capacity(MAX_PATTERNS), 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_audio_commands: Vec::with_capacity(32),
buf_activated: Vec::with_capacity(16), buf_activated: Vec::with_capacity(16),
buf_stopped: Vec::with_capacity(16), buf_stopped: Vec::with_capacity(16),
@@ -757,15 +724,15 @@ impl SequencerState {
input.mouse_down, input.mouse_down,
); );
let vars = self.read_variables(&self.buf_completed_iterations, steps.any_step_fired); let new_tempo = self.read_tempo_variable(steps.any_step_fired);
self.apply_chain_transitions(vars.chain_transitions); self.apply_follow_ups();
self.audio_state.prev_beat = lookahead_end; self.audio_state.prev_beat = lookahead_end;
let flush = std::mem::take(&mut self.audio_state.flush_midi_notes); let flush = std::mem::take(&mut self.audio_state.flush_midi_notes);
TickOutput { TickOutput {
audio_commands: std::mem::take(&mut self.buf_audio_commands), audio_commands: std::mem::take(&mut self.buf_audio_commands),
new_tempo: vars.new_tempo, new_tempo,
shared_state: self.build_shared_state(), shared_state: self.build_shared_state(),
flush_midi_notes: flush, flush_midi_notes: flush,
} }
@@ -896,7 +863,7 @@ impl SequencerState {
{ {
let vars = self.variables.load_full(); let vars = self.variables.load_full();
for id in self.audio_state.active_patterns.keys() { 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()) { if let Some(v) = vars.get(key).and_then(|v: &Value| v.as_float().ok()) {
self.speed_overrides.insert((id.bank, id.pattern), v); self.speed_overrides.insert((id.bank, id.pattern), v);
} }
@@ -947,8 +914,7 @@ impl SequencerState {
active.pattern, active.pattern,
source_idx, source_idx,
); );
let speed_key = format_speed_key(&mut self.key_buf.speed, active.bank, active.pattern); let speed_key = format_speed_key(&mut self.speed_key_buf, active.bank, active.pattern);
let chain_key = format_chain_key(&mut self.key_buf.chain, active.bank, active.pattern);
let ctx = StepContext { let ctx = StepContext {
step: step_idx, step: step_idx,
beat: step_beat, beat: step_beat,
@@ -964,7 +930,6 @@ impl SequencerState {
nudge_secs, nudge_secs,
cc_access: self.cc_access.as_deref(), cc_access: self.cc_access.as_deref(),
speed_key, speed_key,
chain_key,
mouse_x, mouse_x,
mouse_y, mouse_y,
mouse_down, mouse_down,
@@ -1016,14 +981,9 @@ impl SequencerState {
result result
} }
fn read_variables(&self, completed: &[PatternId], any_step_fired: bool) -> VariableReads { fn read_tempo_variable(&self, any_step_fired: bool) -> Option<f64> {
let stopped = &self.buf_stopped; if !any_step_fired {
let needs_access = !completed.is_empty() || !stopped.is_empty() || any_step_fired; return None;
if !needs_access {
return VariableReads {
new_tempo: None,
chain_transitions: Vec::new(),
};
} }
let vars = self.variables.load_full(); let vars = self.variables.load_full();
@@ -1031,76 +991,45 @@ impl SequencerState {
.get("__tempo__") .get("__tempo__")
.and_then(|v: &Value| v.as_float().ok()); .and_then(|v: &Value| v.as_float().ok());
let mut chain_transitions = Vec::new(); if new_tempo.is_some() {
let mut buf = String::with_capacity(24);
for id in completed {
let chain_key = format_chain_key(&mut buf, id.bank, id.pattern);
if let Some(Value::Str(s, _)) = vars.get(chain_key) {
if let Some(target) = parse_chain_target(s) {
chain_transitions.push((*id, target));
}
}
}
// Remove consumed variables (tempo and chain keys)
let mut needs_removal = new_tempo.is_some();
if !needs_removal {
for id in completed.iter().chain(stopped.iter()) {
if vars.contains_key(format_chain_key(&mut buf, id.bank, id.pattern)) {
needs_removal = true;
break;
}
}
}
if needs_removal {
let mut new_vars = (*vars).clone(); let mut new_vars = (*vars).clone();
new_vars.remove("__tempo__"); 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)); self.variables.store(Arc::new(new_vars));
} }
VariableReads { new_tempo
new_tempo,
chain_transitions,
}
} }
fn apply_chain_transitions(&mut self, transitions: Vec<(PatternId, PatternId)>) { fn apply_follow_ups(&mut self) {
for (source, target) in transitions { for completed_id in &self.buf_completed_iterations {
if !self let Some(pattern) = self.pattern_cache.get(completed_id.bank, completed_id.pattern) else {
.audio_state continue;
.pending_stops };
.iter()
.any(|p| p.id == source) match pattern.follow_up {
{ FollowUp::Loop => {}
self.audio_state.pending_stops.push(PendingPattern { FollowUp::Stop => {
id: source, self.audio_state.pending_stops.push(PendingPattern {
quantization: LaunchQuantization::Bar, id: *completed_id,
sync_mode: SyncMode::Reset, quantization: LaunchQuantization::Immediate,
}); sync_mode: SyncMode::Reset,
} });
if !self }
.audio_state FollowUp::Chain { bank, pattern } => {
.pending_starts self.audio_state.pending_stops.push(PendingPattern {
.iter() id: *completed_id,
.any(|p| p.id == target) quantization: LaunchQuantization::Immediate,
{ sync_mode: SyncMode::Reset,
let (quant, sync) = self });
.pattern_cache let target = PatternId { bank, pattern };
.get(target.bank, target.pattern) if !self.audio_state.pending_starts.iter().any(|p| p.id == target) {
.map(|p| (p.quantization, p.sync_mode)) self.audio_state.pending_starts.push(PendingPattern {
.unwrap_or((LaunchQuantization::Bar, SyncMode::Reset)); id: target,
self.audio_state.pending_starts.push(PendingPattern { quantization: LaunchQuantization::Immediate,
id: target, sync_mode: SyncMode::Reset,
quantization: quant, });
sync_mode: sync, }
}); }
} }
} }
} }
@@ -1412,6 +1341,7 @@ mod tests {
.collect(), .collect(),
quantization: LaunchQuantization::Immediate, quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset, 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))); 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] #[test]
fn test_pattern_start_cancels_pending_stop() { fn test_pattern_start_cancels_pending_stop() {
let mut state = make_state(); let mut state = make_state();
@@ -1779,67 +1647,6 @@ mod tests {
assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); 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] #[test]
fn test_multiple_patterns_independent_quantization() { fn test_multiple_patterns_independent_quantization() {
let mut state = make_state(); let mut state = make_state();
@@ -2100,6 +1907,7 @@ mod tests {
.collect(), .collect(),
quantization: LaunchQuantization::Immediate, quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset, sync_mode: SyncMode::Reset,
follow_up: FollowUp::default(),
} }
} }

View File

@@ -3,7 +3,7 @@ use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use super::{InputContext, InputResult}; use super::{InputContext, InputResult};
use crate::commands::AppCommand; use crate::commands::AppCommand;
use crate::engine::SeqCommand; use crate::engine::SeqCommand;
use crate::model::PatternSpeed; use crate::model::{FollowUp, PatternSpeed};
use crate::state::{ use crate::state::{
ConfirmAction, EditorTarget, EuclideanField, Modal, PatternField, ConfirmAction, EditorTarget, EuclideanField, Modal, PatternField,
PatternPropsField, RenameTarget, PatternPropsField, RenameTarget,
@@ -377,21 +377,45 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
speed, speed,
quantization, quantization,
sync_mode, sync_mode,
follow_up,
} => { } => {
let (bank, pattern) = (*bank, *pattern); let (bank, pattern) = (*bank, *pattern);
let is_chain = matches!(follow_up, FollowUp::Chain { .. });
match key.code { match key.code {
KeyCode::Up => *field = field.prev(), KeyCode::Up => *field = field.prev(is_chain),
KeyCode::Down | KeyCode::Tab => *field = field.next(), KeyCode::Down | KeyCode::Tab => *field = field.next(is_chain),
KeyCode::Left => match field { KeyCode::Left => match field {
PatternPropsField::Speed => *speed = speed.prev(), PatternPropsField::Speed => *speed = speed.prev(),
PatternPropsField::Quantization => *quantization = quantization.prev(), PatternPropsField::Quantization => *quantization = quantization.prev(),
PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(), PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(),
PatternPropsField::FollowUp => *follow_up = follow_up.prev_mode(),
PatternPropsField::ChainBank => {
if let FollowUp::Chain { bank: b, .. } = follow_up {
*b = b.saturating_sub(1);
}
}
PatternPropsField::ChainPattern => {
if let FollowUp::Chain { pattern: p, .. } = follow_up {
*p = p.saturating_sub(1);
}
}
_ => {} _ => {}
}, },
KeyCode::Right => match field { KeyCode::Right => match field {
PatternPropsField::Speed => *speed = speed.next(), PatternPropsField::Speed => *speed = speed.next(),
PatternPropsField::Quantization => *quantization = quantization.next(), PatternPropsField::Quantization => *quantization = quantization.next(),
PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(), PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(),
PatternPropsField::FollowUp => *follow_up = follow_up.next_mode(),
PatternPropsField::ChainBank => {
if let FollowUp::Chain { bank: b, .. } = follow_up {
*b = (*b + 1).min(31);
}
}
PatternPropsField::ChainPattern => {
if let FollowUp::Chain { pattern: p, .. } = follow_up {
*p = (*p + 1).min(31);
}
}
_ => {} _ => {}
}, },
KeyCode::Char(c) => match field { KeyCode::Char(c) => match field {
@@ -418,6 +442,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
let speed_val = *speed; let speed_val = *speed;
let quant_val = *quantization; let quant_val = *quantization;
let sync_val = *sync_mode; let sync_val = *sync_mode;
let follow_up_val = *follow_up;
ctx.dispatch(AppCommand::StagePatternProps { ctx.dispatch(AppCommand::StagePatternProps {
bank, bank,
pattern, pattern,
@@ -426,6 +451,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
speed: speed_val, speed: speed_val,
quantization: quant_val, quantization: quant_val,
sync_mode: sync_val, sync_mode: sync_val,
follow_up: follow_up_val,
}); });
ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::CloseModal);
} }

View File

@@ -8,7 +8,7 @@ pub use cagire_forth::{
Variables, Word, WordCompile, WORDS, Variables, Word, WordCompile, WORDS,
}; };
pub use cagire_project::{ pub use cagire_project::{
load, save, Bank, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, MAX_BANKS, load, save, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode,
MAX_PATTERNS, MAX_BANKS, MAX_PATTERNS,
}; };
pub use script::ScriptEngine; pub use script::ScriptEngine;

View File

@@ -60,7 +60,6 @@ pub fn update_cache(editor_ctx: &EditorContext) {
nudge_secs: 0.0, nudge_secs: 0.0,
cc_access: None, cc_access: None,
speed_key: "", speed_key: "",
chain_key: "",
mouse_x: 0.5, mouse_x: 0.5,
mouse_y: 0.5, mouse_y: 0.5,
mouse_down: 0.0, mouse_down: 0.0,

View File

@@ -24,26 +24,37 @@ pub enum PatternPropsField {
Speed, Speed,
Quantization, Quantization,
SyncMode, SyncMode,
FollowUp,
ChainBank,
ChainPattern,
} }
impl PatternPropsField { impl PatternPropsField {
pub fn next(&self) -> Self { pub fn next(&self, follow_up_is_chain: bool) -> Self {
match self { match self {
Self::Name => Self::Length, Self::Name => Self::Length,
Self::Length => Self::Speed, Self::Length => Self::Speed,
Self::Speed => Self::Quantization, Self::Speed => Self::Quantization,
Self::Quantization => Self::SyncMode, Self::Quantization => Self::SyncMode,
Self::SyncMode => Self::SyncMode, Self::SyncMode => Self::FollowUp,
Self::FollowUp if follow_up_is_chain => Self::ChainBank,
Self::FollowUp => Self::FollowUp,
Self::ChainBank => Self::ChainPattern,
Self::ChainPattern => Self::ChainPattern,
} }
} }
pub fn prev(&self) -> Self { pub fn prev(&self, follow_up_is_chain: bool) -> Self {
match self { match self {
Self::Name => Self::Name, Self::Name => Self::Name,
Self::Length => Self::Name, Self::Length => Self::Name,
Self::Speed => Self::Length, Self::Speed => Self::Length,
Self::Quantization => Self::Speed, Self::Quantization => Self::Speed,
Self::SyncMode => Self::Quantization, Self::SyncMode => Self::Quantization,
Self::FollowUp => Self::SyncMode,
Self::ChainBank => Self::FollowUp,
Self::ChainPattern if follow_up_is_chain => Self::ChainBank,
Self::ChainPattern => Self::FollowUp,
} }
} }
} }

View File

@@ -1,4 +1,4 @@
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField}; use crate::state::editor::{EuclideanField, PatternField, PatternPropsField};
use crate::state::file_browser::FileBrowserState; use crate::state::file_browser::FileBrowserState;
@@ -77,6 +77,7 @@ pub enum Modal {
speed: PatternSpeed, speed: PatternSpeed,
quantization: LaunchQuantization, quantization: LaunchQuantization,
sync_mode: SyncMode, sync_mode: SyncMode,
follow_up: FollowUp,
}, },
KeybindingsHelp { KeybindingsHelp {
scroll: usize, scroll: usize,

View File

@@ -1,5 +1,5 @@
use crate::engine::PatternChange; use crate::engine::PatternChange;
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
#[derive(Clone)] #[derive(Clone)]
@@ -21,6 +21,7 @@ pub struct StagedPropChange {
pub speed: PatternSpeed, pub speed: PatternSpeed,
pub quantization: LaunchQuantization, pub quantization: LaunchQuantization,
pub sync_mode: SyncMode, pub sync_mode: SyncMode,
pub follow_up: FollowUp,
} }
pub struct PlaybackState { pub struct PlaybackState {

View File

@@ -716,6 +716,8 @@ fn render_properties(
bank: usize, bank: usize,
pattern_idx: usize, pattern_idx: usize,
) { ) {
use cagire_project::FollowUp;
let theme = theme::get(); let theme = theme::get();
let pattern = &app.project_state.project.banks[bank].patterns[pattern_idx]; let pattern = &app.project_state.project.banks[bank].patterns[pattern_idx];
@@ -729,7 +731,7 @@ fn render_properties(
let label_style = Style::new().fg(theme.ui.text_muted); let label_style = Style::new().fg(theme.ui.text_muted);
let value_style = Style::new().fg(theme.ui.text_primary); let value_style = Style::new().fg(theme.ui.text_primary);
let rows: Vec<Line> = vec![ let mut rows: Vec<Line> = vec![
Line::from(vec![ Line::from(vec![
Span::styled(" Name ", label_style), Span::styled(" Name ", label_style),
Span::styled(name, value_style), Span::styled(name, value_style),
@@ -752,5 +754,17 @@ fn render_properties(
]), ]),
]; ];
if pattern.follow_up != FollowUp::Loop {
let follow_label = match pattern.follow_up {
FollowUp::Loop => unreachable!(),
FollowUp::Stop => "Stop".to_string(),
FollowUp::Chain { bank: b, pattern: p } => format!("Chain B{:02}:P{:02}", b + 1, p + 1),
};
rows.push(Line::from(vec![
Span::styled(" After ", label_style),
Span::styled(follow_label, value_style),
]));
}
frame.render_widget(Paragraph::new(rows), area); frame.render_widget(Paragraph::new(rows), area);
} }

View File

@@ -609,37 +609,45 @@ fn render_modal(
speed, speed,
quantization, quantization,
sync_mode, sync_mode,
follow_up,
} => { } => {
use crate::model::FollowUp;
use crate::state::PatternPropsField; use crate::state::PatternPropsField;
let is_chain = matches!(follow_up, FollowUp::Chain { .. });
let modal_height = if is_chain { 16 } else { 14 };
let inner = ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1)) let inner = ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1))
.width(50) .width(50)
.height(12) .height(modal_height)
.border_color(theme.modal.input) .border_color(theme.modal.input)
.render_centered(frame, term); .render_centered(frame, term);
let speed_label = speed.label(); let speed_label = speed.label();
let fields: Vec<(&str, &str, bool)> = vec![ let follow_up_label = match follow_up {
("Name", name.as_str(), *field == PatternPropsField::Name), FollowUp::Loop => "Loop".to_string(),
( FollowUp::Stop => "Stop".to_string(),
"Length", FollowUp::Chain { bank: b, pattern: p } => {
length.as_str(), format!("Chain B{:02}:P{:02}", b + 1, p + 1)
*field == PatternPropsField::Length, }
), };
("Speed", &speed_label, *field == PatternPropsField::Speed), let mut fields: Vec<(&str, String, bool)> = vec![
( ("Name", name.clone(), *field == PatternPropsField::Name),
"Quantization", ("Length", length.clone(), *field == PatternPropsField::Length),
quantization.label(), ("Speed", speed_label, *field == PatternPropsField::Speed),
*field == PatternPropsField::Quantization, ("Quantization", quantization.label().to_string(), *field == PatternPropsField::Quantization),
), ("Sync Mode", sync_mode.label().to_string(), *field == PatternPropsField::SyncMode),
( ("Follow Up", follow_up_label, *field == PatternPropsField::FollowUp),
"Sync Mode",
sync_mode.label(),
*field == PatternPropsField::SyncMode,
),
]; ];
if is_chain {
if let FollowUp::Chain { bank: b, pattern: p } = follow_up {
fields.push((" Bank", format!("{:02}", b + 1), *field == PatternPropsField::ChainBank));
fields.push((" Pattern", format!("{:02}", p + 1), *field == PatternPropsField::ChainPattern));
}
}
render_props_form(frame, inner, &fields); let fields_ref: Vec<(&str, &str, bool)> = fields.iter().map(|(l, v, s)| (*l, v.as_str(), *s)).collect();
render_props_form(frame, inner, &fields_ref);
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
let hints = hint_line(&[ let hints = hint_line(&[

View File

@@ -22,7 +22,6 @@ pub fn default_ctx() -> StepContext<'static> {
nudge_secs: 0.0, nudge_secs: 0.0,
cc_access: None, cc_access: None,
speed_key: "__speed_0_0__", speed_key: "__speed_0_0__",
chain_key: "__chain_0_0__",
mouse_x: 0.5, mouse_x: 0.5,
mouse_y: 0.5, mouse_y: 0.5,
mouse_down: 0.0, mouse_down: 0.0,