diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 2dc85c0..622b211 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1306,10 +1306,10 @@ impl Engine { self.current_bpm = bpm as f64; } - Command::ApplyBpmChange(bpm, fps, midi_durations) => { - self.current_bpm = bpm; + Command::ApplyBpmChange(from_bpm, to_bpm, fps, midi_durations) => { + self.current_bpm = to_bpm; self.current_fps = fps; - self.project.apply_bpm_change(bpm, fps, &midi_durations); + self.project.apply_bpm_change(from_bpm, to_bpm, fps, &midi_durations); self.refresh_clip_snapshot(); } @@ -2181,13 +2181,16 @@ impl Engine { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { // Downcast to AutomationInputNode using as_any_mut if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { - let keyframe = AutomationKeyframe { + let mut keyframe = AutomationKeyframe { time, + time_beats: 0.0, + time_frames: 0.0, value, interpolation, ease_out, ease_in, }; + keyframe.sync_from_seconds(self.current_bpm, self.current_fps); auto_node.add_keyframe(keyframe); } else { eprintln!("Node {} is not an AutomationInputNode", node_id); @@ -3632,6 +3635,12 @@ impl EngineController { let _ = self.command_tx.push(Command::AutomationSetName(track_id, node_id, name)); } + /// Set the min/max output range of an AutomationInput node (param ids 0 = min, 1 = max) + pub fn automation_set_range(&mut self, track_id: TrackId, node_id: u32, min: f32, max: f32) { + self.graph_set_parameter(track_id, node_id, 0, min); + self.graph_set_parameter(track_id, node_id, 1, max); + } + /// Start recording on a track pub fn start_recording(&mut self, track_id: TrackId, start_time: f64) { let _ = self.command_tx.push(Command::StartRecording(track_id, start_time)); @@ -3692,10 +3701,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. + /// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale + /// automation keyframe times to preserve beat positions. + /// `from_bpm` is the BPM before the change; `to_bpm` is the new BPM. /// 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)); + pub fn apply_bpm_change(&mut self, from_bpm: f64, to_bpm: f64, fps: f64, midi_durations: Vec<(crate::audio::MidiClipId, f64)>) { + let _ = self.command_tx.push(Command::ApplyBpmChange(from_bpm, to_bpm, fps, midi_durations)); } // Node graph operations diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index d1be311..9f9f9c6 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -737,6 +737,18 @@ impl AudioGraph { self.graph.node_indices() } + /// BPM changed: rescale all AutomationInput keyframe times to preserve beat positions. + /// `from_bpm` is the BPM before the change (used to bootstrap beats if not yet populated). + /// `to_bpm` is the new BPM (used to re-derive seconds from beats). + pub fn apply_beats_to_automation_keyframes(&mut self, from_bpm: f64, to_bpm: f64, fps: f64) { + use super::nodes::AutomationInputNode; + for node in self.graph.node_weights_mut() { + if let Some(auto_node) = node.node.as_any_mut().downcast_mut::() { + auto_node.apply_beats_to_keyframes(from_bpm, to_bpm, fps); + } + } + } + /// Reallocate a node's output buffers to match its current port list. /// /// Must be called after `SubtrackInputsNode::update_subtracks` changes the port count, @@ -1358,6 +1370,8 @@ impl AudioGraph { for kf in &serialized_node.automation_keyframes { auto_node.add_keyframe(AutomationKeyframe { time: kf.time, + time_beats: 0.0, + time_frames: 0.0, value: kf.value, interpolation: match kf.interpolation.as_str() { "bezier" => InterpolationType::Bezier, diff --git a/daw-backend/src/audio/node_graph/nodes/automation_input.rs b/daw-backend/src/audio/node_graph/nodes/automation_input.rs index 44142b5..0dc9d43 100644 --- a/daw-backend/src/audio/node_graph/nodes/automation_input.rs +++ b/daw-backend/src/audio/node_graph/nodes/automation_input.rs @@ -18,6 +18,12 @@ pub enum InterpolationType { pub struct AutomationKeyframe { /// Time in seconds (absolute project time) pub time: f64, + /// Time in beats (derived; canonical in Measures mode) + #[serde(default)] + pub time_beats: f64, + /// Time in frames (derived; canonical in Frames mode) + #[serde(default)] + pub time_frames: f64, /// CV output value pub value: f32, /// Interpolation type to next keyframe @@ -32,12 +38,32 @@ impl AutomationKeyframe { pub fn new(time: f64, value: f32) -> Self { Self { time, + time_beats: 0.0, + time_frames: 0.0, value, interpolation: InterpolationType::Linear, ease_out: (0.58, 1.0), ease_in: (0.42, 0.0), } } + + /// Populate beats/frames from the current seconds value. + pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { + self.time_beats = self.time * bpm / 60.0; + self.time_frames = self.time * fps; + } + + /// BPM changed; beats are canonical → recompute seconds and frames. + pub fn apply_beats(&mut self, bpm: f64, fps: f64) { + self.time = self.time_beats * 60.0 / bpm; + self.time_frames = self.time * fps; + } + + /// FPS changed; frames are canonical → recompute seconds and beats. + pub fn apply_frames(&mut self, fps: f64, bpm: f64) { + self.time = self.time_frames / fps; + self.time_beats = self.time * bpm / 60.0; + } } /// Automation Input Node - outputs CV signal controlled by timeline curves @@ -143,6 +169,24 @@ impl AutomationInputNode { self.keyframes.clear(); } + /// Populate beats/frames on all keyframes from their current seconds values. + pub fn sync_keyframes_from_seconds(&mut self, bpm: f64, fps: f64) { + for kf in &mut self.keyframes { + kf.sync_from_seconds(bpm, fps); + } + } + + /// BPM changed: for each keyframe, bootstrap beats from seconds (using `from_bpm`) if not yet + /// set, then re-derive seconds and frames from beats using `to_bpm`. + pub fn apply_beats_to_keyframes(&mut self, from_bpm: f64, to_bpm: f64, fps: f64) { + for kf in &mut self.keyframes { + if kf.time_beats == 0.0 && kf.time.abs() > 1e-9 { + kf.sync_from_seconds(from_bpm, fps); + } + kf.apply_beats(to_bpm, fps); + } + } + /// Evaluate curve at a specific time fn evaluate_at_time(&self, time: f64) -> f32 { if self.keyframes.is_empty() { diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index d46d4f5..967b34e 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -216,19 +216,25 @@ 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. + /// After a BPM change, update MIDI clip durations, sync clip beats/frames, and rescale + /// automation keyframe times to preserve beat positions. /// + /// `from_bpm` is the BPM before the change (used to bootstrap beats on legacy data). + /// `to_bpm` is the new BPM. /// `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)]) { + pub fn apply_bpm_change(&mut self, from_bpm: f64, to_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); + clip.sync_from_seconds(to_bpm, fps); } } crate::audio::track::TrackNode::Midi(t) => { + // Rescale automation keyframe times in this track's graph + t.instrument_graph.apply_beats_to_automation_keyframes(from_bpm, to_bpm, fps); + // 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() @@ -242,7 +248,7 @@ impl Project { instance.external_duration = instance.external_duration * new_dur / old_internal_dur; } } - instance.sync_from_seconds(bpm, fps); + instance.sync_from_seconds(to_bpm, fps); } // Update pool clip durations for &(clip_id, new_dur) in midi_durations { diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index ecc71fc..8b9aba0 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -144,9 +144,10 @@ 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)>), + /// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale + /// automation keyframe times to preserve beat positions. + /// (from_bpm, to_bpm, fps, midi_durations: Vec<(clip_id, new_duration_seconds)>) + ApplyBpmChange(f64, 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/actions/change_bpm.rs b/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs index 0d7c89d..549fa00 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs @@ -290,12 +290,13 @@ impl Action for ChangeBpmAction { } } - // Sync beat/frame representations and rescale MIDI clip durations in the backend + // Sync beat/frame representations, rescale MIDI clip durations, and rescale + // automation keyframe times 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); + controller.apply_bpm_change(self.old_bpm, self.new_bpm, fps, midi_durations); Ok(()) } @@ -337,12 +338,13 @@ impl Action for ChangeBpmAction { } } - // Sync beat/frame representations and restore MIDI clip durations in the backend + // Sync beat/frame representations, restore MIDI clip durations, and restore + // automation keyframe times 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); + controller.apply_bpm_change(self.new_bpm, self.old_bpm, fps, midi_durations); Ok(()) } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 80ed7c4..0aab067 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -215,6 +215,8 @@ pub struct TimelinePane { automation_cache_generation: u64, /// Last seen graph_topology_generation; used to detect node additions/removals automation_topology_generation: u64, + /// Last seen BPM; automation keyframe times rescale on BPM change, so clear cache when it differs + automation_cache_bpm: f64, /// Cached metronome icon texture (loaded on first use) metronome_icon: Option, /// Count-in pre-roll state: set when count-in is active, cleared when recording fires @@ -378,6 +380,7 @@ enum AutomationLaneAction { AddKeyframe { layer_id: uuid::Uuid, node_id: u32, time: f64, value: f32 }, MoveKeyframe { layer_id: uuid::Uuid, node_id: u32, old_time: f64, new_time: f64, new_value: f32, interpolation: String, ease_out: (f32, f32), ease_in: (f32, f32) }, DeleteKeyframe { layer_id: uuid::Uuid, node_id: u32, time: f64 }, + SetRange { layer_id: uuid::Uuid, node_id: u32, min: f32, max: f32 }, } /// Paint a soft drop shadow around a rect using gradient meshes (bottom + right + corner). @@ -700,6 +703,7 @@ impl TimelinePane { pending_automation_actions: Vec::new(), automation_cache_generation: u64::MAX, automation_topology_generation: u64::MAX, + automation_cache_bpm: f64::NAN, metronome_icon: None, pending_recording_start: None, renaming_layer: None, @@ -1836,6 +1840,7 @@ impl TimelinePane { layer_to_track_map: &std::collections::HashMap, track_levels: &std::collections::HashMap, input_level: f32, + playback_time: f64, ) { // Background for header column let header_style = theme.style(".timeline-header", ui.ctx()); @@ -2146,7 +2151,11 @@ impl TimelinePane { Some((nid, kfs)) if kfs.len() == 1 && (kfs[0].time - 0.0).abs() < 0.001 => { (Some(*nid), Some(kfs[0].value as f64), false) } - Some((nid, _)) => (Some(*nid), None, true), + Some((nid, kfs)) => { + // Multiple keyframes — slider is read-only; show the curve value at playback pos + let v = crate::curve_editor::evaluate_curve(kfs, playback_time); + (Some(*nid), Some(v as f64), true) + } }; let current_volume = volume_auto_value.unwrap_or_else(|| layer_for_controls.volume()); @@ -2292,6 +2301,16 @@ impl TimelinePane { if let Some(node_id) = volume_auto_node_id { // Route through automation: update the t=0 keyframe + // Ensure range is 0–2 so the curve editor shows the correct y-axis + let needs_range_update = self.automation_cache.get(&layer_id) + .and_then(|lanes| lanes.iter().find(|l| l.node_id == node_id)) + .map(|l| (l.value_min - 0.0).abs() > 0.01 || (l.value_max - 2.0).abs() > 0.01) + .unwrap_or(true); + if needs_range_update { + self.pending_automation_actions.push(AutomationLaneAction::SetRange { + layer_id, node_id, min: 0.0, max: 2.0, + }); + } self.pending_automation_actions.push(AutomationLaneAction::AddKeyframe { layer_id, node_id, @@ -2301,6 +2320,8 @@ impl TimelinePane { // Optimistic cache update so slider feels immediate if let Some(lanes) = self.automation_cache.get_mut(&layer_id) { if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) { + lane.value_min = 0.0; + lane.value_max = 2.0; if let Some(kf) = lane.keyframes.iter_mut().find(|k| k.time < 0.001) { kf.value = new_volume; } @@ -5211,6 +5232,17 @@ impl PaneRenderer for TimelinePane { for (clip_id, events) in action.new_midi_events() { shared.midi_event_cache.insert(clip_id, events.clone()); } + // Optimistically rescale automation keyframe times in the cache. + // The backend rescales asynchronously via ApplyBpmChange; this keeps + // the cache consistent without waiting for the engine to round-trip. + let scale = start_bpm / new_bpm; + for lanes in self.automation_cache.values_mut() { + for lane in lanes.iter_mut() { + for kf in lane.keyframes.iter_mut() { + kf.time *= scale; + } + } + } shared.pending_actions.push(Box::new(action)); } } @@ -5354,7 +5386,7 @@ impl PaneRenderer for TimelinePane { // Render layer header column with clipping ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); - self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level); + self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time); // Render time ruler (clip to ruler rect) ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); @@ -5428,6 +5460,18 @@ impl PaneRenderer for TimelinePane { } } } + AutomationLaneAction::SetRange { layer_id, node_id, min, max } => { + if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) { + controller.automation_set_range(track_id, node_id, min, max); + // Optimistic cache update + if let Some(lanes) = self.automation_cache.get_mut(&layer_id) { + if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) { + lane.value_min = min; + lane.value_max = max; + } + } + } + } } } } @@ -5439,6 +5483,14 @@ impl PaneRenderer for TimelinePane { self.automation_cache.clear(); } + // Invalidate automation cache when BPM changes (but not during a live drag — + // during drag the backend hasn't rescaled yet so re-querying would give stale data). + let bpm_committed = self.bpm_drag_start.is_none(); + if bpm_committed && (document.bpm - self.automation_cache_bpm).abs() > 1e-9 { + self.automation_cache_bpm = document.bpm; + self.automation_cache.clear(); + } + // Refresh automation cache for expanded layers. // Clears all caches when the project is reloaded (project_generation) or when the node // graph topology changes (graph_topology_generation — bumped by the node graph pane on