From c7a9f7bc5acf5ce249a897c185026c59953d6086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Wed, 28 Jan 2026 02:29:17 +0100 Subject: [PATCH] vastly improved selection system --- crates/forth/src/ops.rs | 1 + crates/forth/src/vm.rs | 10 + crates/forth/src/words.rs | 9 + src/app.rs | 387 +++++++---- src/commands.rs | 16 +- src/engine/sequencer.rs | 1288 +++++++++++++++++++++++++++++-------- src/input.rs | 145 ++++- src/main.rs | 1 + src/state/editor.rs | 35 +- src/state/mod.rs | 2 +- src/state/modal.rs | 6 + src/views/main_view.rs | 23 +- src/views/render.rs | 21 +- 13 files changed, 1507 insertions(+), 437 deletions(-) diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 0c5401b..936004e 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -89,4 +89,5 @@ pub enum Op { StackStart, EmitN, ClearCmd, + SetSpeed, } diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 63701b1..53eae43 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -625,6 +625,16 @@ impl Forth { .insert("__tempo__".to_string(), Value::Float(clamped, None)); } + Op::SetSpeed => { + let speed = stack.pop().ok_or("stack underflow")?.as_float()?; + let clamped = speed.clamp(0.125, 8.0); + let key = format!("__speed_{}_{}__", ctx.bank, ctx.pattern); + self.vars + .lock() + .unwrap() + .insert(key, Value::Float(clamped, None)); + } + Op::Chain => { let pattern = stack.pop().ok_or("stack underflow")?.as_int()? - 1; let bank = stack.pop().ok_or("stack underflow")?.as_int()? - 1; diff --git a/crates/forth/src/words.rs b/crates/forth/src/words.rs index ec85f29..283c168 100644 --- a/crates/forth/src/words.rs +++ b/crates/forth/src/words.rs @@ -755,6 +755,14 @@ pub const WORDS: &[Word] = &[ example: "140 tempo!", compile: Simple, }, + Word { + name: "speed!", + category: "Time", + stack: "(multiplier --)", + desc: "Set pattern speed multiplier", + example: "2.0 speed!", + compile: Simple, + }, Word { name: "chain", category: "Time", @@ -1909,6 +1917,7 @@ pub(super) fn simple_op(name: &str) -> Option { "_" => Op::Silence, "scale!" => Op::Scale, "tempo!" => Op::SetTempo, + "speed!" => Op::SetSpeed, "[" => Op::ListStart, "]" => Op::ListEnd, ">" => Op::ListEndCycle, diff --git a/src/app.rs b/src/app.rs index c3c669d..904397d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -187,15 +187,21 @@ impl App { self.load_step_to_editor(); } - pub fn toggle_step(&mut self) { + pub fn toggle_steps(&mut self) { let (bank, pattern) = self.current_bank_pattern(); - let change = pattern_editor::toggle_step( - &mut self.project_state.project, - bank, - pattern, - self.editor_ctx.step, - ); - self.project_state.mark_dirty(change.bank, change.pattern); + let indices: Vec = match self.editor_ctx.selection_range() { + Some(range) => range.collect(), + None => vec![self.editor_ctx.step], + }; + for idx in indices { + pattern_editor::toggle_step( + &mut self.project_state.project, + bank, + pattern, + idx, + ); + } + self.project_state.mark_dirty(bank, pattern); } pub fn length_increase(&mut self) { @@ -517,26 +523,6 @@ impl App { } } - pub fn copy_step(&mut self) { - let (bank, pattern) = self.current_bank_pattern(); - let step = self.editor_ctx.step; - let script = - pattern_editor::get_step_script(&self.project_state.project, bank, pattern, step); - - if let Some(script) = script { - if let Some(clip) = &mut self.clipboard { - if clip.set_text(&script).is_ok() { - self.editor_ctx.copied_step = Some(crate::state::CopiedStep { - bank, - pattern, - step, - }); - self.ui.set_status("Copied".to_string()); - } - } - } - } - pub fn delete_step(&mut self, bank: usize, pattern: usize, step: usize) { let pat = self.project_state.project.pattern_at_mut(bank, pattern); for s in &mut pat.steps { @@ -573,6 +559,46 @@ impl App { self.ui.flash("Step deleted", 150, FlashKind::Success); } + pub fn delete_steps(&mut self, bank: usize, pattern: usize, steps: &[usize]) { + for &step in steps { + let pat = self.project_state.project.pattern_at_mut(bank, pattern); + for s in &mut pat.steps { + if s.source == Some(step) { + s.source = None; + s.script.clear(); + s.command = None; + } + } + + let change = pattern_editor::set_step_script( + &mut self.project_state.project, + bank, + pattern, + step, + String::new(), + ); + if let Some(s) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step) + { + s.command = None; + s.source = None; + } + self.project_state.mark_dirty(change.bank, change.pattern); + } + if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern { + self.load_step_to_editor(); + } + self.editor_ctx.clear_selection(); + self.ui.flash( + &format!("{} steps deleted", steps.len()), + 150, + FlashKind::Success, + ); + } + pub fn reset_pattern(&mut self, bank: usize, pattern: usize) { self.project_state.project.banks[bank].patterns[pattern] = Pattern::default(); self.project_state.mark_dirty(bank, pattern); @@ -641,108 +667,235 @@ impl App { } } - pub fn paste_step(&mut self, link: &LinkState) { - let text = self - .clipboard - .as_mut() - .and_then(|clip| clip.get_text().ok()); + pub fn harden_steps(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + let indices: Vec = match self.editor_ctx.selection_range() { + Some(range) => range.collect(), + None => vec![self.editor_ctx.step], + }; - if let Some(text) = text { - let (bank, pattern) = self.current_bank_pattern(); - let change = pattern_editor::set_step_script( - &mut self.project_state.project, - bank, - pattern, - self.editor_ctx.step, - text, - ); - self.project_state.mark_dirty(change.bank, change.pattern); - self.load_step_to_editor(); - self.compile_current_step(link); + let pat = self.project_state.project.pattern_at(bank, pattern); + let resolutions: Vec<(usize, String)> = indices + .iter() + .filter_map(|&idx| { + let step = pat.step(idx)?; + step.source?; + let script = pat.resolve_script(idx)?.to_string(); + Some((idx, script)) + }) + .collect(); + + if resolutions.is_empty() { + self.ui.set_status("No linked steps to harden".to_string()); + return; + } + + let count = resolutions.len(); + for (idx, script) in resolutions { + if let Some(s) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(idx) + { + s.source = None; + s.script = script; + } + } + + self.project_state.mark_dirty(bank, pattern); + self.load_step_to_editor(); + self.editor_ctx.clear_selection(); + if count == 1 { + self.ui.flash("Step hardened", 150, FlashKind::Success); + } else { + self.ui.flash(&format!("{count} steps hardened"), 150, FlashKind::Success); } } - pub fn link_paste_step(&mut self) { - let Some(copied) = self.editor_ctx.copied_step else { + pub fn copy_steps(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + let pat = self.project_state.project.pattern_at(bank, pattern); + + let indices: Vec = match self.editor_ctx.selection_range() { + Some(range) => range.collect(), + None => vec![self.editor_ctx.step], + }; + + let mut steps = Vec::new(); + let mut scripts = Vec::new(); + for &idx in &indices { + if let Some(step) = pat.step(idx) { + let resolved = pat.resolve_script(idx).unwrap_or("").to_string(); + scripts.push(resolved.clone()); + steps.push(crate::state::CopiedStepData { + script: resolved, + active: step.active, + source: step.source, + original_index: idx, + }); + } + } + + let count = steps.len(); + self.editor_ctx.copied_steps = Some(crate::state::CopiedSteps { + bank, + pattern, + steps, + }); + + if let Some(clip) = &mut self.clipboard { + let _ = clip.set_text(scripts.join("\n")); + } + + self.ui.flash(&format!("Copied {count} steps"), 150, FlashKind::Info); + } + + pub fn paste_steps(&mut self, link: &LinkState) { + let Some(copied) = self.editor_ctx.copied_steps.clone() else { self.ui.set_status("Nothing copied".to_string()); return; }; let (bank, pattern) = self.current_bank_pattern(); - let step = self.editor_ctx.step; + let pat_len = self.project_state.project.pattern_at(bank, pattern).length; + let cursor = self.editor_ctx.step; - if copied.bank != bank || copied.pattern != pattern { - self.ui - .set_status("Can only link within same pattern".to_string()); - return; + let same_pattern = copied.bank == bank && copied.pattern == pattern; + for (i, data) in copied.steps.iter().enumerate() { + let target = cursor + i; + if target >= pat_len { + break; + } + if let Some(step) = self.project_state.project.pattern_at_mut(bank, pattern).step_mut(target) { + let source = if same_pattern { data.source } else { None }; + step.active = data.active; + step.source = source; + if source.is_some() { + step.script.clear(); + step.command = None; + } else { + step.script = data.script.clone(); + } + } } - if copied.step == step { - self.ui.set_status("Cannot link step to itself".to_string()); - return; - } - - let source_step = self - .project_state - .project - .pattern_at(bank, pattern) - .step(copied.step); - if source_step.map(|s| s.source.is_some()).unwrap_or(false) { - self.ui - .set_status("Cannot link to a linked step".to_string()); - return; - } - - if let Some(s) = self - .project_state - .project - .pattern_at_mut(bank, pattern) - .step_mut(step) - { - s.source = Some(copied.step); - s.script.clear(); - s.command = None; - } self.project_state.mark_dirty(bank, pattern); self.load_step_to_editor(); - self.ui.flash( - &format!("Linked to step {:02}", copied.step + 1), - 150, - FlashKind::Success, - ); + + // Compile affected steps + for i in 0..copied.steps.len() { + let target = cursor + i; + if target >= pat_len { + break; + } + let saved_step = self.editor_ctx.step; + self.editor_ctx.step = target; + self.compile_current_step(link); + self.editor_ctx.step = saved_step; + } + + self.editor_ctx.clear_selection(); + self.ui.flash(&format!("Pasted {} steps", copied.steps.len()), 150, FlashKind::Success); } - pub fn harden_step(&mut self) { - let (bank, pattern) = self.current_bank_pattern(); - let step = self.editor_ctx.step; - - let resolved_script = self - .project_state - .project - .pattern_at(bank, pattern) - .resolve_script(step) - .map(|s| s.to_string()); - - let Some(script) = resolved_script else { + pub fn link_paste_steps(&mut self) { + let Some(copied) = self.editor_ctx.copied_steps.clone() else { + self.ui.set_status("Nothing copied".to_string()); return; }; - if let Some(s) = self - .project_state - .project - .pattern_at_mut(bank, pattern) - .step_mut(step) - { - if s.source.is_none() { - self.ui.set_status("Step is not linked".to_string()); - return; - } - s.source = None; - s.script = script; + let (bank, pattern) = self.current_bank_pattern(); + + if copied.bank != bank || copied.pattern != pattern { + self.ui.set_status("Can only link within same pattern".to_string()); + return; } + + let pat_len = self.project_state.project.pattern_at(bank, pattern).length; + let cursor = self.editor_ctx.step; + + for (i, data) in copied.steps.iter().enumerate() { + let target = cursor + i; + if target >= pat_len { + break; + } + let source_idx = if data.source.is_some() { + // Original was linked, link to same source + data.source + } else { + Some(data.original_index) + }; + if source_idx == Some(target) { + continue; + } + if let Some(step) = self.project_state.project.pattern_at_mut(bank, pattern).step_mut(target) { + step.source = source_idx; + step.script.clear(); + step.command = None; + } + } + self.project_state.mark_dirty(bank, pattern); self.load_step_to_editor(); - self.ui.flash("Step hardened", 150, FlashKind::Success); + self.editor_ctx.clear_selection(); + self.ui.flash(&format!("Linked {} steps", copied.steps.len()), 150, FlashKind::Success); + } + + pub fn duplicate_steps(&mut self, link: &LinkState) { + let (bank, pattern) = self.current_bank_pattern(); + let pat = self.project_state.project.pattern_at(bank, pattern); + let pat_len = pat.length; + let indices: Vec = match self.editor_ctx.selection_range() { + Some(range) => range.collect(), + None => vec![self.editor_ctx.step], + }; + let count = indices.len(); + let paste_at = *indices.last().unwrap() + 1; + + let dupe_data: Vec<(bool, String, Option)> = indices + .iter() + .filter_map(|&idx| { + let step = pat.step(idx)?; + let script = pat.resolve_script(idx).unwrap_or("").to_string(); + let source = step.source; + Some((step.active, script, source)) + }) + .collect(); + + let mut pasted = 0; + for (i, (active, script, source)) in dupe_data.into_iter().enumerate() { + let target = paste_at + i; + if target >= pat_len { + break; + } + if let Some(step) = self.project_state.project.pattern_at_mut(bank, pattern).step_mut(target) { + step.active = active; + step.source = source; + if source.is_some() { + step.script.clear(); + step.command = None; + } else { + step.script = script; + step.command = None; + } + } + pasted += 1; + } + + self.project_state.mark_dirty(bank, pattern); + self.load_step_to_editor(); + + for i in 0..pasted { + let target = paste_at + i; + let saved = self.editor_ctx.step; + self.editor_ctx.step = target; + self.compile_current_step(link); + self.editor_ctx.step = saved; + } + + self.editor_ctx.clear_selection(); + self.ui.flash(&format!("Duplicated {count} steps"), 150, FlashKind::Success); } pub fn open_pattern_modal(&mut self, field: PatternField) { @@ -787,7 +940,7 @@ impl App { AppCommand::SelectEditPattern(pattern) => self.select_edit_pattern(pattern), // Pattern editing - AppCommand::ToggleStep => self.toggle_step(), + AppCommand::ToggleSteps => self.toggle_steps(), AppCommand::LengthIncrease => self.length_increase(), AppCommand::LengthDecrease => self.length_decrease(), AppCommand::SpeedIncrease => self.speed_increase(), @@ -836,6 +989,13 @@ impl App { } => { self.delete_step(bank, pattern, step); } + AppCommand::DeleteSteps { + bank, + pattern, + steps, + } => { + self.delete_steps(bank, pattern, &steps); + } AppCommand::ResetPattern { bank, pattern } => { self.reset_pattern(bank, pattern); } @@ -856,10 +1016,11 @@ impl App { } // Clipboard - AppCommand::CopyStep => self.copy_step(), - AppCommand::PasteStep => self.paste_step(link), - AppCommand::LinkPasteStep => self.link_paste_step(), - AppCommand::HardenStep => self.harden_step(), + AppCommand::HardenSteps => self.harden_steps(), + AppCommand::CopySteps => self.copy_steps(), + AppCommand::PasteSteps => self.paste_steps(link), + AppCommand::LinkPasteSteps => self.link_paste_steps(), + AppCommand::DuplicateSteps => self.duplicate_steps(link), // Pattern playback (staging) AppCommand::StagePatternToggle { bank, pattern } => { diff --git a/src/commands.rs b/src/commands.rs index e8eefce..0197b2e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -20,7 +20,7 @@ pub enum AppCommand { SelectEditPattern(usize), // Pattern editing - ToggleStep, + ToggleSteps, LengthIncrease, LengthDecrease, SpeedIncrease, @@ -45,6 +45,11 @@ pub enum AppCommand { pattern: usize, step: usize, }, + DeleteSteps { + bank: usize, + pattern: usize, + steps: Vec, + }, ResetPattern { bank: usize, pattern: usize, @@ -68,10 +73,11 @@ pub enum AppCommand { }, // Clipboard - CopyStep, - PasteStep, - LinkPasteStep, - HardenStep, + HardenSteps, + CopySteps, + PasteSteps, + LinkPasteSteps, + DuplicateSteps, // Pattern playback (staging) StagePatternToggle { diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 877b529..4430642 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -65,6 +65,7 @@ pub enum SeqCommand { pattern: usize, quantization: LaunchQuantization, }, + StopAll, Shutdown, } @@ -347,6 +348,402 @@ impl RunsCounter { } } +pub(crate) struct TickInput { + pub commands: Vec, + pub playing: bool, + pub beat: f64, + pub tempo: f64, + pub quantum: f64, + pub fill: bool, + pub nudge_secs: f64, +} + +pub(crate) struct TickOutput { + pub audio_commands: Vec, + pub new_tempo: Option, + pub shared_state: SharedSequencerState, +} + +struct StepResult { + audio_commands: Vec, + completed_iterations: Vec, + any_step_fired: bool, +} + +struct VariableReads { + new_tempo: Option, + chain_transitions: Vec<(PatternId, PatternId)>, +} + +fn parse_chain_target(s: &str) -> Option { + let (bank, pattern) = s.split_once(':')?; + Some(PatternId { + bank: bank.parse().ok()?, + pattern: pattern.parse().ok()?, + }) +} + +pub(crate) struct SequencerState { + audio_state: AudioState, + pattern_cache: PatternCache, + runs_counter: RunsCounter, + step_traces: HashMap<(usize, usize, usize), ExecutionTrace>, + event_count: usize, + dropped_events: usize, + script_engine: ScriptEngine, + variables: Variables, +} + +impl SequencerState { + pub fn new( + variables: Variables, + dict: Dictionary, + rng: Rng, + ) -> Self { + let script_engine = ScriptEngine::new(Arc::clone(&variables), dict, rng); + Self { + audio_state: AudioState::new(), + pattern_cache: PatternCache::new(), + runs_counter: RunsCounter::new(), + step_traces: HashMap::new(), + event_count: 0, + dropped_events: 0, + script_engine, + variables, + } + } + + fn process_commands(&mut self, commands: Vec) { + for cmd in commands { + match cmd { + SeqCommand::PatternUpdate { + bank, + pattern, + data, + } => { + self.pattern_cache.set(bank, pattern, data); + } + SeqCommand::PatternStart { + bank, + pattern, + quantization, + sync_mode, + } => { + let id = PatternId { bank, pattern }; + self.audio_state.pending_stops.retain(|p| p.id != id); + if !self.audio_state.pending_starts.iter().any(|p| p.id == id) { + self.audio_state.pending_starts.push(PendingPattern { + id, + quantization, + sync_mode, + }); + } + } + SeqCommand::PatternStop { + bank, + pattern, + quantization, + } => { + let id = PatternId { bank, pattern }; + self.audio_state.pending_starts.retain(|p| p.id != id); + if !self.audio_state.pending_stops.iter().any(|p| p.id == id) { + self.audio_state.pending_stops.push(PendingPattern { + id, + quantization, + sync_mode: SyncMode::Reset, + }); + } + } + SeqCommand::StopAll => { + self.audio_state.active_patterns.clear(); + self.audio_state.pending_starts.clear(); + self.audio_state.pending_stops.clear(); + self.step_traces.clear(); + self.runs_counter.counts.clear(); + } + SeqCommand::Shutdown => {} + } + } + } + + pub fn tick(&mut self, input: TickInput) -> TickOutput { + self.process_commands(input.commands); + + if !input.playing { + return self.tick_paused(); + } + + let beat = input.beat; + let prev_beat = self.audio_state.prev_beat; + + let activated = self.activate_pending(beat, prev_beat, input.quantum); + self.audio_state.pending_starts.retain(|p| !activated.contains(&p.id)); + + let stopped = self.deactivate_pending(beat, prev_beat, input.quantum); + self.audio_state.pending_stops.retain(|p| !stopped.contains(&p.id)); + + let steps = self.execute_steps(beat, prev_beat, input.tempo, input.quantum, input.fill, input.nudge_secs); + + let vars = self.read_variables(&steps.completed_iterations, &stopped, steps.any_step_fired); + self.apply_chain_transitions(vars.chain_transitions); + + self.audio_state.prev_beat = beat; + + TickOutput { + audio_commands: steps.audio_commands, + new_tempo: vars.new_tempo, + shared_state: self.build_shared_state(), + } + } + + fn tick_paused(&mut self) -> TickOutput { + for pending in self.audio_state.pending_stops.drain(..) { + self.audio_state.active_patterns.remove(&pending.id); + self.step_traces.retain(|&(bank, pattern, _), _| { + bank != pending.id.bank || pattern != pending.id.pattern + }); + } + self.audio_state.pending_starts.clear(); + TickOutput { + audio_commands: Vec::new(), + new_tempo: None, + shared_state: self.build_shared_state(), + } + } + + fn activate_pending(&mut self, beat: f64, prev_beat: f64, quantum: f64) -> Vec { + let mut activated = Vec::new(); + for pending in &self.audio_state.pending_starts { + if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) { + let start_step = match pending.sync_mode { + SyncMode::Reset => 0, + SyncMode::PhaseLock => { + if let Some(pat) = self.pattern_cache.get(pending.id.bank, pending.id.pattern) { + let speed_mult = pat.speed.multiplier(); + ((beat * 4.0 * speed_mult) as usize) % pat.length + } else { + 0 + } + } + }; + self.audio_state.active_patterns.insert( + pending.id, + ActivePattern { + bank: pending.id.bank, + pattern: pending.id.pattern, + step_index: start_step, + iter: 0, + }, + ); + activated.push(pending.id); + } + } + activated + } + + fn deactivate_pending(&mut self, beat: f64, prev_beat: f64, quantum: f64) -> Vec { + let mut stopped = Vec::new(); + for pending in &self.audio_state.pending_stops { + if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) { + self.audio_state.active_patterns.remove(&pending.id); + self.step_traces.retain(|&(bank, pattern, _), _| { + bank != pending.id.bank || pattern != pending.id.pattern + }); + stopped.push(pending.id); + } + } + stopped + } + + fn execute_steps( + &mut self, + beat: f64, + prev_beat: f64, + tempo: f64, + quantum: f64, + fill: bool, + nudge_secs: f64, + ) -> StepResult { + let mut result = StepResult { + audio_commands: Vec::new(), + completed_iterations: Vec::new(), + any_step_fired: false, + }; + + let speed_overrides: HashMap<(usize, usize), f64> = { + let vars = self.variables.lock().unwrap(); + self.audio_state + .active_patterns + .keys() + .filter_map(|id| { + let key = format!("__speed_{}_{}__", id.bank, id.pattern); + vars.get(&key) + .and_then(|v| v.as_float().ok()) + .map(|v| ((id.bank, id.pattern), v)) + }) + .collect() + }; + + for (_id, active) in self.audio_state.active_patterns.iter_mut() { + let Some(pattern) = self.pattern_cache.get(active.bank, active.pattern) else { + continue; + }; + + let speed_mult = speed_overrides + .get(&(active.bank, active.pattern)) + .copied() + .unwrap_or_else(|| pattern.speed.multiplier()); + let beat_int = (beat * 4.0 * speed_mult).floor() as i64; + let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64; + + if beat_int != prev_beat_int && prev_beat >= 0.0 { + result.any_step_fired = true; + let step_idx = active.step_index % pattern.length; + + if let Some(step) = pattern.steps.get(step_idx) { + let resolved_script = pattern.resolve_script(step_idx); + let has_script = resolved_script + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if step.active && has_script { + let source_idx = pattern.resolve_source(step_idx); + let runs = self.runs_counter.get_and_increment( + active.bank, + active.pattern, + source_idx, + ); + let ctx = StepContext { + step: step_idx, + beat, + bank: active.bank, + pattern: active.pattern, + tempo, + phase: beat % quantum, + slot: 0, + runs, + iter: active.iter, + speed: speed_mult, + fill, + nudge_secs, + }; + if let Some(script) = resolved_script { + let mut trace = ExecutionTrace::default(); + if let Ok(cmds) = self + .script_engine + .evaluate_with_trace(script, &ctx, &mut trace) + { + self.step_traces.insert( + (active.bank, active.pattern, source_idx), + std::mem::take(&mut trace), + ); + for cmd in cmds { + self.event_count += 1; + result.audio_commands.push(cmd); + } + } + } + } + } + + let next_step = active.step_index + 1; + if next_step >= pattern.length { + active.iter += 1; + result.completed_iterations.push(PatternId { + bank: active.bank, + pattern: active.pattern, + }); + } + active.step_index = next_step % pattern.length; + } + } + + result + } + + fn read_variables( + &self, + completed: &[PatternId], + stopped: &[PatternId], + any_step_fired: bool, + ) -> VariableReads { + let needs_access = !completed.is_empty() || !stopped.is_empty() || any_step_fired; + if !needs_access { + return VariableReads { + new_tempo: None, + chain_transitions: Vec::new(), + }; + } + + let mut vars = self.variables.lock().unwrap(); + let new_tempo = vars.remove("__tempo__").and_then(|v| v.as_float().ok()); + + let mut chain_transitions = Vec::new(); + for id in completed { + let chain_key = format!("__chain_{}_{}__", id.bank, id.pattern); + if let Some(Value::Str(s, _)) = vars.get(&chain_key) { + if let Some(target) = parse_chain_target(s) { + chain_transitions.push((*id, target)); + } + } + vars.remove(&chain_key); + } + + for id in stopped { + let chain_key = format!("__chain_{}_{}__", id.bank, id.pattern); + vars.remove(&chain_key); + } + + VariableReads { + new_tempo, + chain_transitions, + } + } + + fn apply_chain_transitions(&mut self, transitions: Vec<(PatternId, PatternId)>) { + for (source, target) in transitions { + if !self.audio_state.pending_stops.iter().any(|p| p.id == source) { + self.audio_state.pending_stops.push(PendingPattern { + id: source, + quantization: LaunchQuantization::Bar, + sync_mode: SyncMode::Reset, + }); + } + if !self.audio_state.pending_starts.iter().any(|p| p.id == target) { + let (quant, sync) = self + .pattern_cache + .get(target.bank, target.pattern) + .map(|p| (p.quantization, p.sync_mode)) + .unwrap_or((LaunchQuantization::Bar, SyncMode::Reset)); + self.audio_state.pending_starts.push(PendingPattern { + id: target, + quantization: quant, + sync_mode: sync, + }); + } + } + } + + fn build_shared_state(&self) -> SharedSequencerState { + SharedSequencerState { + active_patterns: self + .audio_state + .active_patterns + .values() + .map(|a| ActivePatternState { + bank: a.bank, + pattern: a.pattern, + step_index: a.step_index, + iter: a.iter, + }) + .collect(), + step_traces: self.step_traces.clone(), + event_count: self.event_count, + dropped_events: self.dropped_events, + } + } +} + #[allow(clippy::too_many_arguments)] fn sequencer_loop( cmd_rx: Receiver, @@ -365,64 +762,15 @@ fn sequencer_loop( let _ = set_current_thread_priority(ThreadPriority::Max); - let script_engine = ScriptEngine::new(Arc::clone(&variables), dict, rng); - let mut audio_state = AudioState::new(); - let mut pattern_cache = PatternCache::new(); - let mut runs_counter = RunsCounter::new(); - let mut step_traces: HashMap<(usize, usize, usize), ExecutionTrace> = HashMap::new(); - let mut event_count: usize = 0; - let mut dropped_events: usize = 0; + let mut seq_state = SequencerState::new(variables, dict, rng); loop { + let mut commands = Vec::new(); while let Ok(cmd) = cmd_rx.try_recv() { - match cmd { - SeqCommand::PatternUpdate { - bank, - pattern, - data, - } => { - pattern_cache.set(bank, pattern, data); - } - SeqCommand::PatternStart { - bank, - pattern, - quantization, - sync_mode, - } => { - let id = PatternId { bank, pattern }; - audio_state.pending_stops.retain(|p| p.id != id); - if !audio_state.pending_starts.iter().any(|p| p.id == id) { - audio_state.pending_starts.push(PendingPattern { - id, - quantization, - sync_mode, - }); - } - } - SeqCommand::PatternStop { - bank, - pattern, - quantization, - } => { - let id = PatternId { bank, pattern }; - audio_state.pending_starts.retain(|p| p.id != id); - if !audio_state.pending_stops.iter().any(|p| p.id == id) { - audio_state.pending_stops.push(PendingPattern { - id, - quantization, - sync_mode: SyncMode::Reset, - }); - } - } - SeqCommand::Shutdown => { - return; - } + if matches!(cmd, SeqCommand::Shutdown) { + return; } - } - - if !playing.load(Ordering::Relaxed) { - thread::sleep(Duration::from_micros(200)); - continue; + commands.push(cmd); } let state = link.capture_app_state(); @@ -430,244 +778,620 @@ fn sequencer_loop( let beat = state.beat_at_time(time, quantum); let tempo = state.tempo(); - let prev_beat = audio_state.prev_beat; - let mut stopped_chain_keys: Vec = Vec::new(); + let input = TickInput { + commands, + playing: playing.load(Ordering::Relaxed), + beat, + tempo, + quantum, + fill: live_keys.fill(), + nudge_secs: nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0, + }; - // Process pending starts with per-pattern quantization - let mut started_ids: Vec = Vec::new(); - for pending in &audio_state.pending_starts { - let should_start = - check_quantization_boundary(pending.quantization, beat, prev_beat, quantum); - if should_start { - let start_step = match pending.sync_mode { - SyncMode::Reset => 0, - SyncMode::PhaseLock => { - if let Some(pat) = 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 - } - } - }; - audio_state.active_patterns.insert( - pending.id, - ActivePattern { - bank: pending.id.bank, - pattern: pending.id.pattern, - step_index: start_step, - iter: 0, - }, - ); - started_ids.push(pending.id); - } - } - audio_state - .pending_starts - .retain(|p| !started_ids.contains(&p.id)); + let output = seq_state.tick(input); - // Process pending stops with per-pattern quantization - let mut stopped_ids: Vec = Vec::new(); - for pending in &audio_state.pending_stops { - let should_stop = - check_quantization_boundary(pending.quantization, beat, prev_beat, quantum); - if should_stop { - audio_state.active_patterns.remove(&pending.id); - step_traces.retain(|&(bank, pattern, _), _| { - bank != pending.id.bank || pattern != pending.id.pattern - }); - stopped_chain_keys.push(format!( - "__chain_{}_{}__", - pending.id.bank, pending.id.pattern - )); - stopped_ids.push(pending.id); - } - } - audio_state - .pending_stops - .retain(|p| !stopped_ids.contains(&p.id)); - - let mut chain_transitions: Vec<(PatternId, PatternId)> = Vec::new(); - let mut chain_keys_to_remove: Vec = Vec::new(); - let mut new_tempo: Option = None; - - for (_id, active) in audio_state.active_patterns.iter_mut() { - let Some(pattern) = pattern_cache.get(active.bank, active.pattern) else { - continue; - }; - - let speed_mult = pattern.speed.multiplier(); - let beat_int = (beat * 4.0 * speed_mult).floor() as i64; - let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64; - - if beat_int != prev_beat_int && prev_beat >= 0.0 { - let step_idx = active.step_index % pattern.length; - - if let Some(step) = pattern.steps.get(step_idx) { - let resolved_script = pattern.resolve_script(step_idx); - let has_script = resolved_script - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); - - if step.active && has_script { - let source_idx = pattern.resolve_source(step_idx); - let runs = - runs_counter.get_and_increment(active.bank, active.pattern, source_idx); - let nudge_secs = nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0; - let ctx = StepContext { - step: step_idx, - beat, - bank: active.bank, - pattern: active.pattern, - tempo, - phase: beat % quantum, - slot: 0, - runs, - iter: active.iter, - speed: speed_mult, - fill: live_keys.fill(), - nudge_secs, - }; - if let Some(script) = resolved_script { - let mut trace = ExecutionTrace::default(); - if let Ok(cmds) = - script_engine.evaluate_with_trace(script, &ctx, &mut trace) - { - step_traces.insert( - (active.bank, active.pattern, source_idx), - std::mem::take(&mut trace), - ); - for cmd in cmds { - match audio_tx.load().try_send(AudioCommand::Evaluate(cmd)) { - Ok(()) => { - event_count += 1; - } - Err(TrySendError::Full(_)) => { - dropped_events += 1; - } - Err(TrySendError::Disconnected(_)) => { - // Channel disconnected means old stream is gone, but - // a new one will be swapped in. Don't exit - just skip. - dropped_events += 1; - } - } - } - // Defer tempo check to batched variable read - new_tempo = None; // Will be read in batch below - } - } - } - } - - let next_step = active.step_index + 1; - if next_step >= pattern.length { - active.iter += 1; - let chain_key = format!("__chain_{}_{}__", active.bank, active.pattern); - chain_keys_to_remove.push(chain_key); - } - active.step_index = next_step % pattern.length; - } - } - - // Batched variable operations: read chain targets, check tempo, remove keys - let needs_var_access = !chain_keys_to_remove.is_empty() || !stopped_chain_keys.is_empty(); - if needs_var_access { - let mut vars = variables.lock().unwrap(); - - // Check for tempo change - if let Some(t) = vars.remove("__tempo__").and_then(|v| v.as_float().ok()) { - new_tempo = Some(t); - } - - // Read chain targets and queue transitions - for key in &chain_keys_to_remove { - if let Some(Value::Str(s, _)) = vars.get(key) { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() == 2 { - if let (Ok(b), Ok(p)) = - (parts[0].parse::(), parts[1].parse::()) - { - let target = PatternId { - bank: b, - pattern: p, - }; - // Extract bank/pattern from key: "__chain_{bank}_{pattern}__" - if let Some(rest) = key.strip_prefix("__chain_") { - if let Some(rest) = rest.strip_suffix("__") { - let kparts: Vec<&str> = rest.split('_').collect(); - if kparts.len() == 2 { - if let (Ok(sb), Ok(sp)) = - (kparts[0].parse::(), kparts[1].parse::()) - { - let source = PatternId { - bank: sb, - pattern: sp, - }; - chain_transitions.push((source, target)); - } - } - } - } - } - } + for cmd in output.audio_commands { + match audio_tx.load().try_send(AudioCommand::Evaluate(cmd)) { + Ok(()) => {} + Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => { + // Lags one tick in shared state: build_shared_state() already ran + seq_state.dropped_events += 1; } } - - // Remove all chain keys (both from stopped patterns and completed iterations) - for key in chain_keys_to_remove { - vars.remove(&key); - } - for key in stopped_chain_keys { - vars.remove(&key); - } } - // Apply tempo change - if let Some(t) = new_tempo { + if let Some(t) = output.new_tempo { link.set_tempo(t); } - // Apply chain transitions (use Bar quantization for chains) - for (source, target) in chain_transitions { - if !audio_state.pending_stops.iter().any(|p| p.id == source) { - audio_state.pending_stops.push(PendingPattern { - id: source, - quantization: LaunchQuantization::Bar, - sync_mode: SyncMode::Reset, - }); - } - if !audio_state.pending_starts.iter().any(|p| p.id == target) { - let (quant, sync) = pattern_cache - .get(target.bank, target.pattern) - .map(|p| (p.quantization, p.sync_mode)) - .unwrap_or((LaunchQuantization::Bar, SyncMode::Reset)); - audio_state.pending_starts.push(PendingPattern { - id: target, - quantization: quant, - sync_mode: sync, - }); - } - } - - let new_state = SharedSequencerState { - active_patterns: 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: step_traces.clone(), - event_count, - dropped_events, - }; - shared_state.store(Arc::new(new_state)); - - audio_state.prev_beat = beat; + shared_state.store(Arc::new(output.shared_state)); thread::sleep(Duration::from_micros(200)); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + fn make_state() -> SequencerState { + let variables: Variables = Arc::new(Mutex::new(HashMap::new())); + let dict: Dictionary = Arc::new(Mutex::new(HashMap::new())); + let rng: Rng = Arc::new(Mutex::new( + ::seed_from_u64(0), + )); + SequencerState::new(variables, dict, rng) + } + + fn simple_pattern(length: usize) -> PatternSnapshot { + PatternSnapshot { + speed: Default::default(), + length, + steps: (0..length) + .map(|_| StepSnapshot { + active: true, + script: "test".into(), + source: None, + }) + .collect(), + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + } + } + + fn pid(bank: usize, pattern: usize) -> PatternId { + PatternId { bank, pattern } + } + + fn tick_at(beat: f64, playing: bool) -> TickInput { + TickInput { + commands: Vec::new(), + playing, + beat, + tempo: 120.0, + quantum: 4.0, + fill: false, + nudge_secs: 0.0, + } + } + + fn tick_with(commands: Vec, beat: f64) -> TickInput { + TickInput { + commands, + playing: true, + beat, + tempo: 120.0, + quantum: 4.0, + fill: false, + nudge_secs: 0.0, + } + } + + #[test] + fn test_stop_all_clears_everything() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: simple_pattern(4), + }], + 0.0, + )); + + let output = state.tick(tick_with( + vec![ + SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: simple_pattern(4), + }, + SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }, + ], + 1.0, + )); + assert!(!output.shared_state.active_patterns.is_empty()); + + let output = state.tick(tick_with(vec![SeqCommand::StopAll], 1.5)); + assert!(output.shared_state.active_patterns.is_empty()); + } + + #[test] + fn test_stops_apply_while_paused() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: simple_pattern(4), + }], + 0.0, + )); + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 1.0, + )); + + assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); + + let output = state.tick(TickInput { + commands: vec![SeqCommand::PatternStop { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Bar, + }], + playing: false, + ..tick_at(1.5, false) + }); + + assert!(output.shared_state.active_patterns.is_empty()); + assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); + } + + #[test] + fn test_chain_requires_active_source() { + let mut state = make_state(); + + // Set up: pattern 0 (length 1) chains to pattern 1 + state.tick(tick_with( + vec![ + SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: simple_pattern(1), + }, + SeqCommand::PatternUpdate { + bank: 0, + pattern: 1, + data: simple_pattern(4), + }, + ], + 0.0, + )); + + // Start pattern 0 + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 0.5, + )); + + // Set chain variable + { + let mut vars = state.variables.lock().unwrap(); + vars.insert( + "__chain_0_0__".to_string(), + Value::Str("0:1".to_string(), None), + ); + } + + // Pattern 0 completes iteration AND gets stopped immediately in the same tick. + // The stop removes it from active_patterns before chain evaluation, + // so the chain guard (active_patterns.contains_key) blocks the transition. + let output = state.tick(tick_with( + vec![SeqCommand::PatternStop { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Immediate, + }], + 1.0, + )); + + assert!(output.shared_state.active_patterns.is_empty()); + assert!(!state.audio_state.pending_starts.iter().any(|p| p.id == pid(0, 1))); + } + + #[test] + fn test_pattern_start_cancels_pending_stop() { + let mut state = make_state(); + + state.tick(tick_with( + vec![ + SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: simple_pattern(4), + }, + SeqCommand::PatternStop { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Bar, + }, + ], + 0.0, + )); + + assert!(!state.audio_state.pending_stops.is_empty()); + + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 0.5, + )); + + assert!(state.audio_state.pending_stops.is_empty()); + } + + #[test] + fn test_quantization_boundaries() { + assert!(check_quantization_boundary( + LaunchQuantization::Immediate, 1.5, 1.0, 4.0 + )); + assert!(check_quantization_boundary( + LaunchQuantization::Beat, 2.0, 1.9, 4.0 + )); + assert!(!check_quantization_boundary( + LaunchQuantization::Beat, 1.5, 1.2, 4.0 + )); + assert!(check_quantization_boundary( + LaunchQuantization::Bar, 4.0, 3.9, 4.0 + )); + assert!(!check_quantization_boundary( + LaunchQuantization::Bar, 3.5, 3.2, 4.0 + )); + assert!(!check_quantization_boundary( + LaunchQuantization::Immediate, 1.0, -1.0, 4.0 + )); + } + + #[test] + fn test_pattern_lifecycle() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, pattern: 0, data: simple_pattern(2), + }], + 0.0, + )); + + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 0.5, + )); + + let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); + assert_eq!(ap.step_index, 1); + assert_eq!(ap.iter, 0); + + state.tick(tick_at(0.75, true)); + let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); + assert_eq!(ap.step_index, 0); + assert_eq!(ap.iter, 1); + + state.tick(tick_at(1.0, true)); + let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); + assert_eq!(ap.step_index, 1); + assert_eq!(ap.iter, 1); + } + + #[test] + fn test_speed_multiplier_step_advance() { + let mut state = make_state(); + + let mut pat = simple_pattern(8); + pat.speed = crate::model::PatternSpeed::Double; + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: pat }], + 0.0, + )); + + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 0.5, + )); + + state.tick(tick_at(0.625, true)); + let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); + assert_eq!(ap.step_index, 2); + } + + #[test] + fn test_start_and_stop_same_pattern_same_tick() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, pattern: 0, data: simple_pattern(4), + }], + 0.0, + )); + + // Start then stop in same batch: stop cancels the start + state.tick(tick_with( + vec![ + SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }, + SeqCommand::PatternStop { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Immediate, + }, + ], + 1.0, + )); + + assert!(state.audio_state.pending_starts.is_empty()); + assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); + } + + #[test] + fn test_stop_during_iteration_blocks_chain() { + let mut state = make_state(); + + state.tick(tick_with( + vec![ + SeqCommand::PatternUpdate { + bank: 0, pattern: 0, data: simple_pattern(1), + }, + SeqCommand::PatternUpdate { + bank: 0, pattern: 1, data: simple_pattern(4), + }, + SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }, + ], + 0.0, + )); + + // Pattern 0 is now pending (will activate next tick when prev_beat >= 0) + // Advance so it activates + state.tick(tick_at(0.5, true)); + assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); + + // Set chain: 0:0 -> 0:1 + { + let mut vars = state.variables.lock().unwrap(); + vars.insert( + "__chain_0_0__".to_string(), + Value::Str("0:1".to_string(), None), + ); + } + + // Pattern 0 (length 1) completes iteration at beat=1.0 AND + // an immediate stop removes it from active_patterns first. + // Chain guard should block transition to pattern 1. + state.tick(tick_with( + vec![SeqCommand::PatternStop { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Immediate, + }], + 1.0, + )); + + assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 1))); + assert!(!state.audio_state.pending_starts.iter().any(|p| p.id == pid(0, 1))); + } + + #[test] + fn test_multiple_patterns_independent_quantization() { + let mut state = make_state(); + + state.tick(tick_with( + vec![ + SeqCommand::PatternUpdate { + bank: 0, pattern: 0, data: simple_pattern(4), + }, + SeqCommand::PatternUpdate { + bank: 0, pattern: 1, data: simple_pattern(4), + }, + SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Bar, + sync_mode: SyncMode::Reset, + }, + SeqCommand::PatternStart { + bank: 0, pattern: 1, + quantization: LaunchQuantization::Beat, + sync_mode: SyncMode::Reset, + }, + ], + 0.0, + )); + + // Beat boundary at 1.0: Beat-quantized pattern activates, Bar does not + state.tick(tick_at(1.0, true)); + assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); + assert!(state.audio_state.active_patterns.contains_key(&pid(0, 1))); + + // Bar boundary at 4.0: Bar-quantized pattern also activates + state.tick(tick_at(2.0, true)); + state.tick(tick_at(3.0, true)); + state.tick(tick_at(4.0, true)); + assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); + assert!(state.audio_state.active_patterns.contains_key(&pid(0, 1))); + } + + #[test] + fn test_pattern_update_while_running() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, pattern: 0, data: simple_pattern(4), + }], + 0.0, + )); + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 0.5, + )); + + // Advance to step_index=3 + state.tick(tick_at(0.75, true)); + state.tick(tick_at(1.0, true)); + let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); + assert_eq!(ap.step_index, 3); + + // Update pattern to length 2 while running — step_index wraps via modulo + // beat=1.25: beat_int=5, prev=4, step fires. step_index=3%2=1 fires, advances to (3+1)%2=0 + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, pattern: 0, data: simple_pattern(2), + }], + 1.25, + )); + let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); + assert_eq!(ap.step_index, 0); + + // beat=1.5: beat_int=6, prev=5, step fires. step_index=0 fires, advances to 1 + state.tick(tick_at(1.5, true)); + let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); + assert_eq!(ap.step_index, 1); + } + + #[test] + fn test_start_while_paused_is_discarded() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, pattern: 0, data: simple_pattern(4), + }], + 0.0, + )); + + // Start while paused: pending_starts gets cleared + state.tick(TickInput { + commands: vec![SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + ..tick_at(1.0, false) + }); + + assert!(state.audio_state.pending_starts.is_empty()); + + // Resume playing — pattern should NOT be active + state.tick(tick_at(2.0, true)); + assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); + } + + #[test] + fn test_resuming_after_pause_preserves_active() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, pattern: 0, data: simple_pattern(4), + }], + 0.0, + )); + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 1.0, + )); + assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); + + // Pause (no stop commands) + state.tick(tick_at(2.0, false)); + assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); + + // Resume + state.tick(tick_at(3.0, true)); + assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); + } + + #[test] + fn test_duplicate_start_commands_ignored() { + let mut state = make_state(); + + state.tick(tick_with( + vec![ + SeqCommand::PatternUpdate { + bank: 0, pattern: 0, data: simple_pattern(4), + }, + SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Bar, + sync_mode: SyncMode::Reset, + }, + SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Bar, + sync_mode: SyncMode::Reset, + }, + ], + 0.0, + )); + + let pending_count = state.audio_state.pending_starts + .iter() + .filter(|p| p.id == pid(0, 0)) + .count(); + assert_eq!(pending_count, 1); + } + + #[test] + fn test_tempo_applies_without_iteration_complete() { + let mut state = make_state(); + + // Pattern of length 16 — won't complete iteration for many ticks + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, pattern: 0, data: simple_pattern(16), + }], + 0.0, + )); + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + 0.5, + )); + + // Script fires at beat 1.0 (step 0). Set __tempo__ as if the script did. + { + let mut vars = state.variables.lock().unwrap(); + vars.insert("__tempo__".to_string(), Value::Float(140.0, None)); + } + + let output = state.tick(tick_at(1.0, true)); + assert_eq!(output.new_tempo, Some(140.0)); + } +} diff --git a/src/input.rs b/src/input.rs index 656aab1..afb2a90 100644 --- a/src/input.rs +++ b/src/input.rs @@ -7,7 +7,7 @@ use std::time::{Duration, Instant}; use crate::app::App; use crate::commands::AppCommand; -use crate::engine::{AudioCommand, LinkState, SequencerSnapshot}; +use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; use crate::state::{ @@ -26,6 +26,7 @@ pub struct InputContext<'a> { pub snapshot: &'a SequencerSnapshot, pub playing: &'a Arc, pub audio_tx: &'a ArcSwap>, + pub seq_cmd_tx: &'a Sender, pub nudge_us: &'a Arc, } @@ -140,6 +141,49 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { _ => {} } } + Modal::ConfirmDeleteSteps { + bank, + pattern, + steps, + selected: _, + } => { + let (bank, pattern, steps) = (*bank, *pattern, steps.clone()); + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + ctx.dispatch(AppCommand::DeleteSteps { + bank, + pattern, + steps, + }); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Left | KeyCode::Right => { + if let Modal::ConfirmDeleteSteps { selected, .. } = &mut ctx.app.ui.modal { + *selected = !*selected; + } + } + KeyCode::Enter => { + let do_delete = + if let Modal::ConfirmDeleteSteps { selected, .. } = &ctx.app.ui.modal { + *selected + } else { + false + }; + if do_delete { + ctx.dispatch(AppCommand::DeleteSteps { + bank, + pattern, + steps, + }); + } + ctx.dispatch(AppCommand::CloseModal); + } + _ => {} + } + } Modal::ConfirmResetPattern { bank, pattern, @@ -650,6 +694,8 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult { + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + match key.code { KeyCode::Tab => { if ctx.app.panel.visible { @@ -674,12 +720,54 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); } - KeyCode::Left => ctx.dispatch(AppCommand::PrevStep), - KeyCode::Right => ctx.dispatch(AppCommand::NextStep), - KeyCode::Up => ctx.dispatch(AppCommand::StepUp), - KeyCode::Down => ctx.dispatch(AppCommand::StepDown), - KeyCode::Enter => ctx.dispatch(AppCommand::OpenModal(Modal::Editor)), - KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleStep), + KeyCode::Left if shift && !ctrl => { + if ctx.app.editor_ctx.selection_anchor.is_none() { + ctx.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step); + } + ctx.dispatch(AppCommand::PrevStep); + } + KeyCode::Right if shift && !ctrl => { + if ctx.app.editor_ctx.selection_anchor.is_none() { + ctx.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step); + } + ctx.dispatch(AppCommand::NextStep); + } + KeyCode::Up if shift && !ctrl => { + if ctx.app.editor_ctx.selection_anchor.is_none() { + ctx.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step); + } + ctx.dispatch(AppCommand::StepUp); + } + KeyCode::Down if shift && !ctrl => { + if ctx.app.editor_ctx.selection_anchor.is_none() { + ctx.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step); + } + ctx.dispatch(AppCommand::StepDown); + } + KeyCode::Left => { + ctx.app.editor_ctx.clear_selection(); + ctx.dispatch(AppCommand::PrevStep); + } + KeyCode::Right => { + ctx.app.editor_ctx.clear_selection(); + ctx.dispatch(AppCommand::NextStep); + } + KeyCode::Up => { + ctx.app.editor_ctx.clear_selection(); + ctx.dispatch(AppCommand::StepUp); + } + KeyCode::Down => { + ctx.app.editor_ctx.clear_selection(); + ctx.dispatch(AppCommand::StepDown); + } + KeyCode::Esc => { + ctx.app.editor_ctx.clear_selection(); + } + KeyCode::Enter => { + ctx.app.editor_ctx.clear_selection(); + ctx.dispatch(AppCommand::OpenModal(Modal::Editor)); + } + KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleSteps), KeyCode::Char('s') => { use crate::state::file_browser::FileBrowserState; let initial = ctx @@ -692,10 +780,19 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR let state = FileBrowserState::new_save(initial); ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(state))); } - KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep), - KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep), - KeyCode::Char('b') if ctrl => ctx.dispatch(AppCommand::LinkPasteStep), - KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenStep), + KeyCode::Char('c') if ctrl => { + ctx.dispatch(AppCommand::CopySteps); + } + KeyCode::Char('v') if ctrl => { + ctx.dispatch(AppCommand::PasteSteps); + } + KeyCode::Char('b') if ctrl => { + ctx.dispatch(AppCommand::LinkPasteSteps); + } + KeyCode::Char('d') if ctrl => { + ctx.dispatch(AppCommand::DuplicateSteps); + } + KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenSteps), KeyCode::Char('l') => { use crate::state::file_browser::FileBrowserState; let default_dir = ctx @@ -730,13 +827,23 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR KeyCode::Char('p') => ctx.dispatch(AppCommand::OpenModal(Modal::Preview)), KeyCode::Delete | KeyCode::Backspace => { let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); - let step = ctx.app.editor_ctx.step; - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteStep { - bank, - pattern, - step, - selected: false, - })); + if let Some(range) = ctx.app.editor_ctx.selection_range() { + let steps: Vec = range.collect(); + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteSteps { + bank, + pattern, + steps, + selected: false, + })); + } else { + let step = ctx.app.editor_ctx.step; + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteStep { + bank, + pattern, + step, + selected: false, + })); + } } _ => {} } @@ -987,9 +1094,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } KeyCode::Char('h') => { let _ = ctx.audio_tx.load().send(AudioCommand::Hush); + let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); } KeyCode::Char('p') => { let _ = ctx.audio_tx.load().send(AudioCommand::Panic); + let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); } KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0, KeyCode::Char('t') => { diff --git a/src/main.rs b/src/main.rs index 0087b88..85e5f85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -231,6 +231,7 @@ fn main() -> io::Result<()> { snapshot: &seq_snapshot, playing: &playing, audio_tx: &sequencer.audio_tx, + seq_cmd_tx: &sequencer.cmd_tx, nudge_us: &nudge_us, }; diff --git a/src/state/editor.rs b/src/state/editor.rs index 2855569..aa664a4 100644 --- a/src/state/editor.rs +++ b/src/state/editor.rs @@ -1,3 +1,5 @@ +use std::ops::RangeInclusive; + use cagire_ratatui::Editor; #[derive(Clone, Copy, PartialEq, Eq)] @@ -50,14 +52,36 @@ pub struct EditorContext { pub step: usize, pub focus: Focus, pub editor: Editor, - pub copied_step: Option, + pub selection_anchor: Option, + pub copied_steps: Option, } -#[derive(Clone, Copy)] -pub struct CopiedStep { +#[derive(Clone)] +pub struct CopiedSteps { pub bank: usize, pub pattern: usize, - pub step: usize, + pub steps: Vec, +} + +#[derive(Clone)] +pub struct CopiedStepData { + pub script: String, + pub active: bool, + pub source: Option, + pub original_index: usize, +} + +impl EditorContext { + pub fn selection_range(&self) -> Option> { + let anchor = self.selection_anchor?; + let a = anchor.min(self.step); + let b = anchor.max(self.step); + Some(a..=b) + } + + pub fn clear_selection(&mut self) { + self.selection_anchor = None; + } } impl Default for EditorContext { @@ -68,7 +92,8 @@ impl Default for EditorContext { step: 0, focus: Focus::Sequencer, editor: Editor::new(), - copied_step: None, + selection_anchor: None, + copied_steps: None, } } } diff --git a/src/state/mod.rs b/src/state/mod.rs index f27647a..d1f6a48 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -13,7 +13,7 @@ pub mod ui; pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind}; pub use options::{OptionsFocus, OptionsState}; -pub use editor::{CopiedStep, EditorContext, Focus, PatternField, PatternPropsField}; +pub use editor::{CopiedStepData, CopiedSteps, EditorContext, Focus, PatternField, PatternPropsField}; pub use live_keys::LiveKeyState; pub use modal::Modal; pub use panel::{PanelFocus, PanelState, SidePanel}; diff --git a/src/state/modal.rs b/src/state/modal.rs index 60faf96..2414c31 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -14,6 +14,12 @@ pub enum Modal { step: usize, selected: bool, }, + ConfirmDeleteSteps { + bank: usize, + pattern: usize, + steps: Vec, + selected: bool, + }, ConfirmResetPattern { bank: usize, pattern: usize, diff --git a/src/views/main_view.rs b/src/views/main_view.rs index 0ac90c1..f8d69c9 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -119,6 +119,9 @@ fn render_tile( let is_active = step.map(|s| s.active).unwrap_or(false); let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false); let is_selected = step_idx == app.editor_ctx.step; + let in_selection = app.editor_ctx.selection_range() + .map(|r| r.contains(&step_idx)) + .unwrap_or(false); let is_playing = if app.playback.playing { snapshot.get_step(app.editor_ctx.bank, app.editor_ctx.pattern) == Some(step_idx) @@ -145,21 +148,23 @@ fn render_tile( (BRIGHT[i], DIM[i]) }); - let (bg, fg) = match (is_playing, is_active, is_selected, is_linked) { - (true, true, _, _) => (Color::Rgb(195, 85, 65), Color::White), - (true, false, _, _) => (Color::Rgb(180, 120, 45), Color::Black), - (false, true, true, true) => { + let (bg, fg) = match (is_playing, is_active, is_selected, is_linked, in_selection) { + (true, true, _, _, _) => (Color::Rgb(195, 85, 65), Color::White), + (true, false, _, _, _) => (Color::Rgb(180, 120, 45), Color::Black), + (false, true, true, true, _) => { let (r, g, b) = link_color.unwrap().0; (Color::Rgb(r, g, b), Color::Black) } - (false, true, true, false) => (Color::Rgb(0, 220, 180), Color::Black), - (false, true, false, true) => { + (false, true, true, false, _) => (Color::Rgb(0, 220, 180), Color::Black), + (false, true, _, _, true) => (Color::Rgb(0, 170, 140), Color::Black), + (false, true, false, true, _) => { let (r, g, b) = link_color.unwrap().1; (Color::Rgb(r, g, b), Color::White) } - (false, true, false, false) => (Color::Rgb(45, 106, 95), Color::White), - (false, false, true, _) => (Color::Rgb(80, 180, 255), Color::Black), - (false, false, false, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)), + (false, true, false, false, _) => (Color::Rgb(45, 106, 95), Color::White), + (false, false, true, _, _) => (Color::Rgb(80, 180, 255), Color::Black), + (false, false, _, _, true) => (Color::Rgb(60, 140, 200), Color::Black), + (false, false, false, _, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)), }; let symbol = if is_playing { diff --git a/src/views/render.rs b/src/views/render.rs index c18f3de..630da5c 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -295,13 +295,20 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { } else { let bindings: Vec<(&str, &str)> = match app.page { Page::Main => vec![ - ("←→↑↓", "Navigate"), + ("←→↑↓", "Nav"), + ("Shift+↑↓", "Select"), ("t", "Toggle"), ("Enter", "Edit"), - ("p", "Preview"), ("Space", "Play"), - ("<>", "Length"), - ("[]", "Speed"), + ("^C", "Copy"), + ("^V", "Paste"), + ("^B", "Link"), + ("^D", "Dup"), + ("^H", "Harden"), + ("Del", "Delete"), + ("<>", "Len"), + ("[]", "Spd"), + ("+-", "Tempo"), ], Page::Patterns => vec![ ("←→↑↓", "Navigate"), @@ -382,6 +389,12 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected) .render_centered(frame, term); } + Modal::ConfirmDeleteSteps { steps, selected, .. } => { + let nums: Vec = steps.iter().map(|s| format!("{:02}", s + 1)).collect(); + let label = format!("Delete steps {}?", nums.join(", ")); + ConfirmModal::new("Confirm", &label, *selected) + .render_centered(frame, term); + } Modal::ConfirmResetPattern { pattern, selected, .. } => {