Add piano roll
This commit is contained in:
parent
c11dab928c
commit
b86af7bbf5
|
|
@ -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<String> {
|
||||
self.redo_stack.last().map(|a| a.description())
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ImportedAssetInfo> {
|
||||
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<Uuid, daw_backend::TrackId>,
|
||||
/// 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_pools_with_new_waveforms: &'a HashSet<usize>,
|
||||
/// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels))
|
||||
|
|
|
|||
|
|
@ -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<u32, Vec<(f64, u8, u8, bool)>>,
|
||||
/// 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<u32, Vec<(f64, u8, u8, bool)>>,
|
||||
/// 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>,
|
||||
/// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels))
|
||||
|
|
|
|||
|
|
@ -78,10 +78,11 @@ pub struct PianoRollPane {
|
|||
selected_clip_id: Option<u32>,
|
||||
|
||||
// Note preview
|
||||
preview_note: Option<u8>,
|
||||
preview_base_note: Option<u8>, // original pitch before drag offset
|
||||
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_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)
|
||||
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 —
|
||||
|
|
|
|||
Loading…
Reference in New Issue