Fix: fix two show-stopper bugs

This commit is contained in:
2026-03-16 16:21:02 +01:00
parent 097104a074
commit 6d71c64a34
4 changed files with 259 additions and 7 deletions

View File

@@ -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('/');
}

View File

@@ -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;

View File

@@ -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

View File

@@ -170,6 +170,7 @@ pub struct SharedSequencerState {
pub event_count: usize,
pub tempo: f64,
pub beat: f64,
pub playing: bool,
pub script_trace: Option<ExecutionTrace>,
pub print_output: Option<String>,
}
@@ -180,6 +181,7 @@ pub struct SequencerSnapshot {
pub event_count: usize,
pub tempo: f64,
pub beat: f64,
pub playing: bool,
script_trace: Option<ExecutionTrace>,
pub print_output: Option<String>,
}
@@ -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<f64>,
active_patterns: HashMap<PatternId, ActivePattern>,
pending_starts: Vec<PendingPattern>,
pending_stops: Vec<PendingPattern>,
@@ -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"
);
}
}