From b86af7bbf54296f7704b33fbb64b31577dec7f57 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 12 Feb 2026 19:05:49 -0500 Subject: [PATCH] Add piano roll --- .../lightningbeam-core/src/action.rs | 24 ++++++ .../src/actions/update_midi_notes.rs | 8 ++ .../lightningbeam-editor/src/main.rs | 74 ++++++++++++++----- .../lightningbeam-editor/src/panes/mod.rs | 8 +- .../src/panes/piano_roll.rs | 46 +++++++++--- 5 files changed, 128 insertions(+), 32 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 15e4207..72c2896 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -96,6 +96,18 @@ pub trait Action: Send { fn rollback_backend(&mut self, _backend: &mut BackendContext, _document: &Document) -> Result<(), String> { Ok(()) } + + /// Return MIDI cache data reflecting the state after execute/redo. + /// Format: (clip_id, notes) where notes are (start_time, note, velocity, duration). + /// Used to keep the frontend MIDI event cache in sync after undo/redo. + fn midi_notes_after_execute(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> { + None + } + + /// Return MIDI cache data reflecting the state after rollback/undo. + fn midi_notes_after_rollback(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> { + None + } } /// Action executor that wraps the document and manages undo/redo @@ -245,6 +257,18 @@ impl ActionExecutor { self.undo_stack.last().map(|a| a.description()) } + /// Get MIDI cache data from the last action on the undo stack (after redo). + /// Returns the notes reflecting execute state. + pub fn last_undo_midi_notes(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> { + self.undo_stack.last().and_then(|a| a.midi_notes_after_execute()) + } + + /// Get MIDI cache data from the last action on the redo stack (after undo). + /// Returns the notes reflecting rollback state. + pub fn last_redo_midi_notes(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> { + self.redo_stack.last().and_then(|a| a.midi_notes_after_rollback()) + } + /// Get the description of the next action to redo pub fn redo_description(&self) -> Option { self.redo_stack.last().map(|a| a.description()) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/update_midi_notes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/update_midi_notes.rs index d78306e..06ca4f2 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/update_midi_notes.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/update_midi_notes.rs @@ -71,4 +71,12 @@ impl Action for UpdateMidiNotesAction { controller.update_midi_clip_notes(*track_id, self.midi_clip_id, self.old_notes.clone()); Ok(()) } + + fn midi_notes_after_execute(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> { + Some((self.midi_clip_id, &self.new_notes)) + } + + fn midi_notes_after_rollback(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> { + Some((self.midi_clip_id, &self.old_notes)) + } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 81be7ca..b867243 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1716,46 +1716,72 @@ impl EditorApp { // Edit menu MenuAction::Undo => { - if let Some(ref controller_arc) = self.audio_controller { + let undo_succeeded = if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); let mut backend_context = lightningbeam_core::action::BackendContext { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, }; - match self.action_executor.undo_with_backend(&mut backend_context) { - Ok(true) => println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()), - Ok(false) => println!("Nothing to undo"), - Err(e) => eprintln!("Undo failed: {}", e), + Ok(true) => { + println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()); + true + } + Ok(false) => { println!("Nothing to undo"); false } + Err(e) => { eprintln!("Undo failed: {}", e); false } } } else { match self.action_executor.undo() { - Ok(true) => println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()), - Ok(false) => println!("Nothing to undo"), - Err(e) => eprintln!("Undo failed: {}", e), + Ok(true) => { + println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()); + true + } + Ok(false) => { println!("Nothing to undo"); false } + Err(e) => { eprintln!("Undo failed: {}", e); false } + } + }; + // Rebuild MIDI cache after undo (backend_context dropped, borrows released) + if undo_succeeded { + let midi_update = self.action_executor.last_redo_midi_notes() + .map(|(id, notes)| (id, notes.to_vec())); + if let Some((clip_id, notes)) = midi_update { + self.rebuild_midi_cache_entry(clip_id, ¬es); } } } MenuAction::Redo => { - if let Some(ref controller_arc) = self.audio_controller { + let redo_succeeded = if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); let mut backend_context = lightningbeam_core::action::BackendContext { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, }; - match self.action_executor.redo_with_backend(&mut backend_context) { - Ok(true) => println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()), - Ok(false) => println!("Nothing to redo"), - Err(e) => eprintln!("Redo failed: {}", e), + Ok(true) => { + println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()); + true + } + Ok(false) => { println!("Nothing to redo"); false } + Err(e) => { eprintln!("Redo failed: {}", e); false } } } else { match self.action_executor.redo() { - Ok(true) => println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()), - Ok(false) => println!("Nothing to redo"), - Err(e) => eprintln!("Redo failed: {}", e), + Ok(true) => { + println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()); + true + } + Ok(false) => { println!("Nothing to redo"); false } + Err(e) => { eprintln!("Redo failed: {}", e); false } + } + }; + // Rebuild MIDI cache after redo (backend_context dropped, borrows released) + if redo_succeeded { + let midi_update = self.action_executor.last_undo_midi_notes() + .map(|(id, notes)| (id, notes.to_vec())); + if let Some((clip_id, notes)) = midi_update { + self.rebuild_midi_cache_entry(clip_id, ¬es); } } } @@ -2382,6 +2408,18 @@ impl EditorApp { } } + /// Rebuild a MIDI event cache entry from backend note format. + /// Called after undo/redo to keep the cache consistent with the backend. + fn rebuild_midi_cache_entry(&mut self, clip_id: u32, notes: &[(f64, u8, u8, f64)]) { + let mut events: Vec<(f64, u8, u8, bool)> = Vec::with_capacity(notes.len() * 2); + for &(start_time, note, velocity, duration) in notes { + events.push((start_time, note, velocity, true)); + events.push((start_time + duration, note, velocity, false)); + } + events.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + self.midi_event_cache.insert(clip_id, events); + } + /// Import a MIDI file via daw-backend fn import_midi(&mut self, path: &std::path::Path) -> Option { use lightningbeam_core::clip::AudioClip; @@ -3798,7 +3836,7 @@ impl eframe::App for EditorApp { paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance, polygon_sides: &mut self.polygon_sides, layer_to_track_map: &self.layer_to_track_map, - midi_event_cache: &self.midi_event_cache, + midi_event_cache: &mut self.midi_event_cache, audio_pools_with_new_waveforms: &self.audio_pools_with_new_waveforms, raw_audio_cache: &self.raw_audio_cache, waveform_gpu_dirty: &mut self.waveform_gpu_dirty, @@ -4028,7 +4066,7 @@ struct RenderContext<'a> { /// Mapping from Document layer UUIDs to daw-backend TrackIds layer_to_track_map: &'a std::collections::HashMap, /// Cache of MIDI events for rendering (keyed by backend midi_clip_id) - midi_event_cache: &'a HashMap>, + midi_event_cache: &'a mut HashMap>, /// Audio pool indices with new raw audio data this frame (for thumbnail invalidation) audio_pools_with_new_waveforms: &'a HashSet, /// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels)) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index b5a0fbf..3c49a04 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -187,8 +187,12 @@ pub struct SharedPaneState<'a> { pub paint_bucket_gap_tolerance: &'a mut f64, /// Number of sides for polygon tool pub polygon_sides: &'a mut u32, - /// Cache of MIDI events for rendering (keyed by backend midi_clip_id) - pub midi_event_cache: &'a std::collections::HashMap>, + /// Cache of MIDI events for rendering (keyed by backend midi_clip_id). + /// Mutable so panes can update the cache immediately on edits (avoiding 1-frame snap-back). + /// NOTE: If an action later fails during execution, the cache may be out of sync with the + /// backend. This is acceptable because MIDI note edits are simple and unlikely to fail. + /// Undo/redo rebuilds affected entries from the backend to restore consistency. + pub midi_event_cache: &'a mut std::collections::HashMap>, /// Audio pool indices that got new raw audio data this frame (for thumbnail invalidation) pub audio_pools_with_new_waveforms: &'a std::collections::HashSet, /// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels)) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 74aa258..a9fa000 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -78,10 +78,11 @@ pub struct PianoRollPane { selected_clip_id: Option, // Note preview - preview_note: Option, - preview_base_note: Option, // original pitch before drag offset + preview_note: Option, // current preview pitch (stays set after auto-release for re-strike check) + preview_note_sounding: bool, // true while MIDI note-on is active (false after auto-release) + preview_base_note: Option, // original pitch before drag offset preview_velocity: u8, - preview_duration: Option, // auto-release after this many seconds (None = hold until mouse-up) + preview_duration: Option, // auto-release after this many seconds (None = hold until mouse-up) preview_start_time: f64, // Auto-scroll @@ -117,6 +118,7 @@ impl PianoRollPane { drag_note_offsets: None, selected_clip_id: None, preview_note: None, + preview_note_sounding: false, preview_base_note: None, preview_velocity: DEFAULT_VELOCITY, preview_duration: None, @@ -619,10 +621,20 @@ impl PianoRollPane { let ctrl_held = ui.input(|i| i.modifiers.ctrl); let now = ui.input(|i| i.time); - // Auto-release preview note after its duration expires - if let (Some(_), Some(dur)) = (self.preview_note, self.preview_duration) { - if now - self.preview_start_time >= dur { - self.preview_note_off(shared); + // Auto-release preview note after its duration expires. + // Sends note_off but keeps preview_note set so the re-strike check + // won't re-trigger at the same pitch. + if let (Some(note), Some(dur)) = (self.preview_note, self.preview_duration) { + if self.preview_note_sounding && now - self.preview_start_time >= dur { + if let Some(layer_id) = *shared.active_layer_id { + if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) { + if let Some(controller_arc) = shared.audio_controller.as_ref() { + let mut controller = controller_arc.lock().unwrap(); + controller.send_midi_note_off(track_id, note); + } + } + } + self.preview_note_sounding = false; } } @@ -1004,6 +1016,12 @@ impl PianoRollPane { /// Update midi_event_cache immediately so notes render at their new positions /// without waiting for the backend round-trip. + /// + /// DESYNC RISK: This updates the cache before the action executes on the backend. + /// If the action later fails during execute_with_backend(), the cache will be out + /// of sync with the backend state. This is acceptable because MIDI note edits are + /// simple operations unlikely to fail, and undo/redo rebuilds cache from the action's + /// stored note data to restore consistency. fn update_cache_from_resolved(clip_id: u32, resolved: &[ResolvedNote], shared: &mut SharedPaneState) { let mut events: Vec<(f64, u8, u8, bool)> = Vec::with_capacity(resolved.len() * 2); for n in resolved { @@ -1159,6 +1177,7 @@ impl PianoRollPane { let mut controller = controller_arc.lock().unwrap(); controller.send_midi_note_on(track_id, note, velocity); self.preview_note = Some(note); + self.preview_note_sounding = true; self.preview_velocity = velocity; self.preview_duration = duration; self.preview_start_time = time; @@ -1169,13 +1188,16 @@ impl PianoRollPane { fn preview_note_off(&mut self, shared: &mut SharedPaneState) { if let Some(note) = self.preview_note.take() { - if let Some(layer_id) = *shared.active_layer_id { - if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) { - if let Some(controller_arc) = shared.audio_controller.as_ref() { - let mut controller = controller_arc.lock().unwrap(); - controller.send_midi_note_off(track_id, note); + if self.preview_note_sounding { + if let Some(layer_id) = *shared.active_layer_id { + if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) { + if let Some(controller_arc) = shared.audio_controller.as_ref() { + let mut controller = controller_arc.lock().unwrap(); + controller.send_midi_note_off(track_id, note); + } } } + self.preview_note_sounding = false; } } // Don't clear preview_base_note or preview_duration here —