Add piano roll

This commit is contained in:
Skyler Lehmkuhl 2026-02-12 19:05:49 -05:00
parent c11dab928c
commit b86af7bbf5
5 changed files with 128 additions and 32 deletions

View File

@ -96,6 +96,18 @@ pub trait Action: Send {
fn rollback_backend(&mut self, _backend: &mut BackendContext, _document: &Document) -> Result<(), String> { fn rollback_backend(&mut self, _backend: &mut BackendContext, _document: &Document) -> Result<(), String> {
Ok(()) 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 /// Action executor that wraps the document and manages undo/redo
@ -245,6 +257,18 @@ impl ActionExecutor {
self.undo_stack.last().map(|a| a.description()) 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 /// Get the description of the next action to redo
pub fn redo_description(&self) -> Option<String> { pub fn redo_description(&self) -> Option<String> {
self.redo_stack.last().map(|a| a.description()) self.redo_stack.last().map(|a| a.description())

View File

@ -71,4 +71,12 @@ impl Action for UpdateMidiNotesAction {
controller.update_midi_clip_notes(*track_id, self.midi_clip_id, self.old_notes.clone()); controller.update_midi_clip_notes(*track_id, self.midi_clip_id, self.old_notes.clone());
Ok(()) 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))
}
} }

View File

@ -1716,46 +1716,72 @@ impl EditorApp {
// Edit menu // Edit menu
MenuAction::Undo => { 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 controller = controller_arc.lock().unwrap();
let mut backend_context = lightningbeam_core::action::BackendContext { let mut backend_context = lightningbeam_core::action::BackendContext {
audio_controller: Some(&mut *controller), audio_controller: Some(&mut *controller),
layer_to_track_map: &self.layer_to_track_map, layer_to_track_map: &self.layer_to_track_map,
clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map,
}; };
match self.action_executor.undo_with_backend(&mut backend_context) { match self.action_executor.undo_with_backend(&mut backend_context) {
Ok(true) => println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()), Ok(true) => {
Ok(false) => println!("Nothing to undo"), println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default());
Err(e) => eprintln!("Undo failed: {}", e), true
}
Ok(false) => { println!("Nothing to undo"); false }
Err(e) => { eprintln!("Undo failed: {}", e); false }
} }
} else { } else {
match self.action_executor.undo() { match self.action_executor.undo() {
Ok(true) => println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()), Ok(true) => {
Ok(false) => println!("Nothing to undo"), println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default());
Err(e) => eprintln!("Undo failed: {}", e), 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, &notes);
} }
} }
} }
MenuAction::Redo => { 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 controller = controller_arc.lock().unwrap();
let mut backend_context = lightningbeam_core::action::BackendContext { let mut backend_context = lightningbeam_core::action::BackendContext {
audio_controller: Some(&mut *controller), audio_controller: Some(&mut *controller),
layer_to_track_map: &self.layer_to_track_map, layer_to_track_map: &self.layer_to_track_map,
clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map,
}; };
match self.action_executor.redo_with_backend(&mut backend_context) { match self.action_executor.redo_with_backend(&mut backend_context) {
Ok(true) => println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()), Ok(true) => {
Ok(false) => println!("Nothing to redo"), println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default());
Err(e) => eprintln!("Redo failed: {}", e), true
}
Ok(false) => { println!("Nothing to redo"); false }
Err(e) => { eprintln!("Redo failed: {}", e); false }
} }
} else { } else {
match self.action_executor.redo() { match self.action_executor.redo() {
Ok(true) => println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()), Ok(true) => {
Ok(false) => println!("Nothing to redo"), println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default());
Err(e) => eprintln!("Redo failed: {}", e), 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, &notes);
} }
} }
} }
@ -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 /// Import a MIDI file via daw-backend
fn import_midi(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> { fn import_midi(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
use lightningbeam_core::clip::AudioClip; use lightningbeam_core::clip::AudioClip;
@ -3798,7 +3836,7 @@ impl eframe::App for EditorApp {
paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance, paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance,
polygon_sides: &mut self.polygon_sides, polygon_sides: &mut self.polygon_sides,
layer_to_track_map: &self.layer_to_track_map, 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, audio_pools_with_new_waveforms: &self.audio_pools_with_new_waveforms,
raw_audio_cache: &self.raw_audio_cache, raw_audio_cache: &self.raw_audio_cache,
waveform_gpu_dirty: &mut self.waveform_gpu_dirty, waveform_gpu_dirty: &mut self.waveform_gpu_dirty,
@ -4028,7 +4066,7 @@ struct RenderContext<'a> {
/// Mapping from Document layer UUIDs to daw-backend TrackIds /// Mapping from Document layer UUIDs to daw-backend TrackIds
layer_to_track_map: &'a std::collections::HashMap<Uuid, daw_backend::TrackId>, layer_to_track_map: &'a std::collections::HashMap<Uuid, daw_backend::TrackId>,
/// Cache of MIDI events for rendering (keyed by backend midi_clip_id) /// Cache of MIDI events for rendering (keyed by backend midi_clip_id)
midi_event_cache: &'a HashMap<u32, Vec<(f64, u8, u8, bool)>>, midi_event_cache: &'a mut HashMap<u32, Vec<(f64, u8, u8, bool)>>,
/// Audio pool indices with new raw audio data this frame (for thumbnail invalidation) /// Audio pool indices with new raw audio data this frame (for thumbnail invalidation)
audio_pools_with_new_waveforms: &'a HashSet<usize>, audio_pools_with_new_waveforms: &'a HashSet<usize>,
/// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels)) /// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels))

View File

@ -187,8 +187,12 @@ pub struct SharedPaneState<'a> {
pub paint_bucket_gap_tolerance: &'a mut f64, pub paint_bucket_gap_tolerance: &'a mut f64,
/// Number of sides for polygon tool /// Number of sides for polygon tool
pub polygon_sides: &'a mut u32, pub polygon_sides: &'a mut u32,
/// Cache of MIDI events for rendering (keyed by backend midi_clip_id) /// Cache of MIDI events for rendering (keyed by backend midi_clip_id).
pub midi_event_cache: &'a std::collections::HashMap<u32, Vec<(f64, u8, u8, bool)>>, /// 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<u32, Vec<(f64, u8, u8, bool)>>,
/// Audio pool indices that got new raw audio data this frame (for thumbnail invalidation) /// Audio pool indices that got new raw audio data this frame (for thumbnail invalidation)
pub audio_pools_with_new_waveforms: &'a std::collections::HashSet<usize>, pub audio_pools_with_new_waveforms: &'a std::collections::HashSet<usize>,
/// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels)) /// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels))

View File

@ -78,7 +78,8 @@ pub struct PianoRollPane {
selected_clip_id: Option<u32>, selected_clip_id: Option<u32>,
// Note preview // Note preview
preview_note: Option<u8>, preview_note: Option<u8>, // 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<u8>, // original pitch before drag offset preview_base_note: Option<u8>, // original pitch before drag offset
preview_velocity: u8, preview_velocity: u8,
preview_duration: Option<f64>, // auto-release after this many seconds (None = hold until mouse-up) preview_duration: Option<f64>, // auto-release after this many seconds (None = hold until mouse-up)
@ -117,6 +118,7 @@ impl PianoRollPane {
drag_note_offsets: None, drag_note_offsets: None,
selected_clip_id: None, selected_clip_id: None,
preview_note: None, preview_note: None,
preview_note_sounding: false,
preview_base_note: None, preview_base_note: None,
preview_velocity: DEFAULT_VELOCITY, preview_velocity: DEFAULT_VELOCITY,
preview_duration: None, preview_duration: None,
@ -619,10 +621,20 @@ impl PianoRollPane {
let ctrl_held = ui.input(|i| i.modifiers.ctrl); let ctrl_held = ui.input(|i| i.modifiers.ctrl);
let now = ui.input(|i| i.time); let now = ui.input(|i| i.time);
// Auto-release preview note after its duration expires // Auto-release preview note after its duration expires.
if let (Some(_), Some(dur)) = (self.preview_note, self.preview_duration) { // Sends note_off but keeps preview_note set so the re-strike check
if now - self.preview_start_time >= dur { // won't re-trigger at the same pitch.
self.preview_note_off(shared); 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 /// Update midi_event_cache immediately so notes render at their new positions
/// without waiting for the backend round-trip. /// 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) { 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); let mut events: Vec<(f64, u8, u8, bool)> = Vec::with_capacity(resolved.len() * 2);
for n in resolved { for n in resolved {
@ -1159,6 +1177,7 @@ impl PianoRollPane {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
controller.send_midi_note_on(track_id, note, velocity); controller.send_midi_note_on(track_id, note, velocity);
self.preview_note = Some(note); self.preview_note = Some(note);
self.preview_note_sounding = true;
self.preview_velocity = velocity; self.preview_velocity = velocity;
self.preview_duration = duration; self.preview_duration = duration;
self.preview_start_time = time; self.preview_start_time = time;
@ -1169,6 +1188,7 @@ impl PianoRollPane {
fn preview_note_off(&mut self, shared: &mut SharedPaneState) { fn preview_note_off(&mut self, shared: &mut SharedPaneState) {
if let Some(note) = self.preview_note.take() { if let Some(note) = self.preview_note.take() {
if self.preview_note_sounding {
if let Some(layer_id) = *shared.active_layer_id { 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(&track_id) = shared.layer_to_track_map.get(&layer_id) {
if let Some(controller_arc) = shared.audio_controller.as_ref() { if let Some(controller_arc) = shared.audio_controller.as_ref() {
@ -1177,6 +1197,8 @@ impl PianoRollPane {
} }
} }
} }
self.preview_note_sounding = false;
}
} }
// Don't clear preview_base_note or preview_duration here — // Don't clear preview_base_note or preview_duration here —
// they're needed for re-striking during drag. Cleared in on_grid_release. // they're needed for re-striking during drag. Cleared in on_grid_release.