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> {
|
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())
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, ¬es);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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, ¬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
|
/// 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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue