diff --git a/crates/os-app/src/app.rs b/crates/os-app/src/app.rs index dcd97d3a..f4151d32 100644 --- a/crates/os-app/src/app.rs +++ b/crates/os-app/src/app.rs @@ -2028,14 +2028,23 @@ 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); 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). 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()) @@ -4271,6 +4280,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(); @@ -5001,7 +5013,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(); @@ -9396,6 +9408,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); } @@ -9433,6 +9446,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; @@ -13260,6 +13274,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:?}")) } @@ -16842,6 +16891,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)); @@ -19233,6 +19286,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/control/command.rs b/crates/os-app/src/control/command.rs index a9bdeb53..a77b98bf 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,14 @@ 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, + /// Loop mode and type, which say with the lengths what `pattern`'s loops are. + pub loop_mode: bool, + pub loop_type: u8, } impl Side { @@ -395,6 +404,11 @@ 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(), + loop_mode: r.loop_mode, + loop_type: r.loop_type, } } } @@ -1212,8 +1226,11 @@ 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, 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; mutes.keep(&mut s.project); @@ -1221,7 +1238,16 @@ 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); + (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/control/mod.rs b/crates/os-app/src/control/mod.rs index 402182f2..ea98653a 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 3943838d..19f3fa25 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]), @@ -217,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(); @@ -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,59 @@ 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)]); + } + + #[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)); + } + + #[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). diff --git a/crates/os-app/src/knobs.rs b/crates/os-app/src/knobs.rs index e9aca7f8..c7ec9e35 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 3bc8ab32..59bf6f21 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"): @@ -2929,7 +2930,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::>(); @@ -3104,6 +3105,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(); @@ -3491,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"). diff --git a/crates/os-app/src/one_click.rs b/crates/os-app/src/one_click.rs index b5552be8..3ebd4724 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/piano_dlg.rs b/crates/os-app/src/piano_dlg.rs index 3fd20665..ee9cd2e4 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"); } diff --git a/crates/os-app/src/playlist.rs b/crates/os-app/src/playlist.rs index 13d35bb7..86143a27 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] diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index 6b9d802b..b19538b6 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, @@ -1379,9 +1384,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)); } @@ -2653,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); @@ -2888,6 +2898,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 { @@ -2949,6 +2967,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 @@ -2968,12 +2994,21 @@ 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: - // 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). @@ -2999,18 +3034,48 @@ 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; + } + load_loops(s); + // 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). +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(); - 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 +3087,142 @@ 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); + } + + /// 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()); + } + + /// 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 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)); + } + + /// 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() { + 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; + // 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; + 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); + 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] diff --git a/crates/os-app/src/record.rs b/crates/os-app/src/record.rs index c9a3166e..af2b2ce4 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 a3b255b9..cb863a6a 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 55ade06a..6f2865f1 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 8e6ba364..5ce5e791 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), } } } diff --git a/crates/os-flp/src/project.rs b/crates/os-flp/src/project.rs index d1cb7053..b3e1d0ac 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}; diff --git a/crates/os-model/src/lib.rs b/crates/os-model/src/lib.rs index c4809a23..1335d2e7 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 {