From 6d71c64a343622a576d7bdac2150caf459d57d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Mon, 16 Mar 2026 16:21:02 +0100 Subject: [PATCH] Fix: fix two show-stopper bugs --- crates/forth/src/vm.rs | 3 + plugins/cagire-plugins/src/editor.rs | 6 + src/bin/desktop/main.rs | 5 + src/engine/sequencer.rs | 252 ++++++++++++++++++++++++++- 4 files changed, 259 insertions(+), 7 deletions(-) diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 1a05543..97b0f1e 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -1778,6 +1778,9 @@ fn emit_output( } for (i, (k, v)) in params.iter().enumerate() { + if v.is_empty() { + continue; + } if !out.ends_with('/') { out.push('/'); } diff --git a/plugins/cagire-plugins/src/editor.rs b/plugins/cagire-plugins/src/editor.rs index 813a529..8289074 100644 --- a/plugins/cagire-plugins/src/editor.rs +++ b/plugins/cagire-plugins/src/editor.rs @@ -234,6 +234,7 @@ pub fn create_editor( // Read live snapshot from the audio thread let shared = editor.bridge.shared_state.load(); editor.snapshot = SequencerSnapshot::from(shared.as_ref()); + editor.app.playback.playing = editor.snapshot.playing; // Sync host tempo into LinkState so title bar shows real tempo if shared.tempo > 0.0 { @@ -298,6 +299,11 @@ pub fn create_editor( let elapsed = editor.last_frame.elapsed(); editor.last_frame = Instant::now(); + if editor.app.playback.has_armed() { + let rate = std::f32::consts::TAU; + editor.app.ui.pulse_phase = (editor.app.ui.pulse_phase + elapsed.as_secs_f32() * rate) % std::f32::consts::TAU; + } + let link = &editor.link; let app = &editor.app; let snapshot = &editor.snapshot; diff --git a/src/bin/desktop/main.rs b/src/bin/desktop/main.rs index aee45b5..650bebc 100644 --- a/src/bin/desktop/main.rs +++ b/src/bin/desktop/main.rs @@ -496,6 +496,11 @@ impl eframe::App for CagireDesktop { let elapsed = self.last_frame.elapsed(); self.last_frame = std::time::Instant::now(); + if self.app.playback.has_armed() { + let rate = std::f32::consts::TAU; + self.app.ui.pulse_phase = (self.app.ui.pulse_phase + elapsed.as_secs_f32() * rate) % std::f32::consts::TAU; + } + let link = &self.link; let app = &self.app; self.terminal diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 2b617a7..42bed3c 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -170,6 +170,7 @@ pub struct SharedSequencerState { pub event_count: usize, pub tempo: f64, pub beat: f64, + pub playing: bool, pub script_trace: Option, pub print_output: Option, } @@ -180,6 +181,7 @@ pub struct SequencerSnapshot { pub event_count: usize, pub tempo: f64, pub beat: f64, + pub playing: bool, script_trace: Option, pub print_output: Option, } @@ -192,6 +194,7 @@ impl From<&SharedSequencerState> for SequencerSnapshot { event_count: s.event_count, tempo: s.tempo, beat: s.beat, + playing: s.playing, script_trace: s.script_trace.clone(), print_output: s.print_output.clone(), } @@ -207,6 +210,7 @@ impl SequencerSnapshot { event_count: 0, tempo: 0.0, beat: 0.0, + playing: false, script_trace: None, print_output: None, } @@ -306,6 +310,7 @@ struct PendingPattern { struct AudioState { prev_beat: f64, + pause_beat: Option, active_patterns: HashMap, pending_starts: Vec, pending_stops: Vec, @@ -316,6 +321,7 @@ impl AudioState { fn new() -> Self { Self { prev_beat: -1.0, + pause_beat: None, active_patterns: HashMap::new(), pending_starts: Vec::new(), pending_stops: Vec::new(), @@ -572,6 +578,7 @@ pub struct SequencerState { soloed: std::collections::HashSet<(usize, usize)>, last_tempo: f64, last_beat: f64, + last_playing: bool, script_text: String, script_speed: crate::model::PatternSpeed, script_length: usize, @@ -610,6 +617,7 @@ impl SequencerState { soloed: std::collections::HashSet::new(), last_tempo: 0.0, last_beat: 0.0, + last_playing: false, script_text: String::new(), script_speed: crate::model::PatternSpeed::default(), script_length: 16, @@ -714,6 +722,7 @@ impl SequencerState { self.audio_state.active_patterns.clear(); self.audio_state.pending_starts.clear(); self.audio_state.pending_stops.clear(); + self.audio_state.pause_beat = None; self.step_traces = Arc::new(HashMap::new()); self.runs_counter.counts.clear(); self.audio_state.flush_midi_notes = true; @@ -724,6 +733,7 @@ impl SequencerState { active.iter = 0; } self.audio_state.prev_beat = -1.0; + self.audio_state.pause_beat = None; self.script_frontier = -1.0; self.script_step = 0; self.script_trace = None; @@ -751,6 +761,7 @@ impl SequencerState { self.process_commands(input.commands); self.last_tempo = input.tempo; self.last_beat = input.beat; + self.last_playing = input.playing; if !input.playing { return self.tick_paused(); @@ -758,14 +769,21 @@ impl SequencerState { let frontier = self.audio_state.prev_beat; let lookahead_end = input.lookahead_end; + let resuming = frontier < 0.0; - if frontier < 0.0 { + let boundary_frontier = if resuming { + self.audio_state.pause_beat.take().unwrap_or(input.beat) + } else { + frontier + }; + + self.activate_pending(lookahead_end, boundary_frontier, input.quantum); + self.deactivate_pending(lookahead_end, boundary_frontier, input.quantum); + + if resuming { self.realign_phaselock_patterns(lookahead_end); } - self.activate_pending(lookahead_end, frontier, input.quantum); - self.deactivate_pending(lookahead_end, frontier, input.quantum); - let steps = self.execute_steps( input.beat, frontier, @@ -822,6 +840,9 @@ impl SequencerState { self.pattern_cache.set(key.0, key.1, snapshot); } } + if self.audio_state.prev_beat >= 0.0 { + self.audio_state.pause_beat = Some(self.audio_state.prev_beat); + } self.audio_state.prev_beat = -1.0; self.script_frontier = -1.0; self.script_step = 0; @@ -1240,6 +1261,7 @@ impl SequencerState { event_count: self.event_count, tempo: self.last_tempo, beat: self.last_beat, + playing: self.last_playing, script_trace: self.script_trace.clone(), print_output: self.print_output.clone(), } @@ -1975,10 +1997,8 @@ mod tests { assert!(!state.audio_state.pending_starts.is_empty()); - // Resume playing — first tick resets prev_beat from -1 to 2.0 + // Resume playing — Immediate fires on first tick state.tick(tick_at(2.0, true)); - // Second tick: prev_beat is now >= 0, so Immediate fires - state.tick(tick_at(2.25, true)); assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); } @@ -2187,4 +2207,222 @@ mod tests { // Should have commands from both patterns (2 patterns * 1 command each) assert!(output.audio_commands.len() >= 2); } + + #[test] + fn test_bar_boundary_crossed_during_pause() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: simple_pattern(4), + }], + 0.0, + )); + + // Queue Bar-quantized start at beat 3.5 (before bar at beat 4.0) + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Bar, + sync_mode: SyncMode::Reset, + }], + 3.5, + )); + assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); + + // Pause — saves prev_beat=3.5 as pause_beat + state.tick(tick_at(3.75, false)); + + // Resume after bar boundary (beat 4.0 crossed) + state.tick(tick_at(4.5, true)); + assert!( + state.audio_state.active_patterns.contains_key(&pid(0, 0)), + "Bar-quantized pattern should activate on resume after bar boundary" + ); + } + + #[test] + fn test_immediate_activates_on_first_resume_tick() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: simple_pattern(4), + }], + 0.0, + )); + + // Pause + state.tick(tick_at(1.0, false)); + + // Queue Immediate start while paused + state.tick(TickInput { + commands: vec![SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }], + ..tick_at(2.0, false) + }); + + // Resume — Immediate should fire on this single tick + state.tick(tick_at(3.0, true)); + assert!( + state.audio_state.active_patterns.contains_key(&pid(0, 0)), + "Immediate should activate on first resume tick" + ); + } + + #[test] + fn test_multiple_patterns_sync_after_pause() { + 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(8), + }, + ], + 0.0, + )); + + // Queue two Bar-quantized starts + state.tick(tick_with( + vec![ + SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Bar, + sync_mode: SyncMode::Reset, + }, + SeqCommand::PatternStart { + bank: 0, + pattern: 1, + quantization: LaunchQuantization::Bar, + sync_mode: SyncMode::Reset, + }, + ], + 3.5, + )); + + // Pause before bar + state.tick(tick_at(3.75, false)); + + // Resume after bar boundary + state.tick(tick_at(4.5, true)); + assert!( + state.audio_state.active_patterns.contains_key(&pid(0, 0)), + "First pattern should activate" + ); + assert!( + state.audio_state.active_patterns.contains_key(&pid(0, 1)), + "Second pattern should activate together" + ); + } + + fn phaselock_pattern(length: usize) -> PatternSnapshot { + PatternSnapshot { + speed: Default::default(), + length, + steps: (0..length) + .map(|_| StepSnapshot { + active: true, + script: "test".into(), + source: None, + }) + .collect(), + sync_mode: SyncMode::PhaseLock, + follow_up: FollowUp::default(), + } + } + + #[test] + fn test_phaselock_position_correct_after_resume() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: phaselock_pattern(16), + }], + 0.0, + )); + + // Queue PhaseLock pattern + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Bar, + sync_mode: SyncMode::PhaseLock, + }], + 3.5, + )); + + // Pause before bar + state.tick(tick_at(3.75, false)); + + // Resume at beat 5.0 (after bar boundary at 4.0) + let resume_beat = 5.0; + state.tick(tick_at(resume_beat, true)); + assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); + + // realign_phaselock_patterns uses: (beat * 4.0).floor() + 1 % length + // At beat 5.0: (5.0 * 4.0).floor() = 20, +1 = 21, 21 % 16 = 5 + let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); + let expected = ((resume_beat * 4.0).floor() as usize + 1) % 16; + assert_eq!( + ap.step_index, expected, + "PhaseLock step should be based on resume beat, not pause beat" + ); + } + + #[test] + fn test_no_false_boundary_after_pause_within_same_bar() { + let mut state = make_state(); + + state.tick(tick_with( + vec![SeqCommand::PatternUpdate { + bank: 0, + pattern: 0, + data: simple_pattern(4), + }], + 0.0, + )); + + // Queue Bar-quantized start at beat 1.0 + state.tick(tick_with( + vec![SeqCommand::PatternStart { + bank: 0, + pattern: 0, + quantization: LaunchQuantization::Bar, + sync_mode: SyncMode::Reset, + }], + 1.0, + )); + + // Pause at beat 1.5 (within same bar) + state.tick(tick_at(1.5, false)); + + // Resume at beat 2.5 (still within same bar, quantum=4) + state.tick(tick_at(2.5, true)); + assert!( + !state.audio_state.active_patterns.contains_key(&pid(0, 0)), + "Bar-quantized pattern should NOT activate when no bar boundary was crossed" + ); + } }