diff --git a/daw-backend/src/audio/clip.rs b/daw-backend/src/audio/clip.rs index 6a797d0..9ae021b 100644 --- a/daw-backend/src/audio/clip.rs +++ b/daw-backend/src/audio/clip.rs @@ -15,6 +15,7 @@ pub type ClipId = AudioClipInstanceId; /// ## Timing Model /// - `internal_start` / `internal_end`: Define the region of the source audio to play (trimming) /// - `external_start` / `external_duration`: Define where the clip appears on the timeline and how long +/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display /// /// ## Looping /// If `external_duration` is greater than `internal_end - internal_start`, @@ -26,13 +27,21 @@ pub struct AudioClipInstance { /// Start position within the audio content (seconds) pub internal_start: f64, + #[serde(default)] pub internal_start_beats: f64, + #[serde(default)] pub internal_start_frames: f64, /// End position within the audio content (seconds) pub internal_end: f64, + #[serde(default)] pub internal_end_beats: f64, + #[serde(default)] pub internal_end_frames: f64, /// Start position on the timeline (seconds) pub external_start: f64, + #[serde(default)] pub external_start_beats: f64, + #[serde(default)] pub external_start_frames: f64, /// Duration on the timeline (seconds) - can be longer than internal duration for looping pub external_duration: f64, + #[serde(default)] pub external_duration_beats: f64, + #[serde(default)] pub external_duration_frames: f64, /// Clip-level gain pub gain: f32, @@ -62,9 +71,17 @@ impl AudioClipInstance { id, audio_pool_index, internal_start, + internal_start_beats: 0.0, + internal_start_frames: 0.0, internal_end, + internal_end_beats: 0.0, + internal_end_frames: 0.0, external_start, + external_start_beats: 0.0, + external_start_frames: 0.0, external_duration, + external_duration_beats: 0.0, + external_duration_frames: 0.0, gain: 1.0, read_ahead: None, } @@ -83,9 +100,17 @@ impl AudioClipInstance { id, audio_pool_index, internal_start: offset, + internal_start_beats: 0.0, + internal_start_frames: 0.0, internal_end: offset + duration, + internal_end_beats: 0.0, + internal_end_frames: 0.0, external_start: start_time, + external_start_beats: 0.0, + external_start_frames: 0.0, external_duration: duration, + external_duration_beats: 0.0, + external_duration_frames: 0.0, gain: 1.0, read_ahead: None, } @@ -147,4 +172,40 @@ impl AudioClipInstance { pub fn set_gain(&mut self, gain: f32) { self.gain = gain.max(0.0); } + + /// Populate beats/frames from the current seconds values. + pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { + self.external_start_beats = self.external_start * bpm / 60.0; + self.external_start_frames = self.external_start * fps; + self.external_duration_beats = self.external_duration * bpm / 60.0; + self.external_duration_frames = self.external_duration * fps; + self.internal_start_beats = self.internal_start * bpm / 60.0; + self.internal_start_frames = self.internal_start * fps; + self.internal_end_beats = self.internal_end * bpm / 60.0; + self.internal_end_frames = self.internal_end * fps; + } + + /// BPM changed; recompute seconds/frames from the stored beats values. + pub fn apply_beats(&mut self, bpm: f64, fps: f64) { + self.external_start = self.external_start_beats * 60.0 / bpm; + self.external_start_frames = self.external_start * fps; + self.external_duration = self.external_duration_beats * 60.0 / bpm; + self.external_duration_frames = self.external_duration * fps; + self.internal_start = self.internal_start_beats * 60.0 / bpm; + self.internal_start_frames = self.internal_start * fps; + self.internal_end = self.internal_end_beats * 60.0 / bpm; + self.internal_end_frames = self.internal_end * fps; + } + + /// FPS changed; recompute seconds/beats from the stored frames values. + pub fn apply_frames(&mut self, fps: f64, bpm: f64) { + self.external_start = self.external_start_frames / fps; + self.external_start_beats = self.external_start * bpm / 60.0; + self.external_duration = self.external_duration_frames / fps; + self.external_duration_beats = self.external_duration * bpm / 60.0; + self.internal_start = self.internal_start_frames / fps; + self.internal_start_beats = self.internal_start * bpm / 60.0; + self.internal_end = self.internal_end_frames / fps; + self.internal_end_beats = self.internal_end * bpm / 60.0; + } } diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 6e1bb7d..4b9e2c7 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -108,6 +108,11 @@ pub struct Engine { timing_worst_render_us: u64, timing_sum_total_us: u64, timing_overrun_count: u64, + + // Current tempo/framerate — kept in sync with SetTempo/ApplyBpmChange so that + // newly-created clip instances can be immediately synced via sync_from_seconds. + current_bpm: f64, + current_fps: f64, } impl Engine { @@ -184,6 +189,8 @@ impl Engine { timing_worst_render_us: 0, timing_sum_total_us: 0, timing_overrun_count: 0, + current_bpm: 120.0, + current_fps: 30.0, } } @@ -728,16 +735,19 @@ impl Engine { } Command::MoveClip(track_id, clip_id, new_start_time) => { // Moving just changes external_start, external_duration stays the same + let bpm = self.current_bpm; + let fps = self.current_fps; match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { clip.external_start = new_start_time; + clip.sync_from_seconds(bpm, fps); } } Some(crate::audio::track::TrackNode::Midi(track)) => { - // Note: clip_id here is the pool clip ID, not instance ID - if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { + if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) { instance.external_start = new_start_time; + instance.sync_from_seconds(bpm, fps); } } _ => {} @@ -747,13 +757,15 @@ impl Engine { Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => { // Trim changes which portion of the source content is used // Also updates external_duration to match internal duration (no looping after trim) + let bpm = self.current_bpm; + let fps = self.current_fps; match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { clip.internal_start = new_internal_start; clip.internal_end = new_internal_end; - // By default, trimming sets external_duration to match internal duration clip.external_duration = new_internal_end - new_internal_start; + clip.sync_from_seconds(bpm, fps); } } Some(crate::audio::track::TrackNode::Midi(track)) => { @@ -761,8 +773,8 @@ impl Engine { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { instance.internal_start = new_internal_start; instance.internal_end = new_internal_end; - // By default, trimming sets external_duration to match internal duration instance.external_duration = new_internal_end - new_internal_start; + instance.sync_from_seconds(bpm, fps); } } _ => {} @@ -771,16 +783,20 @@ impl Engine { } Command::ExtendClip(track_id, clip_id, new_external_duration) => { // Extend changes the external duration (enables looping if > internal duration) + let bpm = self.current_bpm; + let fps = self.current_fps; match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { clip.external_duration = new_external_duration; + clip.sync_from_seconds(bpm, fps); } } Some(crate::audio::track::TrackNode::Midi(track)) => { // Note: clip_id here is the pool clip ID, not instance ID if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { instance.external_duration = new_external_duration; + instance.sync_from_seconds(bpm, fps); } } _ => {} @@ -899,13 +915,14 @@ impl Engine { } Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => { // Create a new clip instance with the pre-assigned clip_id - let clip = AudioClipInstance::from_legacy( + let mut clip = AudioClipInstance::from_legacy( clip_id, pool_index, start_time, duration, offset, ); + clip.sync_from_seconds(self.current_bpm, self.current_fps); // Add clip to track if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { @@ -933,7 +950,8 @@ impl Engine { // Create an instance for this clip on the track let instance_id = self.project.next_midi_clip_instance_id(); - let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time); + let mut instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time); + instance.sync_from_seconds(self.current_bpm, self.current_fps); if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { track.clip_instances.push(instance); @@ -973,7 +991,15 @@ impl Engine { } Command::AddLoadedMidiClip(track_id, clip, start_time) => { // Add a pre-loaded MIDI clip to the track with the given start time - let _ = self.project.add_midi_clip_at(track_id, clip, start_time); + let bpm = self.current_bpm; + let fps = self.current_fps; + if let Ok(instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) { + if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + if let Some(inst) = track.clip_instances.iter_mut().find(|i| i.id == instance_id) { + inst.sync_from_seconds(bpm, fps); + } + } + } self.refresh_clip_snapshot(); } Command::UpdateMidiClipNotes(_track_id, clip_id, notes) => { @@ -1277,6 +1303,14 @@ impl Engine { Command::SetTempo(bpm, time_sig) => { self.metronome.update_timing(bpm, time_sig); self.project.set_tempo(bpm, time_sig.0); + self.current_bpm = bpm as f64; + } + + Command::ApplyBpmChange(bpm, fps, midi_durations) => { + self.current_bpm = bpm; + self.current_fps = fps; + self.project.apply_bpm_change(bpm, fps, &midi_durations); + self.refresh_clip_snapshot(); } // Node graph commands @@ -2716,8 +2750,18 @@ impl Engine { } Query::AddMidiClipSync(track_id, clip, start_time) => { // Add MIDI clip to track and return the instance ID + let bpm = self.current_bpm; + let fps = self.current_fps; let result = match self.project.add_midi_clip_at(track_id, clip, start_time) { - Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), + Ok(instance_id) => { + // Sync beats/frames on the newly created instance + if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + if let Some(inst) = track.clip_instances.iter_mut().find(|i| i.id == instance_id) { + inst.sync_from_seconds(bpm, fps); + } + } + QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) + } Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), }; self.refresh_clip_snapshot(); @@ -2728,6 +2772,7 @@ impl Engine { // Assign instance ID let instance_id = self.project.next_midi_clip_instance_id(); instance.id = instance_id; + instance.sync_from_seconds(self.current_bpm, self.current_fps); let result = match self.project.add_midi_clip_instance(track_id, instance) { Ok(_) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), @@ -3642,6 +3687,12 @@ impl EngineController { let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature)); } + /// After a BPM change: update MIDI clip durations and sync all clip beats/frames. + /// Call this after move_clip() has been called for all affected clips. + pub fn apply_bpm_change(&mut self, bpm: f64, fps: f64, midi_durations: Vec<(crate::audio::MidiClipId, f64)>) { + let _ = self.command_tx.push(Command::ApplyBpmChange(bpm, fps, midi_durations)); + } + // Node graph operations /// Add a node to a track's instrument graph diff --git a/daw-backend/src/audio/midi.rs b/daw-backend/src/audio/midi.rs index f3127a5..79747b4 100644 --- a/daw-backend/src/audio/midi.rs +++ b/daw-backend/src/audio/midi.rs @@ -3,6 +3,12 @@ pub struct MidiEvent { /// Time position within the clip in seconds (sample-rate independent) pub timestamp: f64, + /// Time position in beats (quarter-note beats from clip start); derived from timestamp + #[serde(default)] + pub timestamp_beats: f64, + /// Time position in frames; derived from timestamp + #[serde(default)] + pub timestamp_frames: f64, /// MIDI status byte (includes channel) pub status: u8, /// First data byte (note number, CC number, etc.) @@ -16,6 +22,8 @@ impl MidiEvent { pub fn new(timestamp: f64, status: u8, data1: u8, data2: u8) -> Self { Self { timestamp, + timestamp_beats: 0.0, + timestamp_frames: 0.0, status, data1, data2, @@ -26,6 +34,8 @@ impl MidiEvent { pub fn note_on(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self { Self { timestamp, + timestamp_beats: 0.0, + timestamp_frames: 0.0, status: 0x90 | (channel & 0x0F), data1: note, data2: velocity, @@ -36,12 +46,32 @@ impl MidiEvent { pub fn note_off(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self { Self { timestamp, + timestamp_beats: 0.0, + timestamp_frames: 0.0, status: 0x80 | (channel & 0x0F), data1: note, data2: velocity, } } + /// Sync beats and frames from seconds (call after constructing or when seconds is canonical) + pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { + self.timestamp_beats = self.timestamp * bpm / 60.0; + self.timestamp_frames = self.timestamp * fps; + } + + /// Recompute seconds and frames from beats (call when BPM changes in Measures mode) + pub fn apply_beats(&mut self, bpm: f64, fps: f64) { + self.timestamp = self.timestamp_beats * 60.0 / bpm; + self.timestamp_frames = self.timestamp * fps; + } + + /// Recompute seconds and beats from frames (call when FPS changes in Frames mode) + pub fn apply_frames(&mut self, fps: f64, bpm: f64) { + self.timestamp = self.timestamp_frames / fps; + self.timestamp_beats = self.timestamp * bpm / 60.0; + } + /// Check if this is a note on event (with non-zero velocity) pub fn is_note_on(&self) -> bool { (self.status & 0xF0) == 0x90 && self.data2 > 0 @@ -128,6 +158,7 @@ impl MidiClip { /// ## Timing Model /// - `internal_start` / `internal_end`: Define the region of the source clip to play (trimming) /// - `external_start` / `external_duration`: Define where the instance appears on the timeline and how long +/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display /// /// ## Looping /// If `external_duration` is greater than `internal_end - internal_start`, @@ -139,13 +170,21 @@ pub struct MidiClipInstance { /// Start position within the clip content (seconds) pub internal_start: f64, + #[serde(default)] pub internal_start_beats: f64, + #[serde(default)] pub internal_start_frames: f64, /// End position within the clip content (seconds) pub internal_end: f64, + #[serde(default)] pub internal_end_beats: f64, + #[serde(default)] pub internal_end_frames: f64, /// Start position on the timeline (seconds) pub external_start: f64, + #[serde(default)] pub external_start_beats: f64, + #[serde(default)] pub external_start_frames: f64, /// Duration on the timeline (seconds) - can be longer than internal duration for looping pub external_duration: f64, + #[serde(default)] pub external_duration_beats: f64, + #[serde(default)] pub external_duration_frames: f64, } impl MidiClipInstance { @@ -162,9 +201,17 @@ impl MidiClipInstance { id, clip_id, internal_start, + internal_start_beats: 0.0, + internal_start_frames: 0.0, internal_end, + internal_end_beats: 0.0, + internal_end_frames: 0.0, external_start, + external_start_beats: 0.0, + external_start_frames: 0.0, external_duration, + external_duration_beats: 0.0, + external_duration_frames: 0.0, } } @@ -179,9 +226,17 @@ impl MidiClipInstance { id, clip_id, internal_start: 0.0, + internal_start_beats: 0.0, + internal_start_frames: 0.0, internal_end: clip_duration, + internal_end_beats: 0.0, + internal_end_frames: 0.0, external_start, + external_start_beats: 0.0, + external_start_frames: 0.0, external_duration: clip_duration, + external_duration_beats: 0.0, + external_duration_frames: 0.0, } } @@ -215,6 +270,42 @@ impl MidiClipInstance { self.external_start < range_end && self.external_end() > range_start } + /// Populate beats/frames from the current seconds values. + pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { + self.external_start_beats = self.external_start * bpm / 60.0; + self.external_start_frames = self.external_start * fps; + self.external_duration_beats = self.external_duration * bpm / 60.0; + self.external_duration_frames = self.external_duration * fps; + self.internal_start_beats = self.internal_start * bpm / 60.0; + self.internal_start_frames = self.internal_start * fps; + self.internal_end_beats = self.internal_end * bpm / 60.0; + self.internal_end_frames = self.internal_end * fps; + } + + /// BPM changed; recompute seconds/frames from the stored beats values. + pub fn apply_beats(&mut self, bpm: f64, fps: f64) { + self.external_start = self.external_start_beats * 60.0 / bpm; + self.external_start_frames = self.external_start * fps; + self.external_duration = self.external_duration_beats * 60.0 / bpm; + self.external_duration_frames = self.external_duration * fps; + self.internal_start = self.internal_start_beats * 60.0 / bpm; + self.internal_start_frames = self.internal_start * fps; + self.internal_end = self.internal_end_beats * 60.0 / bpm; + self.internal_end_frames = self.internal_end * fps; + } + + /// FPS changed; recompute seconds/beats from the stored frames values. + pub fn apply_frames(&mut self, fps: f64, bpm: f64) { + self.external_start = self.external_start_frames / fps; + self.external_start_beats = self.external_start * bpm / 60.0; + self.external_duration = self.external_duration_frames / fps; + self.external_duration_beats = self.external_duration * bpm / 60.0; + self.internal_start = self.internal_start_frames / fps; + self.internal_start_beats = self.internal_start * bpm / 60.0; + self.internal_end = self.internal_end_frames / fps; + self.internal_end_beats = self.internal_end * bpm / 60.0; + } + /// Get events that should be triggered in a given timeline range /// /// This handles: diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index 2a9003a..d46d4f5 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -216,6 +216,46 @@ impl Project { self.tracks.iter().map(|(&id, node)| (id, node)) } + /// After a BPM change, update MIDI clip durations then sync all clip beats/frames from seconds. + /// + /// `midi_durations` maps each MidiClipId to its new content duration in seconds. + /// Call this after the seconds positions have already been updated (e.g. via MoveClip). + pub fn apply_bpm_change(&mut self, bpm: f64, fps: f64, midi_durations: &[(crate::audio::midi::MidiClipId, f64)]) { + for (_, track) in self.tracks.iter_mut() { + match track { + crate::audio::track::TrackNode::Audio(t) => { + for clip in &mut t.clips { + clip.sync_from_seconds(bpm, fps); + } + } + crate::audio::track::TrackNode::Midi(t) => { + // Update content durations first so internal_end is correct before sync + for instance in &mut t.clip_instances { + if let Some(&new_dur) = midi_durations.iter() + .find(|(id, _)| *id == instance.clip_id) + .map(|(_, d)| d) + { + let old_internal_dur = instance.internal_duration(); + instance.internal_end = instance.internal_start + new_dur; + // Scale external_duration by the same ratio (works for both looping and non-looping) + if old_internal_dur > 1e-12 { + instance.external_duration = instance.external_duration * new_dur / old_internal_dur; + } + } + instance.sync_from_seconds(bpm, fps); + } + // Update pool clip durations + for &(clip_id, new_dur) in midi_durations { + if let Some(clip) = self.midi_clip_pool.get_clip_mut(clip_id) { + clip.duration = new_dur; + } + } + } + _ => {} + } + } + } + /// Get oscilloscope data from a node in a track's graph pub fn get_oscilloscope_data(&self, track_id: TrackId, node_id: u32, sample_count: usize) -> Option<(Vec, Vec)> { if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) { diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index b3a3b85..ecc71fc 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -144,6 +144,9 @@ pub enum Command { SetMetronomeEnabled(bool), /// Set project tempo and time signature (bpm, (numerator, denominator)) SetTempo(f32, (u32, u32)), + /// After a BPM change: update MIDI clip durations and sync all clip beats/frames from seconds. + /// (bpm, fps, midi_durations: Vec<(clip_id, new_duration_seconds)>) + ApplyBpmChange(f64, f64, Vec<(MidiClipId, f64)>), // Node graph commands /// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y) diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index ac8628f..0720ecc 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -120,6 +120,17 @@ pub trait Action: Send { fn midi_events_after_rollback(&self) -> Option<(u32, &[daw_backend::audio::midi::MidiEvent])> { None } + + /// Return MIDI event data for multiple clips after execute/redo (e.g. BPM change). + /// Each element is (midi_clip_id, events). Default: empty. + fn all_midi_events_after_execute(&self) -> Vec<(u32, Vec)> { + Vec::new() + } + + /// Return MIDI event data for multiple clips after rollback/undo. + fn all_midi_events_after_rollback(&self) -> Vec<(u32, Vec)> { + Vec::new() + } } /// Action executor that wraps the document and manages undo/redo @@ -301,6 +312,16 @@ impl ActionExecutor { self.redo_stack.last().and_then(|a| a.midi_events_after_rollback()) } + /// Get multi-clip MIDI event data from the last undo stack action (after redo). + pub fn last_undo_all_midi_events(&self) -> Vec<(u32, Vec)> { + self.undo_stack.last().map(|a| a.all_midi_events_after_execute()).unwrap_or_default() + } + + /// Get multi-clip MIDI event data from the last redo stack action (after undo). + pub fn last_redo_all_midi_events(&self) -> Vec<(u32, Vec)> { + self.redo_stack.last().map(|a| a.all_midi_events_after_rollback()).unwrap_or_default() + } + /// 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/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index b254d0e..fd92016 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -79,6 +79,8 @@ impl Action for AddClipInstanceAction { if let Some(valid_start) = adjusted_start { // Update instance to use the valid position self.clip_instance.timeline_start = valid_start; + let (bpm, fps) = (document.bpm, document.framerate); + self.clip_instance.sync_from_seconds(bpm, fps); } else { // No valid position found - reject the operation return Err("Cannot add clip: no valid position found on layer (layer is full)".to_string()); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs b/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs new file mode 100644 index 0000000..0d7c89d --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs @@ -0,0 +1,361 @@ +//! Change BPM action +//! +//! Atomically changes the document BPM and rescales all clip instance positions and +//! MIDI event timestamps so that beat positions are preserved (Measures mode behaviour). + +use crate::action::{Action, BackendContext}; +use crate::clip::ClipInstance; +use crate::document::Document; +use crate::layer::AnyLayer; +use std::collections::HashMap; +use uuid::Uuid; + +/// Snapshot of all timing fields on a `ClipInstance` +#[derive(Clone)] +struct TimingFields { + timeline_start: f64, + timeline_start_beats: f64, + timeline_start_frames: f64, + trim_start: f64, + trim_start_beats: f64, + trim_start_frames: f64, + trim_end: Option, + trim_end_beats: Option, + trim_end_frames: Option, + timeline_duration: Option, + timeline_duration_beats: Option, + timeline_duration_frames: Option, +} + +impl TimingFields { + fn from_instance(ci: &ClipInstance) -> Self { + Self { + timeline_start: ci.timeline_start, + timeline_start_beats: ci.timeline_start_beats, + timeline_start_frames: ci.timeline_start_frames, + trim_start: ci.trim_start, + trim_start_beats: ci.trim_start_beats, + trim_start_frames: ci.trim_start_frames, + trim_end: ci.trim_end, + trim_end_beats: ci.trim_end_beats, + trim_end_frames: ci.trim_end_frames, + timeline_duration: ci.timeline_duration, + timeline_duration_beats: ci.timeline_duration_beats, + timeline_duration_frames: ci.timeline_duration_frames, + } + } + + fn apply_to(&self, ci: &mut ClipInstance) { + ci.timeline_start = self.timeline_start; + ci.timeline_start_beats = self.timeline_start_beats; + ci.timeline_start_frames = self.timeline_start_frames; + ci.trim_start = self.trim_start; + ci.trim_start_beats = self.trim_start_beats; + ci.trim_start_frames = self.trim_start_frames; + ci.trim_end = self.trim_end; + ci.trim_end_beats = self.trim_end_beats; + ci.trim_end_frames = self.trim_end_frames; + ci.timeline_duration = self.timeline_duration; + ci.timeline_duration_beats = self.timeline_duration_beats; + ci.timeline_duration_frames = self.timeline_duration_frames; + } +} + +#[derive(Clone)] +struct ClipTimingSnapshot { + layer_id: Uuid, + instance_id: Uuid, + old_fields: TimingFields, + new_fields: TimingFields, +} + +#[derive(Clone)] +struct MidiClipSnapshot { + layer_id: Uuid, + midi_clip_id: u32, + clip_id: Uuid, + old_clip_duration: f64, + new_clip_duration: f64, + old_events: Vec, + new_events: Vec, +} + +/// Action that atomically changes BPM and rescales all clip/note positions to preserve beats +pub struct ChangeBpmAction { + old_bpm: f64, + new_bpm: f64, + time_sig: (u32, u32), + clip_snapshots: Vec, + midi_snapshots: Vec, +} + +impl ChangeBpmAction { + /// Build the action, computing new positions for all clip instances and MIDI events. + /// + /// `midi_event_cache` maps backend MIDI clip ID → current event list. + pub fn new( + old_bpm: f64, + new_bpm: f64, + document: &Document, + midi_event_cache: &HashMap>, + ) -> Self { + let fps = document.framerate; + let time_sig = ( + document.time_signature.numerator, + document.time_signature.denominator, + ); + + let mut clip_snapshots: Vec = Vec::new(); + let mut midi_snapshots: Vec = Vec::new(); + + // Collect MIDI clip IDs we've already snapshotted (avoid duplicates) + let mut seen_midi_clips: std::collections::HashSet = std::collections::HashSet::new(); + + for layer in document.all_layers() { + let layer_id = layer.id(); + + let clip_instances: &[ClipInstance] = match layer { + AnyLayer::Vector(vl) => &vl.clip_instances, + AnyLayer::Audio(al) => &al.clip_instances, + AnyLayer::Video(vl) => &vl.clip_instances, + AnyLayer::Effect(el) => &el.clip_instances, + AnyLayer::Group(_) | AnyLayer::Raster(_) => continue, + }; + + for ci in clip_instances { + let old_fields = TimingFields::from_instance(ci); + + // Compute new fields: beats are canonical, recompute seconds + frames. + // Guard: if timeline_start_beats was never populated (clips added without + // sync_from_seconds), derive beats from seconds before applying. + let mut new_ci = ci.clone(); + if new_ci.timeline_start_beats == 0.0 && new_ci.timeline_start.abs() > 1e-9 { + new_ci.sync_from_seconds(old_bpm, fps); + } + new_ci.apply_beats(new_bpm, fps); + let new_fields = TimingFields::from_instance(&new_ci); + + clip_snapshots.push(ClipTimingSnapshot { + layer_id, + instance_id: ci.id, + old_fields, + new_fields, + }); + + // If this is a MIDI clip on an audio layer, collect MIDI events + rescale duration. + // Always snapshot the clip (even if empty) so clip.duration is rescaled. + if let AnyLayer::Audio(_) = layer { + if let Some(audio_clip) = document.get_audio_clip(&ci.clip_id) { + use crate::clip::AudioClipType; + if let AudioClipType::Midi { midi_clip_id } = &audio_clip.clip_type { + let midi_id = *midi_clip_id; + if !seen_midi_clips.contains(&midi_id) { + seen_midi_clips.insert(midi_id); + + let old_clip_duration = audio_clip.duration; + let new_clip_duration = old_clip_duration * old_bpm / new_bpm; + + // Use cached events if present; empty vec for clips with no events yet. + let old_events = midi_event_cache.get(&midi_id).cloned().unwrap_or_default(); + let new_events: Vec<_> = old_events.iter().map(|ev| { + let mut e = ev.clone(); + // Ensure beats are populated before using them as canonical. + // Events created before triple-rep (e.g. from recording) + // have timestamp_beats == 0.0 — sync from seconds first. + if e.timestamp_beats == 0.0 && e.timestamp.abs() > 1e-9 { + e.sync_from_seconds(old_bpm, fps); + } + e.apply_beats(new_bpm, fps); + e + }).collect(); + + midi_snapshots.push(MidiClipSnapshot { + layer_id, + midi_clip_id: midi_id, + clip_id: ci.clip_id, + old_clip_duration, + new_clip_duration, + old_events, + new_events, + }); + } + } + } + } + } + } + + Self { + old_bpm, + new_bpm, + time_sig, + clip_snapshots, + midi_snapshots, + } + } + + /// Return the new MIDI event lists for each affected clip (for immediate cache update). + pub fn new_midi_events(&self) -> impl Iterator)> { + self.midi_snapshots.iter().map(|s| (s.midi_clip_id, &s.new_events)) + } + + fn apply_clips(document: &mut Document, snapshots: &[ClipTimingSnapshot], use_new: bool) { + for snap in snapshots { + let fields = if use_new { &snap.new_fields } else { &snap.old_fields }; + + let layer = match document.get_layer_mut(&snap.layer_id) { + Some(l) => l, + None => continue, + }; + + let clip_instances = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(el) => &mut el.clip_instances, + AnyLayer::Group(_) | AnyLayer::Raster(_) => continue, + }; + + if let Some(ci) = clip_instances.iter_mut().find(|ci| ci.id == snap.instance_id) { + fields.apply_to(ci); + } + } + } + + fn apply_midi_durations(document: &mut Document, snapshots: &[MidiClipSnapshot], use_new: bool) { + for snap in snapshots { + if let Some(clip) = document.get_audio_clip_mut(&snap.clip_id) { + clip.duration = if use_new { snap.new_clip_duration } else { snap.old_clip_duration }; + } + } + } +} + +impl Action for ChangeBpmAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + document.bpm = self.new_bpm; + Self::apply_clips(document, &self.clip_snapshots, true); + Self::apply_midi_durations(document, &self.midi_snapshots, true); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + document.bpm = self.old_bpm; + Self::apply_clips(document, &self.clip_snapshots, false); + Self::apply_midi_durations(document, &self.midi_snapshots, false); + Ok(()) + } + + fn description(&self) -> String { + "Change BPM".to_string() + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + + // Update tempo + controller.set_tempo(self.new_bpm as f32, self.time_sig); + + // Update MIDI clip events and positions + for snap in &self.midi_snapshots { + let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { + Some(&id) => id, + None => continue, + }; + controller.update_midi_clip_events(track_id, snap.midi_clip_id, snap.new_events.clone()); + } + + // Move clip instances in the backend + for snap in &self.clip_snapshots { + let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { + Some(&id) => id, + None => continue, + }; + let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id); + match backend_id { + Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => { + controller.move_clip(track_id, *audio_id, snap.new_fields.timeline_start); + } + Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => { + controller.move_clip(track_id, *midi_id, snap.new_fields.timeline_start); + } + None => {} // Vector/video clips — no backend move needed + } + } + + // Sync beat/frame representations and rescale MIDI clip durations in the backend + let fps = document.framerate; + let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter() + .map(|s| (s.midi_clip_id, s.new_clip_duration)) + .collect(); + controller.apply_bpm_change(self.new_bpm, fps, midi_durations); + + Ok(()) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + + controller.set_tempo(self.old_bpm as f32, self.time_sig); + + for snap in &self.midi_snapshots { + let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { + Some(&id) => id, + None => continue, + }; + controller.update_midi_clip_events(track_id, snap.midi_clip_id, snap.old_events.clone()); + } + + for snap in &self.clip_snapshots { + let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { + Some(&id) => id, + None => continue, + }; + let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id); + match backend_id { + Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => { + controller.move_clip(track_id, *audio_id, snap.old_fields.timeline_start); + } + Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => { + controller.move_clip(track_id, *midi_id, snap.old_fields.timeline_start); + } + None => {} + } + } + + // Sync beat/frame representations and restore MIDI clip durations in the backend + let fps = document.framerate; + let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter() + .map(|s| (s.midi_clip_id, s.old_clip_duration)) + .collect(); + controller.apply_bpm_change(self.old_bpm, fps, midi_durations); + + Ok(()) + } + + fn all_midi_events_after_execute(&self) -> Vec<(u32, Vec)> { + self.midi_snapshots.iter() + .map(|s| (s.midi_clip_id, s.new_events.clone())) + .collect() + } + + fn all_midi_events_after_rollback(&self) -> Vec<(u32, Vec)> { + self.midi_snapshots.iter() + .map(|s| (s.midi_clip_id, s.old_events.clone())) + .collect() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/change_fps.rs b/lightningbeam-ui/lightningbeam-core/src/actions/change_fps.rs new file mode 100644 index 0000000..8c6ff29 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/change_fps.rs @@ -0,0 +1,227 @@ +//! Change FPS action +//! +//! Atomically changes the document framerate and rescales all clip instance positions +//! so that frame positions are preserved (Frames mode behaviour). + +use crate::action::{Action, BackendContext}; +use crate::clip::ClipInstance; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// Snapshot of all timing fields on a `ClipInstance` +#[derive(Clone)] +struct TimingFields { + timeline_start: f64, + timeline_start_beats: f64, + timeline_start_frames: f64, + trim_start: f64, + trim_start_beats: f64, + trim_start_frames: f64, + trim_end: Option, + trim_end_beats: Option, + trim_end_frames: Option, + timeline_duration: Option, + timeline_duration_beats: Option, + timeline_duration_frames: Option, +} + +impl TimingFields { + fn from_instance(ci: &ClipInstance) -> Self { + Self { + timeline_start: ci.timeline_start, + timeline_start_beats: ci.timeline_start_beats, + timeline_start_frames: ci.timeline_start_frames, + trim_start: ci.trim_start, + trim_start_beats: ci.trim_start_beats, + trim_start_frames: ci.trim_start_frames, + trim_end: ci.trim_end, + trim_end_beats: ci.trim_end_beats, + trim_end_frames: ci.trim_end_frames, + timeline_duration: ci.timeline_duration, + timeline_duration_beats: ci.timeline_duration_beats, + timeline_duration_frames: ci.timeline_duration_frames, + } + } + + fn apply_to(&self, ci: &mut ClipInstance) { + ci.timeline_start = self.timeline_start; + ci.timeline_start_beats = self.timeline_start_beats; + ci.timeline_start_frames = self.timeline_start_frames; + ci.trim_start = self.trim_start; + ci.trim_start_beats = self.trim_start_beats; + ci.trim_start_frames = self.trim_start_frames; + ci.trim_end = self.trim_end; + ci.trim_end_beats = self.trim_end_beats; + ci.trim_end_frames = self.trim_end_frames; + ci.timeline_duration = self.timeline_duration; + ci.timeline_duration_beats = self.timeline_duration_beats; + ci.timeline_duration_frames = self.timeline_duration_frames; + } +} + +#[derive(Clone)] +struct ClipTimingSnapshot { + layer_id: Uuid, + instance_id: Uuid, + old_fields: TimingFields, + new_fields: TimingFields, +} + +/// Action that atomically changes framerate and rescales all clip positions to preserve frames +pub struct ChangeFpsAction { + old_fps: f64, + new_fps: f64, + clip_snapshots: Vec, +} + +impl ChangeFpsAction { + /// Build the action, computing new positions for all clip instances. + pub fn new(old_fps: f64, new_fps: f64, document: &Document) -> Self { + let bpm = document.bpm; + + let mut clip_snapshots: Vec = Vec::new(); + + for layer in document.all_layers() { + let layer_id = layer.id(); + + let clip_instances: &[ClipInstance] = match layer { + AnyLayer::Vector(vl) => &vl.clip_instances, + AnyLayer::Audio(al) => &al.clip_instances, + AnyLayer::Video(vl) => &vl.clip_instances, + AnyLayer::Effect(el) => &el.clip_instances, + AnyLayer::Group(_) | AnyLayer::Raster(_) => continue, + }; + + for ci in clip_instances { + let old_fields = TimingFields::from_instance(ci); + + // Compute new fields: frames are canonical, recompute seconds + beats + let mut new_ci = ci.clone(); + new_ci.apply_frames(new_fps, bpm); + let new_fields = TimingFields::from_instance(&new_ci); + + clip_snapshots.push(ClipTimingSnapshot { + layer_id, + instance_id: ci.id, + old_fields, + new_fields, + }); + } + } + + Self { + old_fps, + new_fps, + clip_snapshots, + } + } + + fn apply_clips(document: &mut Document, snapshots: &[ClipTimingSnapshot], use_new: bool) { + for snap in snapshots { + let fields = if use_new { &snap.new_fields } else { &snap.old_fields }; + + let layer = match document.get_layer_mut(&snap.layer_id) { + Some(l) => l, + None => continue, + }; + + let clip_instances = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(el) => &mut el.clip_instances, + AnyLayer::Group(_) | AnyLayer::Raster(_) => continue, + }; + + if let Some(ci) = clip_instances.iter_mut().find(|ci| ci.id == snap.instance_id) { + fields.apply_to(ci); + } + } + } +} + +impl Action for ChangeFpsAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + document.framerate = self.new_fps; + Self::apply_clips(document, &self.clip_snapshots, true); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + document.framerate = self.old_fps; + Self::apply_clips(document, &self.clip_snapshots, false); + Ok(()) + } + + fn description(&self) -> String { + "Change FPS".to_string() + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + // FPS change does not affect audio timing — only move clips that changed position + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + + for snap in &self.clip_snapshots { + if (snap.new_fields.timeline_start - snap.old_fields.timeline_start).abs() < 1e-9 { + continue; // No movement, skip + } + let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { + Some(&id) => id, + None => continue, + }; + let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id); + match backend_id { + Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => { + controller.move_clip(track_id, *audio_id, snap.new_fields.timeline_start); + } + Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => { + controller.move_clip(track_id, *midi_id, snap.new_fields.timeline_start); + } + None => {} + } + } + + Ok(()) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + + for snap in &self.clip_snapshots { + if (snap.new_fields.timeline_start - snap.old_fields.timeline_start).abs() < 1e-9 { + continue; + } + let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { + Some(&id) => id, + None => continue, + }; + let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id); + match backend_id { + Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => { + controller.move_clip(track_id, *audio_id, snap.old_fields.timeline_start); + } + Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => { + controller.move_clip(track_id, *midi_id, snap.old_fields.timeline_start); + } + None => {} + } + } + + Ok(()) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 4458e24..ad49ecf 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -4,6 +4,8 @@ //! through the action system. pub mod add_clip_instance; +pub mod change_bpm; +pub mod change_fps; pub mod add_effect; pub mod add_layer; pub mod add_shape; @@ -70,3 +72,5 @@ pub use raster_stroke::RasterStrokeAction; pub use raster_fill::RasterFillAction; pub use move_layer::MoveLayerAction; pub use set_fill_paint::SetFillPaintAction; +pub use change_bpm::ChangeBpmAction; +pub use change_fps::ChangeFpsAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index fba7ee7..3a97088 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -119,6 +119,9 @@ impl Action for MoveClipInstancesAction { // Store adjusted moves for rollback self.layer_moves = adjusted_moves.clone(); + let bpm = document.bpm; + let fps = document.framerate; + // Apply all adjusted moves for (layer_id, moves) in &adjusted_moves { let layer = document.get_layer_mut(layer_id) @@ -139,6 +142,7 @@ impl Action for MoveClipInstancesAction { if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) { clip_instance.timeline_start = *new; + clip_instance.sync_from_seconds(bpm, fps); } } } @@ -147,6 +151,8 @@ impl Action for MoveClipInstancesAction { } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let bpm = document.bpm; + let fps = document.framerate; for (layer_id, moves) in &self.layer_moves { let layer = document.get_layer_mut(layer_id) .ok_or_else(|| format!("Layer {} not found", layer_id))?; @@ -166,6 +172,7 @@ impl Action for MoveClipInstancesAction { if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) { clip_instance.timeline_start = *old; + clip_instance.sync_from_seconds(bpm, fps); } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index 6993bcd..6da1327 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -179,6 +179,7 @@ impl Action for SplitClipInstanceAction { } self.new_instance_id = Some(right_instance.id); + right_instance.sync_from_seconds(document.bpm, document.framerate); // Now modify the original (left) instance and add the new (right) instance let layer_mut = document @@ -238,6 +239,21 @@ impl Action for SplitClipInstanceAction { } } + // Sync derived fields on the left (original) instance + let (bpm, fps) = (document.bpm, document.framerate); + if let Some(layer) = document.get_layer_mut(&self.layer_id) { + let cis: &mut Vec = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(el) => &mut el.clip_instances, + _ => return { self.executed = true; Ok(()) }, + }; + if let Some(inst) = cis.iter_mut().find(|ci| ci.id == self.instance_id) { + inst.sync_from_seconds(bpm, fps); + } + } + self.executed = true; Ok(()) } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index 080b278..78ea70d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -260,6 +260,9 @@ impl Action for TrimClipInstancesAction { // Store clamped trims for rollback self.layer_trims = clamped_trims.clone(); + let bpm = document.bpm; + let fps = document.framerate; + // Apply all clamped trims for (layer_id, trims) in &clamped_trims { let layer = match document.get_layer_mut(layer_id) { @@ -294,6 +297,7 @@ impl Action for TrimClipInstancesAction { clip_instance.trim_end = new.trim_value; } } + clip_instance.sync_from_seconds(bpm, fps); } } } @@ -301,6 +305,8 @@ impl Action for TrimClipInstancesAction { } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let bpm = document.bpm; + let fps = document.framerate; for (layer_id, trims) in &self.layer_trims { let layer = match document.get_layer_mut(layer_id) { Some(l) => l, @@ -334,6 +340,7 @@ impl Action for TrimClipInstancesAction { clip_instance.trim_end = old.trim_value; } } + clip_instance.sync_from_seconds(bpm, fps); } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 84ee618..2b4b81a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -610,11 +610,23 @@ pub struct ClipInstance { /// This is the external positioning - where the instance appears on the timeline /// Default: 0.0 (start at beginning of layer) pub timeline_start: f64, + /// timeline_start in beats (quarter-note beats); derived from timeline_start + #[serde(default)] + pub timeline_start_beats: f64, + /// timeline_start in frames; derived from timeline_start + #[serde(default)] + pub timeline_start_frames: f64, /// How long this instance appears on the timeline (in seconds) /// If timeline_duration > (trim_end - trim_start), the trimmed content will loop /// Default: None (use trimmed clip duration, no looping) pub timeline_duration: Option, + /// timeline_duration in beats; derived from timeline_duration + #[serde(default)] + pub timeline_duration_beats: Option, + /// timeline_duration in frames; derived from timeline_duration + #[serde(default)] + pub timeline_duration_frames: Option, /// Trim start: offset into the clip's internal content (in seconds) /// Allows trimming the beginning of the clip @@ -623,11 +635,23 @@ pub struct ClipInstance { /// - For vector: offset into the animation timeline /// Default: 0.0 (start at beginning of clip) pub trim_start: f64, + /// trim_start in beats; derived from trim_start + #[serde(default)] + pub trim_start_beats: f64, + /// trim_start in frames; derived from trim_start + #[serde(default)] + pub trim_start_frames: f64, /// Trim end: offset into the clip's internal content (in seconds) /// Allows trimming the end of the clip /// Default: None (use full clip duration) pub trim_end: Option, + /// trim_end in beats; derived from trim_end + #[serde(default)] + pub trim_end_beats: Option, + /// trim_end in frames; derived from trim_end + #[serde(default)] + pub trim_end_frames: Option, /// Playback speed multiplier /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed @@ -696,9 +720,17 @@ impl ClipInstance { opacity: 1.0, name: None, timeline_start: 0.0, + timeline_start_beats: 0.0, + timeline_start_frames: 0.0, timeline_duration: None, + timeline_duration_beats: None, + timeline_duration_frames: None, trim_start: 0.0, + trim_start_beats: 0.0, + trim_start_frames: 0.0, trim_end: None, + trim_end_beats: None, + trim_end_frames: None, playback_speed: 1.0, gain: 1.0, loop_before: None, @@ -714,15 +746,71 @@ impl ClipInstance { opacity: 1.0, name: None, timeline_start: 0.0, + timeline_start_beats: 0.0, + timeline_start_frames: 0.0, timeline_duration: None, + timeline_duration_beats: None, + timeline_duration_frames: None, trim_start: 0.0, + trim_start_beats: 0.0, + trim_start_frames: 0.0, trim_end: None, + trim_end_beats: None, + trim_end_frames: None, playback_speed: 1.0, gain: 1.0, loop_before: None, } } + /// Sync beats and frames from the seconds fields (call after any seconds-based write). + pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { + self.timeline_start_beats = self.timeline_start * bpm / 60.0; + self.timeline_start_frames = self.timeline_start * fps; + self.trim_start_beats = self.trim_start * bpm / 60.0; + self.trim_start_frames = self.trim_start * fps; + self.trim_end_beats = self.trim_end.map(|v| v * bpm / 60.0); + self.trim_end_frames = self.trim_end.map(|v| v * fps); + self.timeline_duration_beats = self.timeline_duration.map(|v| v * bpm / 60.0); + self.timeline_duration_frames = self.timeline_duration.map(|v| v * fps); + } + + /// Recompute seconds and frames from beats (call when BPM changes in Measures mode). + pub fn apply_beats(&mut self, bpm: f64, fps: f64) { + self.timeline_start = self.timeline_start_beats * 60.0 / bpm; + self.timeline_start_frames = self.timeline_start * fps; + self.trim_start = self.trim_start_beats * 60.0 / bpm; + self.trim_start_frames = self.trim_start * fps; + if let Some(b) = self.trim_end_beats { + let s = b * 60.0 / bpm; + self.trim_end = Some(s); + self.trim_end_frames = Some(s * fps); + } + if let Some(b) = self.timeline_duration_beats { + let s = b * 60.0 / bpm; + self.timeline_duration = Some(s); + self.timeline_duration_frames = Some(s * fps); + } + } + + /// Recompute seconds and beats from frames (call when FPS changes in Frames mode). + pub fn apply_frames(&mut self, fps: f64, bpm: f64) { + self.timeline_start = self.timeline_start_frames / fps; + self.timeline_start_beats = self.timeline_start * bpm / 60.0; + self.trim_start = self.trim_start_frames / fps; + self.trim_start_beats = self.trim_start * bpm / 60.0; + if let Some(f) = self.trim_end_frames { + let s = f / fps; + self.trim_end = Some(s); + self.trim_end_beats = Some(s * bpm / 60.0); + } + if let Some(f) = self.timeline_duration_frames { + let s = f / fps; + self.timeline_duration = Some(s); + self.timeline_duration_beats = Some(s * bpm / 60.0); + } + } + /// Set the transform pub fn with_transform(mut self, transform: Transform) -> Self { self.transform = transform; diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 261bd74..c33ea98 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -619,6 +619,56 @@ impl Document { layers } + /// Migrate old documents: compute beats/frames from seconds for any ClipInstance whose + /// derived fields are still zero (i.e., documents saved before triple-representation). + /// Call once after loading a document. + pub fn sync_all_clip_positions(&mut self) { + let bpm = self.bpm; + let fps = self.framerate; + + fn sync_list(list: &mut [crate::layer::AnyLayer], bpm: f64, fps: f64) { + for layer in list.iter_mut() { + match layer { + crate::layer::AnyLayer::Vector(vl) => { + for ci in &mut vl.clip_instances { + if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); } + } + } + crate::layer::AnyLayer::Audio(al) => { + for ci in &mut al.clip_instances { + if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); } + } + } + crate::layer::AnyLayer::Video(vl) => { + for ci in &mut vl.clip_instances { + if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); } + } + } + crate::layer::AnyLayer::Effect(el) => { + for ci in &mut el.clip_instances { + if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); } + } + } + crate::layer::AnyLayer::Group(g) => { + sync_list(&mut g.children, bpm, fps); + } + crate::layer::AnyLayer::Raster(_) => {} + } + } + } + + sync_list(&mut self.root.children, bpm, fps); + for clip in self.vector_clips.values_mut() { + for node in &mut clip.layers.roots { + if let crate::layer::AnyLayer::Vector(vl) = &mut node.data { + for ci in &mut vl.clip_instances { + if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); } + } + } + } + } + } + // === CLIP LIBRARY METHODS === /// Add a vector clip to the library diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index e827cb5..8f90521 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -3114,7 +3114,12 @@ impl EditorApp { }; // Rebuild MIDI cache after undo (backend_context dropped, borrows released) if undo_succeeded { - if let Some((clip_id, events)) = self.action_executor.last_redo_midi_events() + let multi = self.action_executor.last_redo_all_midi_events(); + if !multi.is_empty() { + for (clip_id, events) in multi { + self.midi_event_cache.insert(clip_id, events); + } + } else if let Some((clip_id, events)) = self.action_executor.last_redo_midi_events() .map(|(id, ev)| (id, ev.to_vec())) { self.midi_event_cache.insert(clip_id, events); @@ -3158,7 +3163,12 @@ impl EditorApp { }; // Rebuild MIDI cache after redo (backend_context dropped, borrows released) if redo_succeeded { - if let Some((clip_id, events)) = self.action_executor.last_undo_midi_events() + let multi = self.action_executor.last_undo_all_midi_events(); + if !multi.is_empty() { + for (clip_id, events) in multi { + self.midi_event_cache.insert(clip_id, events); + } + } else if let Some((clip_id, events)) = self.action_executor.last_undo_midi_events() .map(|(id, ev)| (id, ev.to_vec())) { self.midi_event_cache.insert(clip_id, events); @@ -3824,6 +3834,9 @@ impl EditorApp { // Rebuild MIDI event cache for all MIDI clips (needed for timeline/piano roll rendering) let step8_start = std::time::Instant::now(); + // Migrate old documents: compute beats/frames derived fields + self.action_executor.document_mut().sync_all_clip_positions(); + self.midi_event_cache.clear(); let midi_clip_ids: Vec = self.action_executor.document() .audio_clips.values() @@ -3846,6 +3859,19 @@ impl EditorApp { } eprintln!("📊 [APPLY] Step 8: Rebuilt MIDI event cache for {} clips in {:.2}ms", midi_fetched, step8_start.elapsed().as_secs_f64() * 1000.0); + // Sync beats/frames derived fields on MIDI events (migration for old documents) + { + let bpm = self.action_executor.document().bpm; + let fps = self.action_executor.document().framerate; + for events in self.midi_event_cache.values_mut() { + for ev in events.iter_mut() { + if ev.timestamp_beats == 0.0 && ev.timestamp.abs() > 1e-9 { + ev.sync_from_seconds(bpm, fps); + } + } + } + } + // Reset playback state self.playback_time = 0.0; self.is_playing = false; @@ -3976,10 +4002,16 @@ 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 bpm = self.action_executor.document().bpm; + let fps = self.action_executor.document().framerate; let mut events: Vec = Vec::with_capacity(notes.len() * 2); for &(start_time, note, velocity, duration) in notes { - events.push(daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity)); - events.push(daw_backend::audio::midi::MidiEvent::note_off(start_time + duration, 0, note, 0)); + let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity); + on.sync_from_seconds(bpm, fps); + events.push(on); + let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + duration, 0, note, 0); + off.sync_from_seconds(bpm, fps); + events.push(off); } events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); self.midi_event_cache.insert(clip_id, events); @@ -5178,10 +5210,16 @@ impl eframe::App for EditorApp { // Update midi_event_cache with notes captured so far // (inlined to avoid conflicting &mut self borrow) { + let bpm = self.action_executor.document().bpm; + let fps = self.action_executor.document().framerate; let mut events: Vec = Vec::with_capacity(notes.len() * 2); for &(start_time, note, velocity, dur) in ¬es { - events.push(daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity)); - events.push(daw_backend::audio::midi::MidiEvent::note_off(start_time + dur, 0, note, 0)); + let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity); + on.sync_from_seconds(bpm, fps); + events.push(on); + let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + dur, 0, note, 0); + off.sync_from_seconds(bpm, fps); + events.push(off); } events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); self.midi_event_cache.insert(clip_id, events); @@ -5198,7 +5236,13 @@ impl eframe::App for EditorApp { match controller.query_midi_clip(track_id, clip_id) { Ok(midi_clip_data) => { drop(controller); - self.midi_event_cache.insert(clip_id, midi_clip_data.events.clone()); + let bpm = self.action_executor.document().bpm; + let fps = self.action_executor.document().framerate; + let mut final_events = midi_clip_data.events.clone(); + for ev in &mut final_events { + ev.sync_from_seconds(bpm, fps); + } + self.midi_event_cache.insert(clip_id, final_events); // Update document clip with final duration and name let doc_clip_id = self.action_executor.document() diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index b93d028..9ca1bfd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -42,6 +42,8 @@ pub struct InfopanelPane { selected_shape_gradient_stop: Option, /// Selected stop index for gradient editor in tool section (gradient tool). selected_tool_gradient_stop: Option, + /// FPS value captured when a drag/focus-in starts (for single-undo-action on commit) + fps_drag_start: Option, } impl InfopanelPane { @@ -58,6 +60,7 @@ impl InfopanelPane { brush_preview_textures: Vec::new(), selected_shape_gradient_stop: None, selected_tool_gradient_stop: None, + fps_drag_start: None, } } } @@ -906,21 +909,20 @@ impl InfopanelPane { } /// Render document settings section (shown when nothing is focused) - fn render_document_section(&self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState) { + fn render_document_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState) { egui::CollapsingHeader::new("Document") .id_salt(("document", path)) .default_open(true) .show(ui, |ui| { ui.add_space(4.0); - let document = shared.action_executor.document(); - - // Get current values for editing - let mut width = document.width; - let mut height = document.height; - let mut duration = document.duration; - let mut framerate = document.framerate; - let layer_count = document.root.children.len(); + // Extract all needed values up front, then drop the borrow before closures + // that need mutable access to shared or self. + let (mut width, mut height, mut duration, mut framerate, layer_count, background_color) = { + let document = shared.action_executor.document(); + (document.width, document.height, document.duration, document.framerate, + document.root.children.len(), document.background_color) + }; // Canvas width ui.horizontal(|ui| { @@ -966,24 +968,54 @@ impl InfopanelPane { // Framerate ui.horizontal(|ui| { ui.label("Framerate:"); - if ui - .add( - DragValue::new(&mut framerate) - .speed(1.0) - .range(1.0..=120.0) - .suffix(" fps"), - ) - .changed() - { - let action = SetDocumentPropertiesAction::set_framerate(framerate); - shared.pending_actions.push(Box::new(action)); + let fps_response = ui.add( + DragValue::new(&mut framerate) + .speed(1.0) + .range(1.0..=120.0) + .suffix(" fps"), + ); + + if fps_response.gained_focus() || fps_response.drag_started() { + if self.fps_drag_start.is_none() { + self.fps_drag_start = Some(framerate); + } + } + + if fps_response.changed() { + // Live preview: update document directly + shared.action_executor.document_mut().framerate = framerate; + } + + if fps_response.drag_stopped() || fps_response.lost_focus() { + if let Some(start_fps) = self.fps_drag_start.take() { + let new_fps = shared.action_executor.document().framerate; + let timeline_mode = shared.action_executor.document().timeline_mode; + if (start_fps - new_fps).abs() > 1e-9 + && timeline_mode == lightningbeam_core::document::TimelineMode::Frames + { + use lightningbeam_core::actions::ChangeFpsAction; + // Revert live-preview so the action owns it + shared.action_executor.document_mut().framerate = start_fps; + let action = ChangeFpsAction::new( + start_fps, + new_fps, + shared.action_executor.document(), + ); + shared.pending_actions.push(Box::new(action)); + } else if (start_fps - new_fps).abs() > 1e-9 { + // Not in Frames mode — use simple property action (no stretching) + shared.action_executor.document_mut().framerate = start_fps; + let action = SetDocumentPropertiesAction::set_framerate(new_fps); + shared.pending_actions.push(Box::new(action)); + } + } } }); // Background color (with alpha) ui.horizontal(|ui| { ui.label("Background:"); - let bg = document.background_color; + let bg = background_color; let mut color = [bg.r, bg.g, bg.b, bg.a]; if ui.color_edit_button_srgba_unmultiplied(&mut color).changed() { let action = SetDocumentPropertiesAction::set_background_color( diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 00fea9b..695f15f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -770,7 +770,7 @@ impl PianoRollPane { }; let timestamp = note_start + t * note_duration; let (lsb, msb) = encode_bend(normalized); - events.push(MidiEvent { timestamp, status: 0xE0 | channel, data1: lsb, data2: msb }); + events.push(MidiEvent::new(timestamp, 0xE0 | channel, lsb, msb)); } events } @@ -1568,15 +1568,20 @@ impl PianoRollPane { let combined = (existing_norm[i] + zone_norm).clamp(-1.0, 1.0); let (lsb, msb) = encode_bend(combined); let ts = note_start + i as f64 / num_steps as f64 * note_duration; - new_events.push(daw_backend::audio::midi::MidiEvent { timestamp: ts, status: 0xE0 | target_channel, data1: lsb, data2: msb }); + new_events.push(daw_backend::audio::midi::MidiEvent::new(ts, 0xE0 | target_channel, lsb, msb)); } // For End zone: reset just after note ends so it doesn't bleed into next note if zone == PitchBendZone::End { let (lsb, msb) = encode_bend(0.0); - new_events.push(daw_backend::audio::midi::MidiEvent { timestamp: note_start + note_duration + 0.005, status: 0xE0 | target_channel, data1: lsb, data2: msb }); + new_events.push(daw_backend::audio::midi::MidiEvent::new(note_start + note_duration + 0.005, 0xE0 | target_channel, lsb, msb)); } new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal)); + { + let doc = shared.action_executor.document(); + let bpm = doc.bpm; let fps = doc.framerate; + for ev in &mut new_events { ev.sync_from_seconds(bpm, fps); } + } self.push_events_action("Set pitch bend", clip_id, old_events, new_events.clone(), shared); shared.midi_event_cache.insert(clip_id, new_events); } @@ -2359,15 +2364,17 @@ impl PaneRenderer for PianoRollPane { !(is_cc1 && at_start) }); if new_cc1 > 0 { - new_events.push(daw_backend::audio::midi::MidiEvent { - timestamp: sn.start_time, - status: 0xB0 | sn.channel, - data1: 1, - data2: new_cc1, - }); + new_events.push(daw_backend::audio::midi::MidiEvent::new( + sn.start_time, 0xB0 | sn.channel, 1, new_cc1, + )); } } new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal)); + { + let doc = shared.action_executor.document(); + let bpm = doc.bpm; let fps = doc.framerate; + for ev in &mut new_events { ev.sync_from_seconds(bpm, fps); } + } self.push_events_action("Set modulation", clip_id, old_events, new_events.clone(), shared); shared.midi_event_cache.insert(clip_id, new_events); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index ad3d804..2079e4e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -222,6 +222,9 @@ pub struct TimelinePane { /// Layer currently being renamed via inline text edit (layer_id, buffer) renaming_layer: Option<(uuid::Uuid, String)>, + + /// BPM value captured when a drag/focus-in starts (for single-undo-action on commit) + bpm_drag_start: Option, } /// Deferred recording start created during count-in pre-roll @@ -505,11 +508,19 @@ fn build_audio_clip_cache( let mut ci = ClipInstance::new(clip_id); ci.id = instance_id; ci.timeline_start = ac.external_start; + ci.timeline_start_beats = ac.external_start_beats; + ci.timeline_start_frames = ac.external_start_frames; ci.trim_start = ac.internal_start; + ci.trim_start_beats = ac.internal_start_beats; + ci.trim_start_frames = ac.internal_start_frames; ci.trim_end = Some(ac.internal_end); + ci.trim_end_beats = Some(ac.internal_end_beats); + ci.trim_end_frames = Some(ac.internal_end_frames); let internal_dur = ac.internal_end - ac.internal_start; if (ac.external_duration - internal_dur).abs() > 1e-9 { ci.timeline_duration = Some(ac.external_duration); + ci.timeline_duration_beats = Some(ac.external_duration_beats); + ci.timeline_duration_frames = Some(ac.external_duration_frames); } ci.gain = ac.gain; instances.push(ci); @@ -527,11 +538,19 @@ fn build_audio_clip_cache( let mut ci = ClipInstance::new(clip_id); ci.id = instance_id; ci.timeline_start = mc.external_start; + ci.timeline_start_beats = mc.external_start_beats; + ci.timeline_start_frames = mc.external_start_frames; ci.trim_start = mc.internal_start; + ci.trim_start_beats = mc.internal_start_beats; + ci.trim_start_frames = mc.internal_start_frames; ci.trim_end = Some(mc.internal_end); + ci.trim_end_beats = Some(mc.internal_end_beats); + ci.trim_end_frames = Some(mc.internal_end_frames); let internal_dur = mc.internal_end - mc.internal_start; if (mc.external_duration - internal_dur).abs() > 1e-9 { ci.timeline_duration = Some(mc.external_duration); + ci.timeline_duration_beats = Some(mc.external_duration_beats); + ci.timeline_duration_frames = Some(mc.external_duration_frames); } instances.push(ci); } @@ -684,6 +703,7 @@ impl TimelinePane { metronome_icon: None, pending_recording_start: None, renaming_layer: None, + bpm_drag_start: None, } } @@ -1004,8 +1024,11 @@ impl TimelinePane { *shared.recording_clips.get(&layer_id).unwrap_or(&0), 0.0); let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip); - let clip_instance = ClipInstance::new(doc_clip_id) + let bpm = shared.action_executor.document().bpm; + let fps = shared.action_executor.document().framerate; + let mut clip_instance = ClipInstance::new(doc_clip_id) .with_timeline_start(start_time); + clip_instance.sync_from_seconds(bpm, fps); if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { @@ -1340,6 +1363,61 @@ impl TimelinePane { ((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32 } + /// Effective display start for a clip instance. + /// + /// In Measures mode, uses `timeline_start_beats` as the canonical position so clips stay + /// anchored to their beat position during live BPM drag preview. Falls back to seconds + /// in other modes or when beat data is unavailable. + fn instance_display_start(&self, ci: &lightningbeam_core::clip::ClipInstance, bpm: f64) -> f64 { + if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures + && (ci.timeline_start_beats.abs() > 1e-12 || ci.timeline_start == 0.0) + { + ci.timeline_start_beats * 60.0 / bpm - ci.loop_before.unwrap_or(0.0) + } else { + ci.effective_start() + } + } + + /// In Measures mode, uses beats fields for the clip's on-timeline duration so the width + /// stays correct during live BPM drag preview. Falls back to seconds in other modes. + fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, bpm: f64) -> f64 { + use lightningbeam_core::document::TimelineMode; + if self.time_display_format == TimelineMode::Measures { + // Looping/extended clip: explicit timeline_duration_beats + if let Some(dur_beats) = ci.timeline_duration_beats { + if dur_beats.abs() > 1e-12 { + return dur_beats * 60.0 / bpm; + } + } + // Non-looping: derive from trim range in beats + let ts_beats = ci.trim_start_beats; + if let Some(te_beats) = ci.trim_end_beats { + if te_beats.abs() > 1e-12 || ts_beats.abs() > 1e-12 { + return (te_beats - ts_beats).max(0.0) * 60.0 / bpm; + } + } + } + ci.total_duration(clip_dur_secs) + } + + /// In Measures mode, returns the clip content start (trim_start) and duration in + /// beat-derived display seconds, for use when rendering note overlays. + fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, bpm: f64) -> (f64, f64) { + use lightningbeam_core::document::TimelineMode; + if self.time_display_format == TimelineMode::Measures { + let ts_beats = ci.trim_start_beats; + if let Some(te_beats) = ci.trim_end_beats { + if te_beats.abs() > 1e-12 || ts_beats.abs() > 1e-12 { + let start = ts_beats * 60.0 / bpm; + let dur = (te_beats - ts_beats).max(0.0) * 60.0 / bpm; + return (start, dur); + } + } + } + let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); + (ci.trim_start, (trim_end - ci.trim_start).max(0.0)) + } + /// Convert pixel x-coordinate to time (seconds) fn x_to_time(&self, x: f32) -> f64 { self.viewport_start_time + (x / self.pixels_per_second) as f64 @@ -1616,6 +1694,9 @@ impl TimelinePane { /// Render mini piano roll visualization for MIDI clips on timeline /// Shows notes modulo 12 (one octave) matching the JavaScript reference implementation + /// + /// `display_bpm`: when `Some(bpm)`, note timestamps are derived from `timestamp_beats` + /// so positions stay beat-anchored during live BPM drag preview. #[allow(clippy::too_many_arguments)] fn render_midi_piano_roll( painter: &egui::Painter, @@ -1630,6 +1711,7 @@ impl TimelinePane { theme: &crate::theme::Theme, ctx: &egui::Context, faded: bool, + display_bpm: Option, ) { let clip_height = clip_rect.height(); let note_height = clip_height / 12.0; // 12 semitones per octave @@ -1638,6 +1720,17 @@ impl TimelinePane { let note_style = theme.style(".timeline-midi-note", ctx); let note_color = note_style.background_color().unwrap_or(egui::Color32::BLACK); + // In Measures mode during BPM drag, derive display time from beats so notes + // stay anchored to their beat positions. + let event_display_time = |ev: &daw_backend::audio::midi::MidiEvent| -> f64 { + if let Some(bpm) = display_bpm { + if ev.timestamp_beats.abs() > 1e-12 || ev.timestamp == 0.0 { + return ev.timestamp_beats * 60.0 / bpm; + } + } + ev.timestamp + }; + // Build a map of active notes (note_number -> note_on_timestamp) // to calculate durations when we encounter note-offs let mut active_notes: std::collections::HashMap = std::collections::HashMap::new(); @@ -1646,10 +1739,10 @@ impl TimelinePane { // First pass: pair note-ons with note-offs to calculate durations for event in events { if event.is_note_on() { - let (note_number, timestamp) = (event.data1, event.timestamp); + let (note_number, timestamp) = (event.data1, event_display_time(event)); active_notes.insert(note_number, timestamp); } else if event.is_note_off() { - let (note_number, timestamp) = (event.data1, event.timestamp); + let (note_number, timestamp) = (event.data1, event_display_time(event)); if let Some(¬e_on_time) = active_notes.get(¬e_number) { let duration = timestamp - note_on_time; @@ -3120,14 +3213,15 @@ impl TimelinePane { let clip_duration = effective_clip_duration(document, layer, clip_instance); if let Some(clip_duration) = clip_duration { - // Calculate effective duration accounting for trimming - let mut instance_duration = clip_instance.total_duration(clip_duration); + // Calculate effective duration accounting for trimming. + // In Measures mode, uses beats fields so width tracks BPM during live drag. + let mut instance_duration = self.instance_display_duration(clip_instance, clip_duration, document.bpm); - // Instance positioned on the layer's timeline using timeline_start - // The layer itself has start_time, so the absolute timeline position is: - // layer.start_time + instance.timeline_start + // Instance positioned on the layer's timeline using timeline_start. + // In Measures mode, uses timeline_start_beats so clips stay at their beat + // position during live BPM drag preview. let _layer_data = layer.layer(); - let mut instance_start = clip_instance.effective_start(); + let mut instance_start = self.instance_display_start(clip_instance, document.bpm); // Apply drag offset preview for selected clips with snapping let is_selected = selection.contains_clip_instance(&clip_instance.id); @@ -3148,12 +3242,13 @@ impl TimelinePane { // Content origin: where the first "real" content iteration starts // Loop iterations tile outward from this point - let mut content_origin = clip_instance.timeline_start; + let mut content_origin = instance_start + clip_instance.loop_before.unwrap_or(0.0); - // Track preview trim values for waveform rendering - let mut preview_trim_start = clip_instance.trim_start; - let preview_trim_end_default = clip_instance.trim_end.unwrap_or(clip_duration); - let mut preview_clip_duration = (preview_trim_end_default - preview_trim_start).max(0.0); + // Track preview trim values for note/waveform rendering. + // In Measures mode, derive from beats so they track BPM during live drag. + let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm); + let mut preview_trim_start = base_trim_start; + let mut preview_clip_duration = base_clip_duration; if let Some(drag_type) = self.clip_drag_state { if is_selected || is_linked_to_dragged { @@ -3408,6 +3503,11 @@ impl TimelinePane { let iter_duration = iter_end - iter_start; if iter_duration <= 0.0 { continue; } + let note_bpm = if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures { + Some(document.bpm) + } else { + None + }; Self::render_midi_piano_roll( &painter, clip_rect, @@ -3421,9 +3521,15 @@ impl TimelinePane { theme, ui.ctx(), si != 0, // fade non-content iterations + note_bpm, ); } } else { + let note_bpm = if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures { + Some(document.bpm) + } else { + None + }; Self::render_midi_piano_roll( &painter, clip_rect, @@ -3437,6 +3543,7 @@ impl TimelinePane { theme, ui.ctx(), false, + note_bpm, ); } } @@ -5061,7 +5168,21 @@ impl PaneRenderer for TimelinePane { .range(20.0..=300.0) .speed(0.5) .fixed_decimals(1)); + + // Capture start BPM on drag/focus start + if bpm_response.gained_focus() || bpm_response.drag_started() { + if self.bpm_drag_start.is_none() { + self.bpm_drag_start = Some(bpm); + } + } + if bpm_response.changed() { + // Fallback capture: if gained_focus/drag_started didn't fire (e.g. rapid input), + // capture start BPM on the first change before updating the document. + if self.bpm_drag_start.is_none() { + self.bpm_drag_start = Some(bpm); + } + // Live preview: update document directly so grid reflows immediately shared.action_executor.document_mut().bpm = bpm_val; if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); @@ -5069,6 +5190,31 @@ impl PaneRenderer for TimelinePane { } } + // On commit, dispatch a single ChangeBpmAction (single undo entry) + if bpm_response.drag_stopped() || bpm_response.lost_focus() { + if let Some(start_bpm) = self.bpm_drag_start.take() { + let new_bpm = shared.action_executor.document().bpm; + if (start_bpm - new_bpm).abs() > 1e-6 + && self.time_display_format == lightningbeam_core::document::TimelineMode::Measures + { + use lightningbeam_core::actions::ChangeBpmAction; + // Revert the live-preview mutation so the action owns it + shared.action_executor.document_mut().bpm = start_bpm; + let action = ChangeBpmAction::new( + start_bpm, + new_bpm, + shared.action_executor.document(), + shared.midi_event_cache, + ); + // Immediately update midi_event_cache for rendering + for (clip_id, events) in action.new_midi_events() { + shared.midi_event_cache.insert(clip_id, events.clone()); + } + shared.pending_actions.push(Box::new(action)); + } + } + } + ui.separator(); // Time signature selector