From 83c756618f70e4e0a28228694244d11edb49f28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sat, 7 Feb 2026 01:24:38 +0100 Subject: [PATCH] Feat: trying to get rid of some sequencer bugs --- CHANGELOG.md | 6 ++++++ src/app.rs | 2 +- src/engine/sequencer.rs | 36 +++++++++++++++++++++++++++++------- src/init.rs | 2 +- src/main.rs | 2 +- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b43c593..5197c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ All notable changes to this project will be documented in this file. - Removed unnecessary `Arc` wrapper from `Stack` type - Variable key cache computes on-demand with reusable buffers instead of pre-allocating 2048 Strings +### Fixed +- Sequencer sync: auto-loaded patterns now use PhaseLock instead of Reset, so they align to the global beat grid and stay in sync with manually-started patterns. +- PhaseLock off-by-one: start step calculation now uses the frontier beat instead of the lookahead end, eliminating a systematic 1-step offset. +- Stale pattern cache on load: dirty patterns are now flushed before queued start/stop changes, ensuring pattern data arrives before activation. +- Loading while paused no longer drops auto-started patterns; pending starts are preserved and activate on resume. + ### Changed - Header bar is now always 3 lines tall with vertically centered content and full-height background colors, replacing the previous 1-or-2-line width-dependent layout. - Help view Welcome page: BigText title is now gated behind `cfg(not(feature = "desktop"))`, falling back to a plain text title in the desktop build (same strategy as the splash screen). diff --git a/src/app.rs b/src/app.rs index 2a525ae..db7b0f0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -622,7 +622,7 @@ impl App { self.playback.queued_changes.push(StagedChange { change: PatternChange::Start { bank, pattern }, quantization: crate::model::LaunchQuantization::Immediate, - sync_mode: crate::model::SyncMode::Reset, + sync_mode: crate::model::SyncMode::PhaseLock, }); } diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index f954351..657d852 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -693,6 +693,10 @@ impl SequencerState { let frontier = self.audio_state.prev_beat; let lookahead_end = input.lookahead_end; + if frontier < 0.0 { + self.realign_phaselock_patterns(lookahead_end); + } + self.activate_pending(lookahead_end, frontier, input.quantum); self.deactivate_pending(lookahead_end, frontier, input.quantum); @@ -739,7 +743,6 @@ impl SequencerState { self.pattern_cache.set(key.0, key.1, snapshot); } } - self.audio_state.pending_starts.clear(); self.audio_state.prev_beat = -1.0; self.buf_audio_commands.clear(); let flush = std::mem::take(&mut self.audio_state.flush_midi_notes); @@ -751,6 +754,21 @@ impl SequencerState { } } + fn realign_phaselock_patterns(&mut self, beat: f64) { + for (id, active) in &mut self.audio_state.active_patterns { + let Some(pattern) = self.pattern_cache.get(id.bank, id.pattern) else { + continue; + }; + if pattern.sync_mode != SyncMode::PhaseLock { + continue; + } + let speed_mult = pattern.speed.multiplier(); + let subs_per_beat = 4.0 * speed_mult; + let step = (beat * subs_per_beat).floor() as usize + 1; + active.step_index = step % pattern.length; + } + } + fn activate_pending(&mut self, beat: f64, prev_beat: f64, quantum: f64) { self.buf_activated.clear(); for pending in &self.audio_state.pending_starts { @@ -762,7 +780,9 @@ impl SequencerState { 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 + let subs_per_beat = 4.0 * speed_mult; + let first_sub = (prev_beat * subs_per_beat).floor() as usize + 1; + first_sub % pat.length } else { 0 } @@ -1889,7 +1909,7 @@ mod tests { } #[test] - fn test_start_while_paused_is_discarded() { + fn test_start_while_paused_is_preserved() { let mut state = make_state(); state.tick(tick_with( @@ -1901,7 +1921,7 @@ mod tests { 0.0, )); - // Start while paused: pending_starts gets cleared + // Start while paused: pending_starts preserved for resume state.tick(TickInput { commands: vec![SeqCommand::PatternStart { bank: 0, @@ -1912,11 +1932,13 @@ mod tests { ..tick_at(1.0, false) }); - assert!(state.audio_state.pending_starts.is_empty()); + assert!(!state.audio_state.pending_starts.is_empty()); - // Resume playing — pattern should NOT be active + // Resume playing — first tick resets prev_beat from -1 to 2.0 state.tick(tick_at(2.0, true)); - assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); + // 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))); } #[test] diff --git a/src/init.rs b/src/init.rs index bf6bbda..8ac1593 100644 --- a/src/init.rs +++ b/src/init.rs @@ -68,7 +68,7 @@ pub fn init(args: InitArgs) -> Init { pattern: 0, }, quantization: model::LaunchQuantization::Immediate, - sync_mode: model::SyncMode::Reset, + sync_mode: model::SyncMode::PhaseLock, }); app.audio.config.output_device = args.output.or(settings.audio.output_device.clone()); diff --git a/src/main.rs b/src/main.rs index e9d824d..72dbd73 100644 --- a/src/main.rs +++ b/src/main.rs @@ -229,8 +229,8 @@ fn main() -> io::Result<()> { let seq_snapshot = sequencer.snapshot(); app.metrics.event_count = seq_snapshot.event_count; - app.flush_queued_changes(&sequencer.cmd_tx); app.flush_dirty_patterns(&sequencer.cmd_tx); + app.flush_queued_changes(&sequencer.cmd_tx); let had_event = event::poll(Duration::from_millis( app.audio.config.refresh_rate.millis(),