From 316ea68cae666a6e171566e5b01876d0d8ac9100 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sat, 7 Nov 2026 11:28:07 -0300 Subject: [PATCH 01/19] Recording and the latency box time beats at the tempo playing The count-in, one-click and track recording clip lengths, the score log, an audio file's clip length and the latency box's beat unit read project.tempo, which holds the tempo as last opened or saved, not the tempo playing (State.tempo). Found by the equivalence harness survey. Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/app.rs | 2 +- crates/os-app/src/one_click.rs | 4 ++-- crates/os-app/src/record.rs | 13 ++++++++++++- crates/os-app/src/score_log.rs | 2 +- crates/os-app/src/track_rec.rs | 4 ++-- crates/os-app/src/typein.rs | 2 +- 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/os-app/src/app.rs b/crates/os-app/src/app.rs index dcd97d3a5..a8736749e 100644 --- a/crates/os-app/src/app.rs +++ b/crates/os-app/src/app.rs @@ -5001,7 +5001,7 @@ impl App { let start = s.playlist.pos; let secs = s.playlist.wave(&path).map_or(0.0, |w| w.secs()); let p = &s.project; - let tempo = p.tick_tempo(p.tempo.unwrap_or(140.0).max(1.0)); + let tempo = p.tick_tempo(s.tempo.max(1.0)); let len = (secs * tempo / 60.0 * f64::from(p.ppq.max(1))).ceil() as u32; let track = crate::playlist::free_track(s, 0, start, start + len.max(1)); let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); diff --git a/crates/os-app/src/one_click.rs b/crates/os-app/src/one_click.rs index b5552be8b..3ebd47244 100644 --- a/crates/os-app/src/one_click.rs +++ b/crates/os-app/src/one_click.rs @@ -169,7 +169,7 @@ impl App { }; let start_tick = self.state.playlist.pos; let p = &self.state.project; - let tempo = p.tick_tempo(p.tempo.unwrap_or(140.0).max(1.0)); + let tempo = p.tick_tempo(self.state.tempo.max(1.0)); let start_secs = os_engine::laws::tick_to_frame(f64::from(start_tick), p.ppq.max(1), tempo, 48000) as f64 / 48000.0; self.state.rec.audio = true; self.audio_rec = Some(AudioRec { stream, recorder: Some(recorder), rate, start_tick, start_secs, est: (0.0, 0), ending: false, editor }); @@ -238,7 +238,7 @@ impl App { fn place_take(&mut self, path: &std::path::Path, frames: u64, rate: u32, start: u32) -> String { let s = &mut self.state; let p = &s.project; - let tempo = p.tick_tempo(p.tempo.unwrap_or(140.0).max(1.0)); + let tempo = p.tick_tempo(s.tempo.max(1.0)); let len = (frames as f64 / f64::from(rate) * tempo / 60.0 * f64::from(p.ppq.max(1))).ceil() as u32; let track = crate::playlist::free_track(s, 0, start, start + len.max(1)); let text = path.to_string_lossy(); diff --git a/crates/os-app/src/record.rs b/crates/os-app/src/record.rs index c9a3166e9..af2b2ce45 100644 --- a/crates/os-app/src/record.rs +++ b/crates/os-app/src/record.rs @@ -411,7 +411,7 @@ impl App { self.start_playback_counted(bars); if bars > 0 && self.transport.playing() { let p = &self.state.project; - let tempo = p.tick_tempo(p.tempo.unwrap_or(140.0).max(1.0)); + let tempo = p.tick_tempo(self.state.tempo.max(1.0)); let beat = f64::from(p.beat_ticks()) / f64::from(p.ppq.max(1)) * 60.0 / tempo; let beats = bars * u32::from(p.bar().0.max(1)); self.state.rec.count = Some(CountIn { from: std::time::Instant::now(), beat, beats }); @@ -1377,6 +1377,17 @@ mod tests { /// A mixer track's fader and pan, the tempo and a plugin's parameter record into the current pattern as control /// points on their targets, each in its setting's own unit (the plugin's parameter: its plain value in a whole /// range), in the take's one undo step; a dialog's knob records nothing (spec §2 Taking automation). + /// What is timed in beats follows the tempo playing, not the tempo the project was last opened or saved with. + #[test] + fn timing_follows_the_tempo_playing() { + let mut app = app(); + app.state.project.tempo = Some(140.0); + app.state.tempo = 70.0; + let p = &app.state.project; + let at_70 = f64::from(p.ppq) * p.tick_tempo(70.0) / 60.0; + assert!((crate::score_log::ticks_per_second(&app.state) - at_70).abs() < 1e-9); + } + #[test] fn records_mixer_controls_the_tempo_and_plugin_parameters() { use os_model::target; diff --git a/crates/os-app/src/score_log.rs b/crates/os-app/src/score_log.rs index a3b255b9f..cb863a6a2 100644 --- a/crates/os-app/src/score_log.rs +++ b/crates/os-app/src/score_log.rs @@ -82,5 +82,5 @@ pub fn items(_: &State) -> Vec { /// Ticks a second at the project's tempo. pub fn ticks_per_second(s: &State) -> f64 { let p = &s.project; - f64::from(p.ppq.max(1)) * p.tick_tempo(p.tempo.unwrap_or(140.0).max(1.0)) / 60.0 + f64::from(p.ppq.max(1)) * p.tick_tempo(s.tempo.max(1.0)) / 60.0 } diff --git a/crates/os-app/src/track_rec.rs b/crates/os-app/src/track_rec.rs index 55ade06a4..6f2865f15 100644 --- a/crates/os-app/src/track_rec.rs +++ b/crates/os-app/src/track_rec.rs @@ -135,7 +135,7 @@ impl App { fn track_rec_end(&mut self) { self.transport.record_tracks(None); let p = &self.state.project; - let tempo = p.tick_tempo(p.tempo.unwrap_or(140.0).max(1.0)); + let tempo = p.tick_tempo(self.state.tempo.max(1.0)); let Some(r) = self.track_rec.as_mut() else { return }; r.takes.on.store(false, Ordering::Release); let tick = os_engine::laws::tick_to_frame(1.0, p.ppq.max(1), tempo, r.rate).max(1); @@ -184,7 +184,7 @@ impl App { let s = &mut self.state; crate::playlist::snapshot_as(s, crate::one_click::RECORDED); let p = &s.project; - let tempo = p.tick_tempo(p.tempo.unwrap_or(140.0).max(1.0)); + let tempo = p.tick_tempo(s.tempo.max(1.0)); let ppq = f64::from(p.ppq.max(1)); let mut placed = 0; for (t, path, frames, at) in &made { diff --git a/crates/os-app/src/typein.rs b/crates/os-app/src/typein.rs index 8e6ba364b..5ce5e791c 100644 --- a/crates/os-app/src/typein.rs +++ b/crates/os-app/src/typein.rs @@ -66,7 +66,7 @@ impl LatUnit { match self { LatUnit::Ms => 1.0, LatUnit::Samples => 1000.0 / f64::from(crate::device::rate().unwrap_or(44100).max(1)), - LatUnit::Beats => 60000.0 / s.project.tempo.unwrap_or(140.0).max(1.0), + LatUnit::Beats => 60000.0 / s.tempo.max(1.0), } } } From cac08b0438fdbc0e9483de3ee97164cc6954b42f Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sat, 7 Nov 2026 11:29:32 -0300 Subject: [PATCH 02/19] Undoing a whole-project step puts back the mixer's slot colours and icons and the rack's loop points The snapshot of a whole-project step (Side) kept the slot names but not the slot colours, the icons or Advanced looping's loop points. Undoing a mixer track delete or move left the colours and icons on the tracks they had moved to, and undoing a channel delete lost its loop points. Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/control/command.rs | 17 +++++++++++++---- crates/os-app/src/mixer.rs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/os-app/src/control/command.rs b/crates/os-app/src/control/command.rs index a9bdeb53d..5c7ac6d53 100644 --- a/crates/os-app/src/control/command.rs +++ b/crates/os-app/src/control/command.rs @@ -365,7 +365,8 @@ impl Settings { } } -/// The whole project with the rack's own state of it: the tempo, groups, filter, zipped and locked rows and loops. +/// The whole project with the state of it the interface holds: the tempo, the rack's groups, filter, zipped and locked +/// rows and loops, and the mixer's slot names, colours and icons. #[derive(Debug, Clone, PartialEq)] pub struct Side { pub project: Project, @@ -379,6 +380,11 @@ pub struct Side { pub loop_len: std::collections::BTreeMap, /// The mixer's slot names typed in this session (they follow tracks and slots as they move). pub slot_names: std::collections::HashMap<(u16, u8), String>, + /// The slots' colours and the channels', slots' and tracks' icons, which follow them the same way. + pub slot_colors: std::collections::HashMap<(u16, u8), os_model::Rgb>, + pub icons: std::collections::HashMap, + /// Advanced looping's loop points by row, beside the loop lengths. + pub loop_unit: std::collections::BTreeMap, } impl Side { @@ -395,6 +401,9 @@ impl Side { locked: r.locked.clone(), loop_len: r.loop_len.clone(), slot_names: s.mixer.slot_names.clone(), + slot_colors: s.mixer.slot_colors.clone(), + icons: s.playlist.icons.clone(), + loop_unit: r.loop_unit.clone(), } } } @@ -1212,8 +1221,8 @@ fn restore(s: &mut State, side: Side) { let sel_clips = selected_clips(s); let new_row: std::collections::HashMap = side.project.channels.iter().enumerate().map(|(r, c)| (c.id.0, r)).collect(); let map: Vec> = old.iter().map(|id| new_row.get(id).copied()).collect(); - let Side { project, tempo, pattern, groups, unsorted_at, filter, zipped, locked, loop_len, slot_names } = side; - s.mixer.slot_names = slot_names; + let Side { project, tempo, pattern, groups, unsorted_at, filter, zipped, locked, loop_len, slot_names, slot_colors, icons, loop_unit } = side; + (s.mixer.slot_names, s.mixer.slot_colors, s.playlist.icons) = (slot_names, slot_colors, icons); let mutes = Mutes::of(&s.project); s.project = project; mutes.keep(&mut s.project); @@ -1221,7 +1230,7 @@ fn restore(s: &mut State, side: Side) { s.pattern = pattern; crate::rack::remap_ui(s, &map); let r = &mut s.rack; - (r.groups, r.unsorted_at, r.filter, r.zipped, r.locked, r.loop_len) = (groups, unsorted_at, filter, zipped, locked, loop_len); + (r.groups, r.unsorted_at, r.filter, r.zipped, r.locked, r.loop_len, r.loop_unit) = (groups, unsorted_at, filter, zipped, locked, loop_len, loop_unit); let tracks = crate::mixer::inserts(s); s.selected_mixer = s.selected_mixer.min(tracks); s.mixer.sel.retain(|&t| t <= tracks); diff --git a/crates/os-app/src/mixer.rs b/crates/os-app/src/mixer.rs index 3bc8ab325..e22749273 100644 --- a/crates/os-app/src/mixer.rs +++ b/crates/os-app/src/mixer.rs @@ -3104,6 +3104,22 @@ mod tests { assert_eq!(level_text("To Bus 11", 0.0), "To Bus 11: 0% -INFdB 0.00"); } + #[test] + fn undoing_a_track_delete_puts_back_its_slots_colours_and_icons() { + use crate::playlist::IconOwner; + let mut a = app(); + let red = Rgb { r: 200, g: 0, b: 0 }; + a.state.mixer.slot_colors.insert((3, 0), red); + a.state.playlist.icons.insert(IconOwner::Track(3), crate::icons::Icon::Play); + run(&mut a, MCmd::Delete(2)); + assert_eq!(a.state.mixer.slot_colors.get(&(2, 0)), Some(&red), "the colour follows its track left"); + crate::history::undo(&mut a.state); + assert_eq!(a.state.mixer.slot_colors.get(&(3, 0)), Some(&red)); + assert_eq!(a.state.mixer.slot_colors.get(&(2, 0)), None); + assert_eq!(a.state.playlist.icons.get(&IconOwner::Track(3)), Some(&crate::icons::Icon::Play)); + assert_eq!(a.state.playlist.icons.get(&IconOwner::Track(2)), None); + } + #[test] fn add_move_delete_tracks() { let mut a = app(); From 55a67609848e181b054083afb2ed4821f06aa8fe Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sat, 7 Nov 2026 11:30:58 -0300 Subject: [PATCH 03/19] A saved project opens again on the pattern shown when it was saved, with that pattern's loops The current pattern (event 67) was read on open but never written back, so a project always reopened on the pattern it was first opened with, while the rack saved its loop lengths into the pattern shown at save: those loops came back on the wrong pattern or not at all. Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/app.rs | 3 +++ crates/os-app/src/rack.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/os-app/src/app.rs b/crates/os-app/src/app.rs index a8736749e..13b5c6438 100644 --- a/crates/os-app/src/app.rs +++ b/crates/os-app/src/app.rs @@ -2032,6 +2032,9 @@ impl App { let s = &mut self.state; s.project.tempo = Some(s.tempo); s.project.song_mode = Some(s.song_mode); + // The pattern shown is saved, so the project opens on it again with the loops the rack stores into it (event + // 67, spec: flp/engine-events.md). + s.project.current_pattern = Some(s.pattern); // Lock, zip, filter groups and loop lengths live in the rack UI (spec: ui/channel-rack.md, Saving). crate::rack::to_model(s); // The playlist's track height, zoom and scroll (event 242, spec: flp/events/playlist.md). diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index 6b9d802b2..fb7870f21 100644 --- a/crates/os-app/src/rack.rs +++ b/crates/os-app/src/rack.rs @@ -3022,6 +3022,25 @@ pub fn to_model(s: &mut State) { mod tests { use super::*; + /// A project opens on the pattern it was saved on, with the loop lengths saved into that pattern. + #[test] + fn the_current_pattern_and_its_loops_reopen() { + let file = std::env::temp_dir().join(format!("openstudio-current-pattern-{}.flp", std::process::id())); + let mut a = crate::app::App::new(None); + a.state.project = crate::app::test_project(); + a.state.file = None; + a.state.path = None; + crate::patterns::pat_mut(&mut a.state, 2); + a.state.pattern = 2; + a.state.rack.loop_len.insert(0, 8); + a.save_to(file.clone()); + let mut b = crate::app::App::new(None); + b.open(file.clone()); + let _ = std::fs::remove_file(&file); + assert_eq!(b.state.pattern, 2); + assert_eq!(b.state.rack.loop_len.get(&0), Some(&8)); + } + /// A knob's Edit automation clip... is greyed until a clip drives the control, then shows that clip (§19, as on a /// plugin parameter). #[test] From 3608ea5b600b70ce4768c4ea5f9753e560e546e4 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sat, 7 Nov 2026 11:32:23 -0300 Subject: [PATCH 04/19] A playlist solo is over once it no longer holds, as in the channel rack and the mixer The playlist kept its solo's saved track states across an arrangement switch, track moves and undo. Soloing again then wrote the first arrangement's states, by track number, onto the tracks shown. A solo whose shape no longer holds now starts afresh. Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/playlist.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/os-app/src/playlist.rs b/crates/os-app/src/playlist.rs index 13d35bb74..86143a273 100644 --- a/crates/os-app/src/playlist.rs +++ b/crates/os-app/src/playlist.rs @@ -2155,6 +2155,11 @@ pub fn toggle_track(s: &mut State, t: u16) { /// Ctrl+click / right-click on the LED: solo the track (others muted); again ends the solo (see [`end_solo`]). A solo /// moved to another track keeps the states from before the first one. pub fn toggle_solo(s: &mut State, t: u16) { + // A solo no longer in effect (another arrangement shown, tracks moved or an undo since) is over: its saved states + // belong to the tracks as they were, as in the channel rack and the mixer. + if !solo_in_effect(s) { + s.playlist.solo = None; + } if let Some((was, _)) = s.playlist.solo { end_solo(s); if was == t { @@ -2170,6 +2175,13 @@ pub fn toggle_solo(s: &mut State, t: u16) { s.piano.dirty = true; } +/// Whether the last solo still holds: its track alone on, in a playlist of as many tracks as when it started. +fn solo_in_effect(s: &mut State) -> bool { + let Some((t, states)) = s.playlist.solo.clone() else { return false }; + let arr = arr_mut(s); + arr.tracks.len() == states.len() && arr.tracks.iter().enumerate().all(|(k, tr)| tr.enabled == (k == usize::from(t))) +} + /// Unsolo: every track on, or, with "Restore previous state after solo" on, the states from before the solo (spec: /// ui/settings-choices.md "Miscellaneous"). fn end_solo(s: &mut State) { @@ -5695,6 +5707,28 @@ mod tests { app } + /// A solo belongs to the arrangement it was made in: shown another one, soloing there starts afresh rather than + /// putting the first arrangement's track states on this one's tracks. + #[test] + fn a_solo_stays_with_its_arrangement() { + use crate::prefs::{set_check, RESTORE_AFTER_SOLO}; + set_check(&RESTORE_AFTER_SOLO, true); + let mut app = app(); + let s = &mut app.state; + arr_mut(s).tracks[3].enabled = false; + toggle_solo(s, 0); + let mut other = s.project.arrangement().cloned().expect("an arrangement"); + other.tracks.iter_mut().for_each(|t| t.enabled = true); + other.number += 1; + let n = other.number; + s.project.arrangements.push(other); + show_arrangement(s, n); + toggle_solo(s, 5); + assert!(arr_mut(s).tracks.iter().enumerate().all(|(k, t)| t.enabled == (k == 5)), "a fresh solo of track 5"); + toggle_solo(s, 5); + assert!(arr_mut(s).tracks.iter().all(|t| t.enabled), "this arrangement's own states come back, track 3 on"); + } + /// Rename... on an audio or automation clip renames its channel in the rack's name box; a pattern clip still edits /// its pattern's name in place. #[test] From c53dbd326572332b383fb3b05870e713a55d45e5 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sat, 7 Nov 2026 12:16:46 -0300 Subject: [PATCH 05/19] Tests: the latency box's beats at the tempo playing; the reopened pattern holds a note so it is saved Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/mixer.rs | 2 +- crates/os-app/src/rack.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/os-app/src/mixer.rs b/crates/os-app/src/mixer.rs index e22749273..7cd7161b8 100644 --- a/crates/os-app/src/mixer.rs +++ b/crates/os-app/src/mixer.rs @@ -2929,7 +2929,7 @@ mod tests { fn latency_settings_are_typed_in_and_undo() { use crate::typein::{accept, parse, LatUnit, Target}; let mut a = app(); - a.state.project.tempo = Some(120.0); + a.state.tempo = 120.0; // The app's tick gives the tracks their ids, which the undo steps name them by. a.state.control.settle(&mut a.state.project); let labels = |p: &Popup| p.items.iter().map(|i| (i.label.to_string(), i.enabled)).collect::>(); diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index fb7870f21..2692ca520 100644 --- a/crates/os-app/src/rack.rs +++ b/crates/os-app/src/rack.rs @@ -3030,7 +3030,10 @@ mod tests { a.state.project = crate::app::test_project(); a.state.file = None; a.state.path = None; - crate::patterns::pat_mut(&mut a.state, 2); + // A pattern with nothing in it is not saved: pattern 2 gets a note. + let ch = a.state.project.channels[0].index; + let note = os_model::Note { position: 0, length: 96, channel: ch, key: 60, fine_pitch: 120, release: 64, pan: 64, velocity: 100, mod_x: 128, mod_y: 128, flags: 0x4000, group: 0, unk14: 0, repeat: 0 }; + crate::patterns::pat_mut(&mut a.state, 2).notes.push(note); a.state.pattern = 2; a.state.rack.loop_len.insert(0, 8); a.save_to(file.clone()); From b71dfc5973c030d50112634bcdf7f11f73e0e0c3 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sat, 7 Nov 2026 15:56:05 -0300 Subject: [PATCH 06/19] A mixer track's equaliser is saved Saving into a file wrote a track's volume, pan and stereo separation but never its equaliser, and a new track got the default bands: every EQ edit was lost on save. Found by the equivalence harness's round trip (Drumloop Masher: a band's frequency dragged, saved, opened again at the old value). Co-Authored-By: Claude Opus 5.5 --- crates/os-flp/src/project.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/os-flp/src/project.rs b/crates/os-flp/src/project.rs index d1cb70537..b3e1d0ac7 100644 --- a/crates/os-flp/src/project.rs +++ b/crates/os-flp/src/project.rs @@ -3565,6 +3565,16 @@ pub fn apply(project: &Project, file: &mut FlpFile) -> Result<(), ProjectError> let v = (v * scale).round() as i32; data[off + 8..off + 12].copy_from_slice(&v.to_le_bytes()); } + // The equaliser, band by band: gain, frequency and width (spec: flp/events/mixer.md). A file without a + // band's record gets one. + let loc = mixer_location(record_track(m.index, project.mixer.len()), 0); + for (b, (band, was)) in m.eq.iter().zip(&old.eq).enumerate() { + for (k, first) in [MX_EQ_GAIN, MX_EQ_FREQ, MX_EQ_WIDTH].into_iter().enumerate() { + if band[k] != was[k] { + set_param_record(data, loc, first + b as u8, band[k]); + } + } + } } } if let (Some(v), Some(data)) = (project.main_volume.filter(|&v| Some(v) != current.main_volume), params.as_mut()) { @@ -4225,6 +4235,9 @@ fn track_records(t: u16, m: Option<&MixerTrack>) -> Vec { MX_VOLUME => m.volume.map_or(v, |x| (x * 16000.0).round() as i32), MX_PAN => m.pan.map_or(v, |x| (x * 6400.0).round() as i32), MX_STEREO => m.stereo_separation.map_or(v, |x| (x * 64.0).round() as i32), + MX_EQ_GAIN..MX_EQ_FREQ => m.eq[usize::from(pid - MX_EQ_GAIN)][0], + MX_EQ_FREQ..MX_EQ_WIDTH => m.eq[usize::from(pid - MX_EQ_FREQ)][1], + MX_EQ_WIDTH..=0xE2 => m.eq[usize::from(pid - MX_EQ_WIDTH)][2], _ => v, }; out.extend(param_rec(pid, MX_KIND_PARAM, mixer_location(t, 0), v)); @@ -4993,6 +5006,26 @@ mod tests { /// The track menu's Separator (flag 0x0400), Dock to (0x00C0) and Allow threaded processing (0x0010) are written by /// `create` and `apply` and read back; the last ("current") track keeps the master's docking (spec: flp/layouts.md /// "A mixer track's record (event 236)", ui/mixer.md §6.1). + /// A track's equaliser is written: a new track with its bands, an edit of a file's track band by band. + #[test] + fn mixer_equaliser_round_trips() { + use os_model::MixerTrack; + let mut p = new_project(); + p.arrangements.push(default_arrangement()); + p.mixer = (0..10).map(MixerTrack::new).collect(); + p.mixer[9].routes.clear(); + p.mixer[1].eq[0] = [-600, 2641, 17500]; + let mut file = FlpFile::parse(&create(&p).unwrap().to_bytes().unwrap()).unwrap(); + let back = load(&file).unwrap(); + assert_eq!(back.mixer[1].eq, p.mixer[1].eq); + let mut edited = back.clone(); + edited.mixer[1].eq[0][1] = 5777; + edited.mixer[2].eq[2] = [1200, 60000, 30000]; + apply(&edited, &mut file).unwrap(); + let again = load(&FlpFile::parse(&file.to_bytes().unwrap()).unwrap()).unwrap(); + assert_eq!((again.mixer[1].eq, again.mixer[2].eq), (edited.mixer[1].eq, edited.mixer[2].eq)); + } + #[test] fn mixer_separator_docking_and_threading_round_trip() { use os_model::{MixerTrack, DOCK_LEFT, DOCK_MIDDLE, DOCK_RIGHT}; From 3e9356ab73070e58086b6ea248895c9220a174e8 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sat, 7 Nov 2026 16:18:38 -0300 Subject: [PATCH 07/19] Loop mode with every channel following the pattern survives a save A pattern whose loop mode is on with every channel following it (FL's file: follow entries, no lengths, no loop_off) opened with loop mode on, but a save wrote no loops, so it opened again with loop mode off. The rack now writes follow entries for that state, and leaves a pattern's loops as the file has them while they say what the rack holds. Found by the equivalence harness (Inner Days SH-1 Rework, opened and saved twice). Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/rack.rs | 47 +++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index 2692ca520..c3a5dcbbd 100644 --- a/crates/os-app/src/rack.rs +++ b/crates/os-app/src/rack.rs @@ -2968,6 +2968,18 @@ pub fn from_model(s: &mut State) { rack.loop_mode = !loops.is_empty() && off.is_none(); } +/// What a pattern's loops say, as `from_model` reads them: the lengths by channel, the loop type, loop mode on. +fn loop_state(loops: &[(u16, u32)], off: Option) -> (BTreeSet<(u16, u32)>, u8, bool) { + let lengths = loops.iter().filter(|&&(_, l)| l != 0 && l != os_model::Pattern::LOOP_FOLLOW).copied().collect(); + let all = loops.iter().any(|&(_, l)| l == os_model::Pattern::LOOP_FOLLOW); + let kind = match off { + Some(2) => 1, + Some(_) => 0, + None => u8::from(all), + }; + (lengths, kind, !loops.is_empty() && off.is_none()) +} + /// Writes the rack state into the project before a save (the inverse of [`from_model`]). pub fn to_model(s: &mut State) { // Advanced looping's loop points are saved as the lengths they give in the current pattern (Lemon Studio's choice: @@ -3001,16 +3013,19 @@ pub fn to_model(s: &mut State) { } // Loop lengths belong to the current pattern. let mut loops: Vec<(u16, u32)> = lens.iter().filter_map(|(&r, &l)| p.channels.get(r).map(|c| (c.index, l))).collect(); - if rack.loop_type == 1 && !loops.is_empty() { + // Loop mode on with every channel following the pattern and none of its own length: FL's file says so with a + // follow entry for each channel (and no loop_off), which is what `from_model` reads back as loop mode on. + let all_follow = rack.loop_mode && rack.loop_type == 1 && loops.is_empty(); + if rack.loop_type == 1 && (!loops.is_empty() || all_follow) { let set: BTreeSet = loops.iter().map(|l| l.0).collect(); loops.extend(p.channels.iter().filter(|c| !set.contains(&c.index)).map(|c| (c.index, os_model::Pattern::LOOP_FOLLOW))); } loops.sort_by_key(|l| l.0); let loop_off = (!rack.loop_mode && !loops.is_empty()).then(|| LOOP_TYPE_CODES[usize::from(rack.loop_type.min(2))]); let n = s.pattern; - // Pairs with no length (0) carry nothing: a file that has them is unchanged. - let set = |l: &[(u16, u32)]| l.iter().filter(|x| x.1 != 0).copied().collect::>(); - let changed = p.pattern(n).map_or(!loops.is_empty(), |pat| set(&pat.loops) != set(&loops) || pat.loop_off != loop_off); + // A pattern whose loops already say what the rack holds is left as the file has it (pairs of no length, or a + // follow entry for only some channels, say the same). + let changed = p.pattern(n).map_or(!loops.is_empty(), |pat| loop_state(&pat.loops, pat.loop_off) != loop_state(&loops, loop_off)); if changed { let pat = crate::patterns::pat_mut(s, n); pat.loops = loops; @@ -3022,6 +3037,30 @@ pub fn to_model(s: &mut State) { mod tests { use super::*; + /// Loop mode on with every channel following the pattern (follow entries, no lengths) is still on after a save. + #[test] + fn loop_mode_with_every_channel_following_survives_a_save() { + let mut a = crate::app::App::new(None); + a.state.project = crate::app::test_project(); + a.state.pattern = 1; + let ch = a.state.project.channels[0].index; + let follow = os_model::Pattern::LOOP_FOLLOW; + let pat = crate::patterns::pat_mut(&mut a.state, 1); + (pat.loops, pat.loop_off) = (vec![(ch, follow)], None); + from_model(&mut a.state); + assert!(a.state.rack.loop_mode); + to_model(&mut a.state); + let p = a.state.project.pattern(1).expect("pattern 1"); + assert_eq!((p.loops.clone(), p.loop_off), (vec![(ch, follow)], None), "the file's form is kept"); + // Switched on in a pattern that had none: written so that it reads back on. + let pat = crate::patterns::pat_mut(&mut a.state, 1); + (pat.loops, pat.loop_off) = (Vec::new(), None); + (a.state.rack.loop_mode, a.state.rack.loop_type) = (true, 1); + to_model(&mut a.state); + from_model(&mut a.state); + assert!(a.state.rack.loop_mode && a.state.rack.loop_type == 1); + } + /// A project opens on the pattern it was saved on, with the loop lengths saved into that pattern. #[test] fn the_current_pattern_and_its_loops_reopen() { From 12abef8e90321b6d6e618a32c2cad311cd584ac2 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sat, 7 Nov 2026 16:58:16 -0300 Subject: [PATCH 08/19] Moving a channel with Shift+wheel is a Move channel step, as from the menu and Alt+Up / Down Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/app.rs | 4 ++++ crates/os-app/src/rack.rs | 9 ++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/os-app/src/app.rs b/crates/os-app/src/app.rs index 13b5c6438..3785c47e2 100644 --- a/crates/os-app/src/app.rs +++ b/crates/os-app/src/app.rs @@ -16845,6 +16845,10 @@ mod tests { app.wheel(0.0, 1.0, true, false); assert_eq!(order(&app)[..5], rows([0, 2, 1, 3, 4]), "the same channel, two rows up"); assert_eq!(app.state.rack.sel, std::collections::BTreeSet::from([1])); + // Each move is a "Move channel" step, as the menu's: undo puts them back. + assert_eq!(app.state.history.newest_done(), Some("Move channel")); + while crate::history::undo(&mut app.state).is_some() {} + assert_eq!(order(&app), b, "undone back to the start"); frame(&mut app, &mut c, None); let moved = order(&app); let name = hit_rect(&app, &Action::ChannelSelect(4)); diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index c3a5dcbbd..d70ec5115 100644 --- a/crates/os-app/src/rack.rs +++ b/crates/os-app/src/rack.rs @@ -1379,9 +1379,12 @@ pub fn wheel_move(app: &mut App, r: usize, notches: i32) { _ => r, }; app.state.rack.select_only(r); - for _ in 0..notches.unsigned_abs() { - move_selected(app, -notches.signum()); - } + // A "Move channel" step, as the menu's and Alt+Up / Down's moves are. + crate::control::edit_app(app, "Move channel", crate::control::command::Region::Project, |app| { + for _ in 0..notches.unsigned_abs() { + move_selected(app, -notches.signum()); + } + }); let now = app.state.rack.sel.first().copied().unwrap_or(r); app.state.rack.wheel_grab = Some((now, at)); } From 223b790c72e183874a18afc34f804524b298d8a5 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sun, 8 Nov 2026 06:50:35 -0300 Subject: [PATCH 09/19] Open audio editor's added Tesla is an undo step, and stereo separation moves in 64ths, as measured on FL FL 24.1.1 records "Insert effect plugin" when Open audio editor adds Edison; Lemon's Tesla made no step (the toolbar button and Open audio editor and record too). FL's separation knob sets whole 64ths, the steps the file keeps (audio/mixer.md); Lemon's was continuous and only rounded at save. Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/knobs.rs | 17 ++++++++++++++++- crates/os-app/src/mixer.rs | 26 +++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/os-app/src/knobs.rs b/crates/os-app/src/knobs.rs index e9aca7f83..c7ec9e358 100644 --- a/crates/os-app/src/knobs.rs +++ b/crates/os-app/src/knobs.rs @@ -334,7 +334,8 @@ impl State { } KnobId::MixSep(t) => { if let Some(m) = self.project.mixer.iter_mut().find(|m| m.index == t) { - m.stereo_separation = Some(f64::from((v - 0.5) * 2.0)); + // In 64ths, as the file keeps it and FL's knob sets it (12% merged is 8, a typed 1% is 1). + m.stereo_separation = Some((f64::from((v - 0.5) * 2.0) * 64.0).round() / 64.0); self.piano.dirty = true; } } @@ -553,6 +554,20 @@ mod tests { assert_eq!(LfoKnob::Pulse.text(&ClipLfo { pulse_width: -64, ..d }), "LFO pulse width: -50%"); } + /// Stereo separation moves in 64ths, the steps the file keeps, as FL's knob does (measured on FL 24.1.1: a + /// typed 1% is 1/64, shown as 2% merged), so what is set is what reopens. + #[test] + fn stereo_separation_moves_in_64ths() { + let mut app = crate::app::App::new(None); + app.state.project = crate::app::test_project(); + app.state.set_knob(KnobId::MixSep(1), 0.5 + 0.01 / 2.0); + let sep = |app: &crate::app::App| crate::mixer::track(&app.state, 1).unwrap().stereo_separation; + assert_eq!(sep(&app), Some(1.0 / 64.0)); + assert_eq!(app.state.knob_text(KnobId::MixSep(1)), "Stereo separation: 2% merged"); + app.state.set_knob(KnobId::MixSep(1), 0.5 - 0.125 / 2.0); + assert_eq!(sep(&app), Some(-8.0 / 64.0)); + } + #[test] fn texts_and_roundtrip() { assert_eq!(pan_text(0.0), "Centered"); diff --git a/crates/os-app/src/mixer.rs b/crates/os-app/src/mixer.rs index 7cd7161b8..59bf6f210 100644 --- a/crates/os-app/src/mixer.rs +++ b/crates/os-app/src/mixer.rs @@ -2561,6 +2561,7 @@ impl App { /// Tesla on track `t`: the toolbar's audio editor button puts it on the master, as FL opens Edison there (spec: /// plugins/tesla/requests.md #5); the track menu's "Open audio editor" on that track. It shows the track's first /// Tesla, adding one when it has none; `new` (the button's right click) always adds one, in the first free slot. + /// An added Tesla is an "Add effect" step, as FL's Edison is one ("insert effect plugin"). pub(crate) fn audio_editor(&mut self, t: u16, new: bool) { if !new { if let Some(k) = first_tesla(&self.state, t) { @@ -2568,20 +2569,20 @@ impl App { return self.invalidate(); } } - self.add_tesla(t, None); + crate::control::edit_app(self, "Add effect", Region::Project, |app| app.add_tesla(t, None)); } /// Open audio editor and record: a Tesla on track `t` set up as an audio logger, as FL's quick load sets Edison /// (spec: plugins/tesla/README.md "Recording into Tesla from Lemon Studio"): records on the host's play, for 5 /// minutes, appended, slaved to the transport, armed. pub(crate) fn audio_logger(&mut self, t: u16) { - self.tesla_for_recording(t, |s| { + crate::control::edit_app(self, "Add effect", Region::Project, |app| app.tesla_for_recording(t, |s| { s.rec.when = tesla::state::WHEN_ON_PLAY; s.rec.max = AUDIO_LOG_5_MINUTES; s.rec.append = true; s.rec.slave = true; s.rec.armed = true; - }); + })); } /// A Tesla on track `t` to record into (spec: plugins/tesla/README.md "Recording into Tesla from Lemon Studio"): @@ -3507,6 +3508,25 @@ mod tests { assert_eq!(from_plugin_state(&a.plugins.state(key).unwrap()).map(|s| s.sample.frames()), Some(100), "left alone"); } + /// Open audio editor adds a Tesla as one step that undo takes away, as FL's adds Edison ("insert effect plugin", + /// measured on FL 24.1.1); showing the track's Tesla again is no step. + #[test] + fn an_added_audio_editor_is_an_undo_step() { + let mut a = app(); + a.plugins.effects = crate::plugins::builtin_effects(); + let steps = a.state.history.at(); + run(&mut a, MCmd::AudioEditor(2)); + assert_eq!(teslas(&a.state), [(2, 0)]); + assert_eq!(a.state.history.at(), steps + 1); + run(&mut a, MCmd::AudioEditor(2)); + assert_eq!(a.state.history.at(), steps + 1, "shown again: no step"); + run(&mut a, MCmd::AudioEditorRecord(3)); + assert_eq!(a.state.history.at(), steps + 2); + crate::history::undo(&mut a.state); + crate::history::undo(&mut a.state); + assert!(teslas(&a.state).is_empty()); + } + /// Remove all (audio editor) instances, in Mixer options and TOOLS > Macros: every Tesla (a project's Edison slots /// too) goes after a question; with none, a message (spec: plugins/tesla/README.md "Recording into Tesla from /// Lemon Studio"). From bb17c66cd5d10d9eb6f738042bd91af99e552750 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sun, 8 Nov 2026 16:14:15 -0300 Subject: [PATCH 10/19] Undo of a send level puts back no level record when the route had none (it left one at unity: the saved file differed) Found by the equivalence harness (System/Render tests/Hardcore.flp, undo-to-start). Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/history.rs | 43 +++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/os-app/src/history.rs b/crates/os-app/src/history.rs index 3943838d3..41d430848 100644 --- a/crates/os-app/src/history.rs +++ b/crates/os-app/src/history.rs @@ -61,8 +61,9 @@ pub enum Snap { Pattern { number: u16, notes: Vec, controls: Vec, length: Option }, /// The playlist (`playlist::capture`). Playlist(Box), - /// A knob's value: the model's own for the channel knobs, else the knob's 0..1. - Knob(KnobId, f64), + /// A knob's value: the model's own for the channel knobs, else the knob's 0..1; `None` for a send with no level + /// record (at unity), so undo leaves none behind. + Knob(KnobId, Option), /// Mixer track `.0`'s audio input, output, record source and monitor mode (`MixerTrack::audio_in` ...); its armed /// state is no part of it (spec: audio/track-io.md §2.3). TrackIo(u16, [i32; 4]), @@ -292,17 +293,28 @@ fn put_rack(s: &mut State, r: RackSide) { } /// The model's value of a knob: the channels' volume and pan as stored (exact), the others as the knob reads them. -fn knob_value(s: &State, id: KnobId) -> f64 { - match id { +fn knob_value(s: &State, id: KnobId) -> Option { + Some(match id { KnobId::ChanVol(i) => s.project.channels.get(i).map_or(0.0, |c| c.volume), KnobId::ChanPan(i) => s.project.channels.get(i).map_or(0.0, |c| c.pan), // The level the container's list holds (the knob shows the chain's, an automated one included). KnobId::Member(t, k, id, wet) => crate::container::level(s, t, k, id, wet).map_or(0.0, f64::from), + // A route with no level record: the knob shows unity, and the record is what the step must put back. + KnobId::Send(a, b) => return crate::mixer::track(s, a).map_or(Some(0.8), |m| m.send_levels.iter().find(|(d, _)| *d == b).map(|&(_, l)| l)), _ => f64::from(s.knob(id)), - } + }) } -fn set_knob_value(s: &mut State, id: KnobId, v: f64) { +fn set_knob_value(s: &mut State, id: KnobId, v: Option) { + let Some(v) = v else { + if let KnobId::Send(a, b) = id { + if let Some(m) = crate::mixer::track_mut(s, a) { + m.send_levels.retain(|&(d, _)| d != b); + s.piano.dirty = true; + } + } + return; + }; match id { KnobId::ChanVol(i) => s.project.channels.get_mut(i).into_iter().for_each(|c| c.volume = v), KnobId::ChanPan(i) => s.project.channels.get_mut(i).into_iter().for_each(|c| c.pan = v), @@ -964,6 +976,25 @@ mod tests { assert_eq!(edit_menu(&app), [entry("Undo", "Ctrl+Z", false)]); } + #[test] + fn undoing_a_send_level_leaves_no_level_record() { + // A route with no level record plays at unity; turning its send knob writes one, and undo takes it away again + // instead of leaving one at unity (equivalence harness, System/Render tests/Hardcore.flp). + let mut app = App::new(None); + let s = &mut app.state; + let t = s.project.mixer.iter().position(|m| m.index == 1).expect("track 1"); + s.project.mixer[t].routes = vec![0]; + s.project.mixer[t].send_levels.clear(); + let id = KnobId::Send(1, 0); + knob(s, id, true); + s.set_knob(id, 0.5); + assert_eq!(s.project.mixer[t].send_levels, vec![(0, 0.5)]); + assert!(undo(s).is_some()); + assert_eq!(s.project.mixer[t].send_levels, vec![], "no record left behind"); + assert!(redo(s).is_some()); + assert_eq!(s.project.mixer[t].send_levels, vec![(0, 0.5)]); + } + /// Undo knob tweaks: on, a turn of a channel knob is a step and Ctrl+Z puts the knob back, the click before it /// staying; off, the turn makes no step and Ctrl+Z leaves the knob, undoing the click made before it (spec: /// ui/settings-choices.md "Undo history: the four checks (2026-10-16)", Undo knob tweaks). From d2c16d952065e06e3f8a686d66a11d8aa74ac2a1 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Sun, 8 Nov 2026 20:10:29 -0300 Subject: [PATCH 11/19] The rack's loop lengths follow the pattern shown: each pattern keeps its own, and a save no longer writes the lengths of the pattern opened over the pattern shown Found by the equivalence harness (Electroconductor - Inner Days - Original.flp: switch pattern, save, the pattern's loop lengths gone). Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/app.rs | 3 ++ crates/os-app/src/control/command.rs | 2 + crates/os-app/src/rack.rs | 77 ++++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/os-app/src/app.rs b/crates/os-app/src/app.rs index 3785c47e2..e1718956b 100644 --- a/crates/os-app/src/app.rs +++ b/crates/os-app/src/app.rs @@ -4274,6 +4274,9 @@ impl App { pub fn tick(&mut self) -> bool { // Things the interface added get their ids before anything looks them up (`control::ids`). let s = &mut self.state; + // The rack's loops follow the pattern shown: a file holds them per pattern (spec: flp/events/patterns.md "Loop + // lengths"). + crate::rack::follow_pattern(s); s.control.settle_tick(&mut s.project, s.piano.dirty); self.control_tick(); self.plugin_tick(); diff --git a/crates/os-app/src/control/command.rs b/crates/os-app/src/control/command.rs index 5c7ac6d53..62a237389 100644 --- a/crates/os-app/src/control/command.rs +++ b/crates/os-app/src/control/command.rs @@ -1231,6 +1231,8 @@ fn restore(s: &mut State, side: Side) { crate::rack::remap_ui(s, &map); let r = &mut s.rack; (r.groups, r.unsorted_at, r.filter, r.zipped, r.locked, r.loop_len, r.loop_unit) = (groups, unsorted_at, filter, zipped, locked, loop_len, loop_unit); + // The loop lengths put back are the pattern's put back with them. + r.loops_of = s.pattern; let tracks = crate::mixer::inserts(s); s.selected_mixer = s.selected_mixer.min(tracks); s.mixer.sel.retain(|&t| t <= tracks); diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index d70ec5115..3d4d9edca 100644 --- a/crates/os-app/src/rack.rs +++ b/crates/os-app/src/rack.rs @@ -237,6 +237,9 @@ pub struct RackUi { /// Advanced looping's loop points (§15, loop type 2): a row with one loops at the end of its last used step, beat /// or bar; a row without one does not loop. pub loop_unit: BTreeMap, + /// The pattern `loop_len`, `loop_mode` and `loop_type` belong to: the one shown when they were last loaded. + /// [`follow_pattern`] stores them into it and loads the shown one's when another is shown. + pub loops_of: u16, pub loop_type: u8, /// Position of "Unsorted" in the file's group list (`None` = last; see [`to_model`]). pub unsorted_at: Option, @@ -313,6 +316,8 @@ impl Default for RackUi { loop_mode: false, loop_len: BTreeMap::new(), loop_unit: BTreeMap::new(), + // The pattern a new project shows (`State`'s `pattern`). + loops_of: 1, loop_type: 0, unsorted_at: None, clipboard: None, @@ -2952,6 +2957,14 @@ pub fn from_model(s: &mut State) { None => Filter::All, }, }; + load_loops(s); +} + +/// The shown pattern's loop lengths, loop type and loop mode into the rack (they are the pattern's, in the file). +fn load_loops(s: &mut State) { + let p = &s.project; + let rack = &mut s.rack; + rack.loops_of = s.pattern; let pat = p.pattern(s.pattern); let loops = pat.map(|p| p.loops.clone()).unwrap_or_default(); rack.loop_len = loops @@ -2985,10 +2998,7 @@ fn loop_state(loops: &[(u16, u32)], off: Option) -> (BTreeSet<(u16, u32)>, /// Writes the rack state into the project before a save (the inverse of [`from_model`]). pub fn to_model(s: &mut State) { - // Advanced looping's loop points are saved as the lengths they give in the current pattern (Lemon Studio's choice: - // the file's own form for them is not known); a row with no notes there has none. - let lens: BTreeMap = - if advanced(s) { s.rack.loop_unit.keys().filter_map(|&r| unit_len(s, r).map(|l| (r, l))).collect() } else { s.rack.loop_len.clone() }; + follow_pattern(s); let rack = &s.rack; let p = &mut s.project; // Groups in file order: the user groups, with "Unsorted" where the file had it (else last, like FL's Alt+G). @@ -3014,7 +3024,36 @@ pub fn to_model(s: &mut State) { if p.groups.is_empty() { p.current_group = None; } - // Loop lengths belong to the current pattern. + store_loops(s); +} + +/// When another pattern is shown than the one the rack's loops belong to: they go into theirs (unless it is gone), +/// and the shown pattern's are loaded. Before, a save wrote them into whichever pattern was shown, over its own. +pub(crate) fn follow_pattern(s: &mut State) { + let (shown, of) = (s.pattern, s.rack.loops_of); + if shown == of { + return; + } + if s.project.pattern(of).is_some() { + s.pattern = of; + store_loops(s); + s.pattern = shown; + } + let before = (s.rack.loop_len.clone(), s.rack.loop_mode, s.rack.loop_type); + load_loops(s); + if (s.rack.loop_len.clone(), s.rack.loop_mode, s.rack.loop_type) != before { + s.piano.dirty = true; + } +} + +/// The rack's loop lengths, type and mode into the pattern they belong to (the shown one: `follow_pattern` first). +fn store_loops(s: &mut State) { + // Advanced looping's loop points are saved as the lengths they give in the pattern (Lemon Studio's choice: the + // file's own form for them is not known); a row with no notes there has none. + let lens: BTreeMap = + if advanced(s) { s.rack.loop_unit.keys().filter_map(|&r| unit_len(s, r).map(|l| (r, l))).collect() } else { s.rack.loop_len.clone() }; + let rack = &s.rack; + let p = &mut s.project; let mut loops: Vec<(u16, u32)> = lens.iter().filter_map(|(&r, &l)| p.channels.get(r).map(|c| (c.index, l))).collect(); // Loop mode on with every channel following the pattern and none of its own length: FL's file says so with a // follow entry for each channel (and no loop_off), which is what `from_model` reads back as loop mode on. @@ -3064,6 +3103,33 @@ mod tests { assert!(a.state.rack.loop_mode && a.state.rack.loop_type == 1); } + /// Each pattern keeps its own loop lengths: showing another pattern loads its lengths, and a save there no longer + /// writes the first one's over them (equivalence harness, Electroconductor - Inner Days - Original.flp). + #[test] + fn loop_lengths_follow_the_pattern_shown() { + let mut a = crate::app::App::new(None); + a.state.project = crate::app::test_project(); + let ch = a.state.project.channels[0].index; + for (n, len) in [(1, 3), (2, 5)] { + let pat = crate::patterns::pat_mut(&mut a.state, n); + (pat.loops, pat.loop_off) = (vec![(ch, len)], None); + } + a.state.pattern = 1; + from_model(&mut a.state); + assert_eq!(a.state.rack.loop_len, [(0, 3)].into()); + a.state.rack.loop_len.insert(0, 4); + a.state.pattern = 2; + follow_pattern(&mut a.state); + assert_eq!(a.state.rack.loop_len, [(0, 5)].into(), "pattern 2's own"); + to_model(&mut a.state); + let loops = |a: &crate::app::App, n| a.state.project.pattern(n).map(|p| p.loops.clone()); + assert_eq!(loops(&a, 2), Some(vec![(ch, 5)]), "pattern 2 keeps its lengths"); + assert_eq!(loops(&a, 1), Some(vec![(ch, 4)]), "pattern 1 keeps the length set while it was shown"); + a.state.pattern = 1; + follow_pattern(&mut a.state); + assert_eq!(a.state.rack.loop_len, [(0, 4)].into()); + } + /// A project opens on the pattern it was saved on, with the loop lengths saved into that pattern. #[test] fn the_current_pattern_and_its_loops_reopen() { @@ -3077,6 +3143,7 @@ mod tests { let note = os_model::Note { position: 0, length: 96, channel: ch, key: 60, fine_pitch: 120, release: 64, pan: 64, velocity: 100, mod_x: 128, mod_y: 128, flags: 0x4000, group: 0, unk14: 0, repeat: 0 }; crate::patterns::pat_mut(&mut a.state, 2).notes.push(note); a.state.pattern = 2; + follow_pattern(&mut a.state); a.state.rack.loop_len.insert(0, 8); a.save_to(file.clone()); let mut b = crate::app::App::new(None); From 9f8a45d5fdab640b2e2c64f53121cd335c1cce43 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Mon, 9 Nov 2026 10:07:08 -0300 Subject: [PATCH 12/19] A piano roll tool's preview is put back in the pattern it previewed on when another pattern is shown meanwhile The tool dialogs (Chop, Quantize, Flam and the others) preview on the notes without an undo step. A switch to another pattern while one was open (New pattern, the pattern selector) left the previewed notes in the old pattern: cancel, Accept and the next preview each skipped the put-back, so no undo could bring the notes back. Found by the equivalence harness (3OSC, Ogun: undo to the start left a chopped note). Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/app.rs | 28 ++++++++++++++++++++++++++++ crates/os-app/src/piano_dlg.rs | 23 ++++++++++++++++------- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/os-app/src/app.rs b/crates/os-app/src/app.rs index e1718956b..9c14dd34e 100644 --- a/crates/os-app/src/app.rs +++ b/crates/os-app/src/app.rs @@ -19243,6 +19243,34 @@ mod tests { } frame(&mut app, &mut c, None); + // Another pattern shown while a dialog previews (New pattern, the pattern selector): the pattern previewed on + // gets its notes back on ×, on Accept and when the preview starts again on the new pattern, as no undo step + // holds the preview. + { + use crate::piano_dlg::Act as DAct; + let pat = app.state.pattern; + let notes_of = |app: &App, n: u16| app.state.project.patterns.iter().find(|p| p.number == n).map(|p| p.notes.clone()).unwrap_or_default(); + put(&mut app, &[(0, 96, 60), (96, 48, 62)]); + let before = notes_of(&app, pat); + for close in [Action::CloseWin(Win::PianoDlg), Action::PianoDlg(DAct::Accept)] { + key(&mut app, "u", false, true, false); + assert_ne!(notes_of(&app, pat), before, "previewed"); + app.state.pattern = pat + 1; + app.act(close.clone()); + assert_eq!(notes_of(&app, pat), before, "{close:?} puts back the pattern previewed on"); + assert!(!app.state.shown(Win::PianoDlg)); + app.state.pattern = pat; + } + key(&mut app, "u", false, true, false); + app.state.pattern = pat + 1; + app.act(Action::PianoDlg(DAct::Group)); + assert_eq!(notes_of(&app, pat), before, "a preview on the new pattern puts the old one back"); + app.act(Action::CloseWin(Win::PianoDlg)); + app.state.pattern = pat; + assert_eq!(notes_of(&app, pat), before); + } + frame(&mut app, &mut c, None); + // The toolbar's tools button opens the Tools menu. let r = hit_rect(&app, &Action::PianoToolsMenu); app.mouse_move(r.cx(), r.cy()); diff --git a/crates/os-app/src/piano_dlg.rs b/crates/os-app/src/piano_dlg.rs index 3fd20665b..ee9cd2e4b 100644 --- a/crates/os-app/src/piano_dlg.rs +++ b/crates/os-app/src/piano_dlg.rs @@ -711,12 +711,16 @@ pub fn preview(s: &mut State) { None if s.piano_dlg.on_events => 0, None => return, }; - let (now, now_ctl) = (piano::notes(s).to_vec(), piano::controls(s).to_vec()); + let (mut now, mut now_ctl) = (piano::notes(s).to_vec(), piano::controls(s).to_vec()); let fresh = match &s.piano_dlg.session { Some(se) => se.pattern != s.pattern || se.channel != ch || se.last != now || se.last_ctl != now_ctl, None => true, }; if fresh { + // The last preview goes first: on another pattern or channel it would stay, with no undo step. + if restore(s) { + (now, now_ctl) = (piano::notes(s).to_vec(), piano::controls(s).to_vec()); + } let base_sel = s.piano.selection(s.pattern, Some(ch)); let se = Session { pattern: s.pattern, channel: ch, base: now, base_sel, base_ctl: now_ctl, last: Vec::new(), last_ctl: Vec::new(), shown: false }; s.piano_dlg.session = Some(se); @@ -729,19 +733,22 @@ pub fn preview(s: &mut State) { } } -/// The session's notes, selection and control points back, when they are still what its last preview left. +/// The session's notes, selection and control points back in the pattern it previewed on (the one shown may be another +/// by now: New pattern, the pattern selector), when they are still what its last preview left. fn restore(s: &mut State) -> bool { let Some(se) = &s.piano_dlg.session else { return false }; - if se.pattern != s.pattern || se.shown && (piano::notes(s) != se.last.as_slice() || piano::controls(s) != se.last_ctl.as_slice()) { + let Some(i) = s.project.patterns.iter().position(|p| p.number == se.pattern) else { return false }; + let p = &s.project.patterns[i]; + if se.shown && (p.notes != se.last || p.controls != se.last_ctl) { return false; } - let (base, base_ctl, sel, ch) = (se.base.clone(), se.base_ctl.clone(), se.base_sel.clone(), se.channel); - let p = piano::pattern_mut(s); + let (base, base_ctl, sel, ch, number) = (se.base.clone(), se.base_ctl.clone(), se.base_sel.clone(), se.channel, se.pattern); + let p = &mut s.project.patterns[i]; (p.notes, p.controls) = (base, base_ctl); if sel.is_empty() { s.piano.clear_selection(); } else { - s.piano.select(s.pattern, ch, sel); + s.piano.select(number, ch, sel); } s.piano.dirty = true; true @@ -751,7 +758,9 @@ fn restore(s: &mut State) -> bool { pub fn accept(s: &mut State) -> String { let (tool, on_events) = (s.piano_dlg.tool, s.piano_dlg.on_events); let who = if on_events { "Event editor" } else { "Piano roll" }; - if !restore(s) { + // Accept applies what was previewed: after a switch to another pattern, the previewed one only gets its notes back. + let here = s.piano_dlg.session.as_ref().is_some_and(|se| se.pattern == s.pattern); + if !restore(s) || !here { s.piano_dlg.session = None; return format!("{who}: nothing to change"); } From d6d5a172c247b09cbc9997649e2ed40eecd3c5d4 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Mon, 9 Nov 2026 10:17:48 -0300 Subject: [PATCH 13/19] Undo and redo of a pattern edit leave the pattern shown, as in FL Measured on FL 24.1.1 (2026-11-09, hidden display): a step sequencer edit and a piano roll note added in one pattern, then another pattern shown; undo and redo changed the first pattern's notes and FL kept showing the other. Lemon switched to the pattern edited. Found by the equivalence harness (redo to the end showed another pattern than before, 6 of 151 projects). Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/history.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/os-app/src/history.rs b/crates/os-app/src/history.rs index 41d430848..8e2929243 100644 --- a/crates/os-app/src/history.rs +++ b/crates/os-app/src/history.rs @@ -218,11 +218,11 @@ impl Snap { } } - /// Puts this into the project. A pattern's step also shows that pattern, as the piano roll's undo always did. + /// Puts this into the project. A pattern's step leaves the pattern shown as it is, as in FL (24.1.1, 2026-11-09: a + /// step sequencer and a piano roll edit in one pattern, undone and redone while another was shown, kept that one). fn put(self, s: &mut State) { match self { Snap::Pattern { number, notes, controls, length } => { - s.pattern = number; let p = crate::patterns::pat_mut(s, number); (p.notes, p.controls, p.length) = (notes, controls, length); s.piano.clear_selection(); @@ -995,6 +995,23 @@ mod tests { assert_eq!(s.project.mixer[t].send_levels, vec![(0, 0.5)]); } + #[test] + fn undo_and_redo_of_a_pattern_edit_leave_the_pattern_shown() { + // As in FL 24.1.1 (2026-11-09): a note added in pattern 1, pattern 2 shown, undo and redo change pattern 1's + // notes and leave pattern 2 shown (equivalence harness, redo to the end showed the pattern edited). + let mut app = App::new(None); + let s = &mut app.state; + s.pattern = 1; + crate::piano::snapshot_as(s, "Piano roll edit"); + crate::patterns::pat_mut(s, 1).notes.push(os_model::Note::step(0, 0)); + let edited = s.project.pattern(1).map(|p| p.notes.clone()); + s.pattern = 2; + assert!(undo(s).is_some()); + assert_eq!((s.pattern, s.project.pattern(1).map(|p| p.notes.len())), (2, Some(0))); + assert!(redo(s).is_some()); + assert_eq!((s.pattern, s.project.pattern(1).map(|p| p.notes.clone())), (2, edited)); + } + /// Undo knob tweaks: on, a turn of a channel knob is a step and Ctrl+Z puts the knob back, the click before it /// staying; off, the turn makes no step and Ctrl+Z leaves the knob, undoing the click made before it (spec: /// ui/settings-choices.md "Undo history: the four checks (2026-10-16)", Undo knob tweaks). From 21fa1fae47cf10164f835e82074795dbd547c52c Mon Sep 17 00:00:00 2001 From: thacer13 Date: Mon, 9 Nov 2026 10:54:04 -0300 Subject: [PATCH 14/19] A drag in the channel rack's graph editor is one undo step, as in FL ui/undo-steps.md measured "Graph editor change velocity", one per drag, on FL 24.1.1; Lemon recorded none, so undo skipped the drag and took back the edit before it. A press that changes nothing leaves no step. Reported by the lead's FL parity audit (2026-11-08, row "Graph editor undo step"). Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/app.rs | 37 +++++++++++++++++++++++++++++++++++++ crates/os-app/src/rack.rs | 8 ++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/os-app/src/app.rs b/crates/os-app/src/app.rs index 9c14dd34e..beb374758 100644 --- a/crates/os-app/src/app.rs +++ b/crates/os-app/src/app.rs @@ -9402,6 +9402,7 @@ impl App { Action::RackGraph => self.state.rack.graph_open = !self.state.rack.graph_open, Action::RackLoopBox(i) => self.drag = Some(Drag::RackLoopLen { row: i, y0: mouse.1, v0: rk::loop_box_value(&self.state, i) }), Action::RackGraphArea => { + crate::rack::graph_step(&mut self.state); self.rack_graph_at(mouse); self.drag = Some(Drag::RackGraph); } @@ -9439,6 +9440,7 @@ impl App { return true; } Action::RackGraphArea => { + crate::rack::graph_step(&mut self.state); self.rack_graph_at((x, y)); self.drag = Some(Drag::RackGraph); return true; @@ -13266,6 +13268,41 @@ mod tests { assert_eq!(app.rows_get(Scroll::RackY), None); } + /// A drag in the channel rack's graph editor is one undo step, "Graph editor change velocity" on the velocity tab; + /// a press that changes nothing leaves none (spec: ui/undo-steps.md; FL parity audit 2026-11-08 found it missing). + #[test] + fn a_graph_editor_drag_is_one_undo_step() { + let mut app = App::new(None); + let Ok(mut c) = Canvas::new(1920, 1080, 1.0) else { return }; + app.state.raise(Win::ChannelRack); + app.state.rack.graph_open = true; + app.state.selected_channel = 0; + let ch = app.state.project.channels[0].index; + let n = app.state.pattern.max(1); + crate::patterns::pat_mut(&mut app.state, n).notes.push(os_model::Note::step(ch, 0)); + frame(&mut app, &mut c, None); + let area = hit_rect(&app, &Action::RackGraphArea); + let x = hit_rect(&app, &Action::Step(0, 0)).cx(); + let vel = |app: &App| app.state.project.pattern(n).map(|p| p.notes[0].velocity); + let (before, steps) = (vel(&app), app.state.history.done()); + app.mouse_move(x, area.y + area.h * 0.9); + app.mouse_down(); + app.mouse_move(x, area.y + area.h * 0.6); + app.mouse_move(x, area.y + area.h * 0.4); + app.mouse_up(); + assert_ne!(vel(&app), before); + assert_eq!((app.state.history.done(), app.state.history.newest_done()), (steps + 1, Some("Graph editor change velocity"))); + assert!(crate::history::undo(&mut app.state).is_some()); + assert_eq!(vel(&app), before, "one step takes the whole drag back"); + app.state.pattern = n + 1; + frame(&mut app, &mut c, None); + let steps = app.state.history.done(); + app.mouse_move(x, area.y + area.h * 0.5); + app.mouse_down(); + app.mouse_up(); + assert_eq!((app.state.history.done(), vel(&app)), (steps, before), "a press in a pattern without steps changes nothing"); + } + fn hit_rect(app: &App, a: &Action) -> Rect { app.hits.iter().rev().find(|h| &h.action == a).map(|h| h.rect).unwrap_or_else(|| panic!("no hit for {a:?}")) } diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index 3d4d9edca..1e214d87a 100644 --- a/crates/os-app/src/rack.rs +++ b/crates/os-app/src/rack.rs @@ -2896,6 +2896,14 @@ pub fn fill_hit(s: &mut State, h: FillHit) { f.rebuild(); } +/// A press in the graph editor (§14): one undo step for the drag, dropped on release when it changed nothing (spec: +/// ui/undo-steps.md, "Graph editor change velocity", one per drag). Only the velocity tab's name was measured. +pub fn graph_step(s: &mut State) { + let name = if s.rack.graph_tab == Tab::Vel { "Graph editor change velocity" } else { "Graph editor change" }; + let snap = crate::history::Snap::pattern(s, s.pattern.max(1)); + crate::history::record(s, name, snap); +} + /// Graph editor (§14): set step `k`'s value of the focused channel from 0..1; a column without a step edits the /// nearest step note to its left. pub fn graph_set(s: &mut State, k: u32, v: f32) -> Option { From eb66bc3197998fc498da578c1c5389ec7c7d2d56 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Mon, 9 Nov 2026 11:55:46 -0300 Subject: [PATCH 15/19] Undo of a whole-project step puts the rack's loop mode and type back with its lengths, and leaves the pattern shown The project snapshot (Add effect, channel and track edits) held the rack's loop lengths but not its loop mode or type: after an undo the rack paired one pattern's lengths with another's mode, and the next pattern switch wrote that into the pattern without an undo step (equivalence harness, DEDZII - NIGHT TERROR). The undo also showed the pattern shown when the step was made; it now leaves the pattern shown, as FL does for pattern edits (2318a9c0), except for steps that make or remove patterns (a clone, a delete), which show the pattern they showed. Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/control/command.rs | 19 +++++++++++++++++-- crates/os-app/src/rack.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/os-app/src/control/command.rs b/crates/os-app/src/control/command.rs index 62a237389..a77b98bf5 100644 --- a/crates/os-app/src/control/command.rs +++ b/crates/os-app/src/control/command.rs @@ -385,6 +385,9 @@ pub struct Side { pub icons: std::collections::HashMap, /// Advanced looping's loop points by row, beside the loop lengths. pub loop_unit: std::collections::BTreeMap, + /// Loop mode and type, which say with the lengths what `pattern`'s loops are. + pub loop_mode: bool, + pub loop_type: u8, } impl Side { @@ -404,6 +407,8 @@ impl Side { slot_colors: s.mixer.slot_colors.clone(), icons: s.playlist.icons.clone(), loop_unit: r.loop_unit.clone(), + loop_mode: r.loop_mode, + loop_type: r.loop_type, } } } @@ -1221,7 +1226,10 @@ fn restore(s: &mut State, side: Side) { let sel_clips = selected_clips(s); let new_row: std::collections::HashMap = side.project.channels.iter().enumerate().map(|(r, c)| (c.id.0, r)).collect(); let map: Vec> = old.iter().map(|id| new_row.get(id).copied()).collect(); - let Side { project, tempo, pattern, groups, unsorted_at, filter, zipped, locked, loop_len, slot_names, slot_colors, icons, loop_unit } = side; + let Side { project, tempo, pattern, groups, unsorted_at, filter, zipped, locked, loop_len, slot_names, slot_colors, icons, loop_unit, loop_mode, loop_type } = side; + let shown = s.pattern; + let numbers = |p: &Project| p.patterns.iter().map(|q| q.number).collect::>(); + let patterns_before = numbers(&s.project); (s.mixer.slot_names, s.mixer.slot_colors, s.playlist.icons) = (slot_names, slot_colors, icons); let mutes = Mutes::of(&s.project); s.project = project; @@ -1231,8 +1239,15 @@ fn restore(s: &mut State, side: Side) { crate::rack::remap_ui(s, &map); let r = &mut s.rack; (r.groups, r.unsorted_at, r.filter, r.zipped, r.locked, r.loop_len, r.loop_unit) = (groups, unsorted_at, filter, zipped, locked, loop_len, loop_unit); - // The loop lengths put back are the pattern's put back with them. + (r.loop_mode, r.loop_type) = (loop_mode, loop_type); + // The loops put back are those of the pattern shown when the step was made. The pattern shown now stays shown, as + // in FL (an undo leaves the pattern shown), and gets its own loops; a step that made or removed patterns (a clone, + // a delete) shows the pattern it showed. r.loops_of = s.pattern; + if numbers(&s.project) == patterns_before { + s.pattern = shown; + } + crate::rack::follow_pattern(s); let tracks = crate::mixer::inserts(s); s.selected_mixer = s.selected_mixer.min(tracks); s.mixer.sel.retain(|&t| t <= tracks); diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index 1e214d87a..90f2bb01b 100644 --- a/crates/os-app/src/rack.rs +++ b/crates/os-app/src/rack.rs @@ -3138,6 +3138,34 @@ mod tests { assert_eq!(a.state.rack.loop_len, [(0, 4)].into()); } + /// Undo of a whole-project step leaves the pattern shown as it is (as in FL) and puts the loop mode back with the + /// lengths: a pattern whose loops say "no length, mode off" keeps them when patterns are switched afterwards + /// (equivalence harness, DEDZII - NIGHT TERROR: they were rewritten with another pattern's mode, without a step). + #[test] + fn undo_of_a_project_step_keeps_the_pattern_shown_and_its_loops() { + let mut a = crate::app::App::new(None); + a.state.project = crate::app::test_project(); + let ch = a.state.project.channels[0].index; + let pat = crate::patterns::pat_mut(&mut a.state, 1); + pat.notes.push(os_model::Note::step(ch, 0)); + (pat.loops, pat.loop_off) = (vec![(ch, 0)], Some(LOOP_TYPE_CODES[0])); + let before = a.state.project.pattern(1).cloned(); + a.state.pattern = 1; + from_model(&mut a.state); + let p2 = crate::patterns::pat_mut(&mut a.state, 2); + p2.notes.push(os_model::Note::step(ch, 0)); + (p2.loops, p2.loop_off) = (vec![(ch, 4)], None); + crate::control::edit(&mut a.state, "Project step", crate::control::command::Region::Project, |s| s.project.main_volume = Some(50)); + a.state.pattern = 2; + follow_pattern(&mut a.state); + assert!(crate::history::undo(&mut a.state).is_some()); + assert_eq!(a.state.pattern, 2, "the pattern shown stays"); + assert_eq!((a.state.rack.loop_len.clone(), a.state.rack.loop_mode), ([(0, 4)].into(), true), "pattern 2's own loops"); + a.state.pattern = 3; + follow_pattern(&mut a.state); + assert_eq!(a.state.project.pattern(1).cloned(), before, "pattern 1's loops as they were"); + } + /// A project opens on the pattern it was saved on, with the loop lengths saved into that pattern. #[test] fn the_current_pattern_and_its_loops_reopen() { From 5206d1207f474bf052c7d2884b0527f507217013 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Mon, 9 Nov 2026 12:16:41 -0300 Subject: [PATCH 16/19] A song with no clips plays the pattern shown, not the pattern the project was opened or saved on The engine plays the project's current pattern in song mode when the playlist has no clips; play_project handed it the pattern from the last open or save, so a switch was not heard until a save. It now hands the pattern shown, and a pattern switch reaches playback. Found by the equivalence harness (I'll Be Your Madonna: after Save new version, the engine held another pattern than the project). Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/rack.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index 90f2bb01b..5a2b4325c 100644 --- a/crates/os-app/src/rack.rs +++ b/crates/os-app/src/rack.rs @@ -2661,6 +2661,8 @@ fn tempo_fitted(c: &os_model::Channel) -> bool { pub fn play_project(s: &State) -> Project { let mut p = s.project.clone(); p.tempo = Some(s.tempo); + // The pattern shown: a song with no clips plays it (os-engine), not the one the project was opened or saved on. + p.current_pattern = Some(s.pattern.max(1)); let rows = loop_rows(s); if s.rack.loop_mode && !rows.is_empty() { expand_loops(&mut p, s, &rows); @@ -3047,11 +3049,9 @@ pub(crate) fn follow_pattern(s: &mut State) { store_loops(s); s.pattern = shown; } - let before = (s.rack.loop_len.clone(), s.rack.loop_mode, s.rack.loop_type); load_loops(s); - if (s.rack.loop_len.clone(), s.rack.loop_mode, s.rack.loop_type) != before { - s.piano.dirty = true; - } + // Playback follows: the pattern's loops, and a song with no clips plays the pattern shown (`play_project`). + s.piano.dirty = true; } /// The rack's loop lengths, type and mode into the pattern they belong to (the shown one: `follow_pattern` first). @@ -3166,6 +3166,21 @@ mod tests { assert_eq!(a.state.project.pattern(1).cloned(), before, "pattern 1's loops as they were"); } + /// A song with no clips plays the pattern shown: the engine is handed it, and a switch reaches playback + /// (equivalence harness, I'll Be Your Madonna: the engine held the pattern the project was opened on). + #[test] + fn the_engine_gets_the_pattern_shown() { + let mut a = crate::app::App::new(None); + a.state.project = crate::app::test_project(); + a.state.pattern = 1; + from_model(&mut a.state); + a.state.piano.dirty = false; + a.state.pattern = 2; + follow_pattern(&mut a.state); + assert!(a.state.piano.dirty, "playback follows the switch"); + assert_eq!(play_project(&a.state).current_pattern, Some(2)); + } + /// A project opens on the pattern it was saved on, with the loop lengths saved into that pattern. #[test] fn the_current_pattern_and_its_loops_reopen() { From e16ff69afc739fffd261595303579850895590fd Mon Sep 17 00:00:00 2001 From: thacer13 Date: Mon, 9 Nov 2026 13:28:43 -0300 Subject: [PATCH 17/19] An edit no longer drops the knob step before it when the edit renumbers what the step names An edit judged whether the newest pressed step had changed anything after making its own change: a mixer track deleted before a wheeled knob's track put another track's value in that place, the knob's step read as unchanged and was dropped, so undo could not take the turn back. The check now runs before the edit. Found by the equivalence harness (nucleon - Fruity Delay 3: stereo separation wheeled, a track deleted). Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/control/mod.rs | 9 ++++++++- crates/os-app/src/history.rs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/os-app/src/control/mod.rs b/crates/os-app/src/control/mod.rs index 402182f21..ea98653a0 100644 --- a/crates/os-app/src/control/mod.rs +++ b/crates/os-app/src/control/mod.rs @@ -327,6 +327,9 @@ pub fn apply(s: &mut State, by: &By, name: Option<&str>, commands: Vec) /// `Command::Put`, as a command's own undo is. pub fn edit(s: &mut State, name: &'static str, region: command::Region, f: impl FnOnce(&mut State) -> R) -> R { s.link_moves.clear(); + // A pressed step that changed nothing goes before this edit, judged on the project as it is now: after the edit + // its region may name other things (tracks renumbered by a delete). + crate::history::settle(s); let before = command::Taken::of(s, region); let r = f(s); if let Some(before) = before { @@ -354,6 +357,9 @@ pub fn run_edit(s: &mut State, name: &'static str, region: command::Region, r return f(s); } s.link_moves.clear(); + // A pressed step that changed nothing goes before this edit, judged on the project as it is now: after the edit + // its region may name other things (tracks renumbered by a delete). + crate::history::settle(s); let before = command::Taken::of(s, region); let r = f(s); if let Some(before) = before { @@ -368,7 +374,6 @@ pub fn run_edit(s: &mut State, name: &'static str, region: command::Region, r /// Records an interface edit's step. A step of the same press that changed nothing goes first (a text box's playlist /// step when the box named a mixer track), as the press's release would have dropped it. fn record_edit(s: &mut State, name: &'static str, before: command::Taken) { - crate::history::settle(s); let mut undo = vec![Command::Put(Box::new(before))]; // The remote control links the edit moved with what they drive (`remote::follow`) go back with it. let links: Vec<_> = std::mem::take(&mut s.link_moves).into_iter().map(|(from, to)| (to, from)).collect(); @@ -382,6 +387,7 @@ fn record_edit(s: &mut State, name: &'static str, before: command::Taken) { /// `edit` for an edit that needs the app (a mixer menu command hosting or dropping plugins). pub fn edit_app(app: &mut crate::app::App, name: &'static str, region: command::Region, f: impl FnOnce(&mut crate::app::App) -> R) -> R { app.state.link_moves.clear(); + crate::history::settle(&mut app.state); let before = command::Taken::of(&app.state, region); let r = f(app); let s = &mut app.state; @@ -463,6 +469,7 @@ pub fn edit_run(app: &mut crate::app::App, name: &'static str, region: comman return f(app); } app.state.link_moves.clear(); + crate::history::settle(&mut app.state); let before = command::Taken::of(&app.state, region); let r = f(app); let s = &mut app.state; diff --git a/crates/os-app/src/history.rs b/crates/os-app/src/history.rs index 8e2929243..19f3fa252 100644 --- a/crates/os-app/src/history.rs +++ b/crates/os-app/src/history.rs @@ -1012,6 +1012,23 @@ mod tests { assert_eq!((s.pattern, s.project.pattern(1).map(|p| p.notes.clone())), (2, edited)); } + #[test] + fn a_knob_turn_stays_a_step_when_a_track_before_it_is_deleted() { + // Track 3's separation wheeled, then track 2 deleted: track 3's place now holds track 4's separation, the + // value track 3 had before the wheel. The wheel's step was judged on that and dropped as a no-op; both steps + // stay, and undoing them gives the project back (equivalence harness, nucleon - Fruity Delay 3). + let mut app = App::new(None); + let id = KnobId::MixSep(3); + let start = app.state.project.clone(); + knob(&mut app.state, id, true); + app.state.set_knob(id, 0.25); + let steps = app.state.history.len(); + crate::mixer::run(&mut app, crate::mixer::MCmd::Delete(2)); + assert_eq!(app.state.history.len(), steps + 1, "the wheel's step stays"); + while undo(&mut app.state).is_some() {} + assert_eq!(app.state.project.mixer, start.mixer); + } + /// Undo knob tweaks: on, a turn of a channel knob is a step and Ctrl+Z puts the knob back, the click before it /// staying; off, the turn makes no step and Ctrl+Z leaves the knob, undoing the click made before it (spec: /// ui/settings-choices.md "Undo history: the four checks (2026-10-16)", Undo knob tweaks). From 2bcb2ee8b85b83d29aa138a35cab891cb8709038 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Mon, 9 Nov 2026 14:34:26 -0300 Subject: [PATCH 18/19] A save hands what it writes into the project to playback A save writes the interface's part of the project into it (the tempo, song mode, the pattern shown, the rack's groups and loops, the playlist's view); the engine kept the project from before, so it held another project than the one saved until the next edit. The save now marks the project changed when it wrote something new, as an edit does. Found by the equivalence harness (I'll Be Your Madonna, Electro: Save new version, then the engine was behind). Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/app.rs | 6 ++++++ crates/os-app/src/rack.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/os-app/src/app.rs b/crates/os-app/src/app.rs index beb374758..f4151d32f 100644 --- a/crates/os-app/src/app.rs +++ b/crates/os-app/src/app.rs @@ -2028,6 +2028,9 @@ impl App { } } } + // What the save writes into the project from the interface (below) reaches playback like any edit: the engine + // holds the project as it is. + let before = self.state.project.clone(); self.store_builtin_states(); let s = &mut self.state; s.project.tempo = Some(s.tempo); @@ -2039,6 +2042,9 @@ impl App { crate::rack::to_model(s); // The playlist's track height, zoom and scroll (event 242, spec: flp/events/playlist.md). crate::playlist::store_view(s); + if s.project != before { + s.piano.dirty = true; + } // A file Lemon Studio made before the project had a playlist has no arrangement block (FL's files always have // one): build it again from the model, which holds everything that file stored. let lacks_playlist = s.file.as_ref().and_then(|f| os_flp::project::load(f).ok()).is_some_and(|p| p.arrangements.is_empty()) diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index 5a2b4325c..b19538b6e 100644 --- a/crates/os-app/src/rack.rs +++ b/crates/os-app/src/rack.rs @@ -3181,6 +3181,25 @@ mod tests { assert_eq!(play_project(&a.state).current_pattern, Some(2)); } + /// What a save writes into the project from the interface (the tempo, the pattern shown) reaches playback: the + /// engine holds the project as it is after the save (equivalence harness, I'll Be Your Madonna and Electro). + #[test] + fn a_save_hands_what_it_writes_in_to_playback() { + let file = std::env::temp_dir().join(format!("openstudio-save-dirty-{}.flp", std::process::id())); + let mut a = crate::app::App::new(None); + a.state.project = crate::app::test_project(); + (a.state.file, a.state.path) = (None, None); + a.state.pattern = 3; + a.state.piano.dirty = false; + a.save_to(file.clone()); + assert_eq!(a.state.project.current_pattern, Some(3)); + assert!(a.state.piano.dirty, "playback gets the project as saved"); + a.state.piano.dirty = false; + a.save_to(file.clone()); + assert!(!a.state.piano.dirty, "a save that writes nothing new changes nothing"); + let _ = std::fs::remove_file(file); + } + /// A project opens on the pattern it was saved on, with the loop lengths saved into that pattern. #[test] fn the_current_pattern_and_its_loops_reopen() { From 720145062bf03a989bdd8ad2a4779410c4f4b898 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Tue, 10 Nov 2026 01:20:13 -0300 Subject: [PATCH 19/19] A new audio or automation clip holds the offsets FL writes for it (float -1.0, not the pattern clips' ff bytes) Painting a channel clip kept the pattern encoding in memory; the save wrote -1.0, so the reopened project differed from the one saved (found by the equivalence harness on Olbaid - Sytrus Essentials: fill paint, save). Co-Authored-By: Claude Opus 5.5 --- crates/os-model/src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/os-model/src/lib.rs b/crates/os-model/src/lib.rs index c4809a239..1335d2e7d 100644 --- a/crates/os-model/src/lib.rs +++ b/crates/os-model/src/lib.rs @@ -1762,8 +1762,15 @@ impl Clip { r }; + /// A clip showing its source from the start. Its offsets are "none" in its source's encoding: `ff ff ff ff` + /// for a pattern clip (the template's), float −1.0 for any other (spec: flp/events/playlist.md, clip offsets). pub fn new(position: u32, length: u32, track: u16, source: ClipSource) -> Clip { - Clip { id: Id::NONE, position, length, track, source, muted: false, window: None, raw: Clip::TEMPLATE } + let mut raw = Clip::TEMPLATE; + if let ClipSource::Channel(_) = source { + raw[24..28].copy_from_slice(&(-1.0f32).to_le_bytes()); + raw[28..32].copy_from_slice(&(-1.0f32).to_le_bytes()); + } + Clip { id: Id::NONE, position, length, track, source, muted: false, window: None, raw } } pub fn end(&self) -> u32 {