From c833ca1b78609a88d94da84100f7b359a8936640 Mon Sep 17 00:00:00 2001 From: Petr Gladkikh Date: Thu, 30 Nov 2023 01:10:48 +0100 Subject: [PATCH] Diff command history revamp Remaining issues: * Tape cut does not work as expected. * The code needs cleanup, some parts are unused now. * The history starts with empty track (undoing the first change clears the track - that may surprize for users). --- README.md | 11 +- src/app.rs | 2 +- src/changeset.rs | 38 ++-- src/edit_commands.rs | 436 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/stave.rs | 212 ++++++++------------ src/track.rs | 68 ++++--- src/track_edit.rs | 447 +++++++++++++++++-------------------------- src/track_history.rs | 150 ++++++++------- src/util.rs | 17 +- 10 files changed, 846 insertions(+), 536 deletions(-) create mode 100644 src/edit_commands.rs diff --git a/README.md b/README.md index 8a5a6ba..663bb31 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Off grid MIDI editor with following goals: -* Not a DAW (would like it to be but do not have enough time for that). +* Not a DAW: MIDI is the input, MIDI is exported (would like it to be but do not have enough time for that). * Do not care (much) about measures. Primarily aimed at piano real recordings without strict tempo/bars. * A feature absent in other midi editors I could get my hands on (both commercial and free ones): removing a piece of MIDI recording as one can remove a time fragment from PCM recording. For some reason DAW authors insist on handling @@ -12,9 +12,9 @@ Off grid MIDI editor with following goals: * Comfortable workflow with keyboard. * Allows making fine adjustments of notes and tempo. * Unlimited undo/redo. Never loose session data. Non destructive edits, do not override original files. -* Blackbox recording (always-on MIDI recording). +* Flight recorder (always-on MIDI recording). -I'd love to see this in one of commercial or open-source DAWs and even pay money for that, but I do not see it +I'd love this to be in one of commercial or open-source DAWs and even pay money for that, but I do not see it happening. ## Status @@ -35,14 +35,17 @@ I use Pianoteq, but that is a commercial product. ## TODO +- [ ] Organize commands, reduce diff disk usage. +- [ ] Highlight undo changes. +- [ ] Location history navigation (e.g. go to a bookmark that was visited recently), with Alt + LeftArrow / RightArrow - [ ] Adjust tempo for selection. - [ ] When start playing send current CC values (will help damper to take effect immediately, not on next change). - [ ] Time marks on stave ("minute:second" from the beginning). -- [ ] Location history navigation (e.g. go to a bookmark that was visited recently), with Alt + LeftArrow / RightArrow - [ ] Minimize use of unwrap. The biggest contention currently is event data shared between engine and stave. - [ ] Multi-track UI (for snippets, flight recorder, and copy/paste buffer). Can show only one at a time, though. Use tabs? - [ ] Show (scroll to) changing objects before undo/redo. +- [ ] Reduce number of range types (preferring util::Range?) - [ ] Zoom to fit whole composition. - [ ] Visual hint for out-of-view selected notes. Scroll to the earliest of the selected notes on an action, if none of them are currently visible. diff --git a/src/app.rs b/src/app.rs index 0790313..f510bff 100644 --- a/src/app.rs +++ b/src/app.rs @@ -99,7 +99,7 @@ impl EmApp { impl eframe::App for EmApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.stave.history.do_pending(); + // TODO (tokio, diff history) self.stave.history.do_pending(); if let Some(message) = self.message_receiver.try_iter().last() { match message { Message::UpdateTime(t) => { diff --git a/src/changeset.rs b/src/changeset.rs index e03b0ac..023c4d7 100644 --- a/src/changeset.rs +++ b/src/changeset.rs @@ -3,15 +3,18 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::common::VersionId; +use crate::edit_commands::{CommandDiff, EditCommandId}; use crate::track::{EventId, Note, Track, TrackEvent, TrackEventType}; -/// Simplest track edit operation. See Changeset for uses. +/// Simplest track edit operation. See [Changeset] for uses. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub enum EventAction { - // TODO It is possible to recover necessary state by patching one of the recent (preferably the - // most recent) snapshots. Such snapshots (the ones that track event ids) are not - // implemented yet, so adding "before" states here to support undo operations - // as the initial draft in-memory implementation. + /* Adding "before" states here to support undo operations (the EventAction itself has + enough information to undo). + TODO (possible revamp) Alternatively it is also possible to recover necessary state by patching + one of the recent snapshots. That approach may probably help to save space and simplify + data structures. E.g. Delete action will only need the event id and update action will + only need the new state. OTOH then we'll need to save snapshots more often. */ Delete(TrackEvent), Update(TrackEvent, TrackEvent), Insert(TrackEvent), @@ -41,16 +44,28 @@ impl EventAction { EventAction::Insert(_) => None, } } + + pub fn revert(&self) -> Self { + match self { + EventAction::Delete(ev) => EventAction::Insert(ev.clone()), + EventAction::Update(before, after) => { + EventAction::Update(after.clone(), before.clone()) + } + EventAction::Insert(ev) => EventAction::Delete(ev.clone()), + } + } } /// Complete patch of a track editing action. -/// TODO This should be a part of the persisted edit history, then it should contain the complete event values instead of ids. -/// Note that this would also require event ids that are unique within the whole project history (the generator value should be) +/// Plain [EventActionsList] can be used instead, but there the actions order becomes important +/// (e.g. duplicating 'update' actions will overwrite previous result). #[derive(Debug)] pub struct Changeset { pub changes: HashMap, } +pub type EventActionsList = Vec; + impl Changeset { pub fn empty() -> Self { Changeset { @@ -62,7 +77,7 @@ impl Changeset { self.changes.insert(action.event_id(), action); } - pub fn add_all(&mut self, actions: &Vec) { + pub fn add_all(&mut self, actions: &EventActionsList) { for a in actions.iter().cloned() { self.add(a); } @@ -77,14 +92,15 @@ impl Changeset { /// undo hints (so it is obvious what is currently changing), and avoid storing whole track /// every time. See also [Snapshot], [Changeset]. #[derive(Serialize, Deserialize)] -pub struct Patch { +pub struct HistoryLogEntry { pub base_version: VersionId, pub version: VersionId, - pub changes: Vec, + pub command_id: EditCommandId, + pub diff: Vec, } /// Serializable snapshot of a complete track state that can be exported or used as a base -/// for Patch sequence. See also [Patch]. +/// for Patch sequence. See also [HistoryLogEntry]. #[derive(Serialize, Deserialize)] pub struct Snapshot { pub version: VersionId, diff --git a/src/edit_commands.rs b/src/edit_commands.rs new file mode 100644 index 0000000..4647e16 --- /dev/null +++ b/src/edit_commands.rs @@ -0,0 +1,436 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::changeset::{Changeset, EventAction, EventActionsList}; +use crate::common::Time; +use crate::stave::PIANO_KEY_LINES; +use crate::track::{ + is_cc_switch_on, ControllerId, ControllerSetValue, EventId, Level, Note, Pitch, Track, + TrackEvent, TrackEventType, MAX_LEVEL, MIDI_CC_SUSTAIN_ID, +}; +use crate::util::{range_contains, IdSeq, Range}; + +#[derive(Serialize, Deserialize)] +pub enum EditCommandId { + ShiftTail, + TapeInsert, + TapeDelete, + AddNote, + DeleteEvents, + SetDamper, + SetDamperOn, + EventsShift, + NotesStretch, + NotesTranspose, + NotesAccent, + Load, +} + +// Commands that do not usually generate large patches can use generic Changeset, this is the default. +// Commands that cannot be stored efficiently should use custom diffs. +// Note to support undo, the event updates must be reversible. +#[derive(Debug, Serialize, Deserialize)] +pub enum CommandDiff { + Changeset { patch: EventActionsList }, + TailShift { at: Time, delta: Time }, +} + +// TODO (cleanup) Use a constant, ctx field, or a setting for delta value (see KEYBOARD_TIME_STEP). +const TIME_DELTA: Time = 10_000; + +pub type AppliedCommand = (EditCommandId, Vec); + +pub fn into_changeset(track: &Track, diffs: &Vec, changeset: &mut Changeset) { + for d in diffs { + apply_diff(track, d, changeset); + } +} + +pub fn apply_diff(track: &Track, diff: &CommandDiff, changeset: &mut Changeset) { + match diff { + CommandDiff::Changeset { patch } => changeset.add_all(patch), + CommandDiff::TailShift { at, delta } => do_shift_tail(track, at, &delta, changeset), + } +} + +pub fn revert_diffs(track: &Track, diffs: &Vec, changeset: &mut Changeset) { + for d in diffs.iter().rev() { + revert_diff(track, d, changeset); + } +} + +pub fn revert_diff(track: &Track, diff: &CommandDiff, changeset: &mut Changeset) { + match diff { + CommandDiff::Changeset { patch } => { + for action in patch.iter().rev() { + changeset.add(action.revert()) + } + } + CommandDiff::TailShift { at, delta } => do_shift_tail(track, at, &-delta, changeset), + } +} + +pub fn shift_tail(at: &Time, delta: &Time) -> (EditCommandId, Vec) { + ( + EditCommandId::ShiftTail, + vec![CommandDiff::TailShift { + at: *at, + delta: *delta, + }], + ) +} + +// TODO (cleanup) Organize the naming? Functions updating changeset, vs functions producing CommandDiffs +fn do_shift_tail(track: &Track, at: &Time, delta: &Time, changeset: &mut Changeset) { + // TODO (improvement) When moving earlier adjust last delta so the events will start exactly at 'at'. + let mut actions = vec![]; + for ev in &track.events { + if *at < ev.at { + if (ev.at + delta) < *at { + /* The action should be replayable and undoable. If we allow events to move earlier + then 'at' time, then on undo we should somehow find them still while not confusing them + with unchanged events in (at - delta, at] range (when if delta > 0). + Track events are expected to be in sorted order, so this will likely trigger early. */ + break; + } + actions.push(shift_event(&ev, delta)); + } + } + changeset.add_all(&actions); +} + +fn do_delete_events_range(track: &Track, range: &Range