Update automation lanes too

This commit is contained in:
Skyler Lehmkuhl 2026-04-01 10:17:52 -04:00
parent 3fc4773ec3
commit ae146533d9
7 changed files with 150 additions and 20 deletions

View File

@ -1306,10 +1306,10 @@ impl Engine {
self.current_bpm = bpm as f64; self.current_bpm = bpm as f64;
} }
Command::ApplyBpmChange(bpm, fps, midi_durations) => { Command::ApplyBpmChange(from_bpm, to_bpm, fps, midi_durations) => {
self.current_bpm = bpm; self.current_bpm = to_bpm;
self.current_fps = fps; 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(); self.refresh_clip_snapshot();
} }
@ -2181,13 +2181,16 @@ impl Engine {
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
// Downcast to AutomationInputNode using as_any_mut // Downcast to AutomationInputNode using as_any_mut
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() { if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
let keyframe = AutomationKeyframe { let mut keyframe = AutomationKeyframe {
time, time,
time_beats: 0.0,
time_frames: 0.0,
value, value,
interpolation, interpolation,
ease_out, ease_out,
ease_in, ease_in,
}; };
keyframe.sync_from_seconds(self.current_bpm, self.current_fps);
auto_node.add_keyframe(keyframe); auto_node.add_keyframe(keyframe);
} else { } else {
eprintln!("Node {} is not an AutomationInputNode", node_id); 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)); 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 /// Start recording on a track
pub fn start_recording(&mut self, track_id: TrackId, start_time: f64) { pub fn start_recording(&mut self, track_id: TrackId, start_time: f64) {
let _ = self.command_tx.push(Command::StartRecording(track_id, start_time)); 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)); 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. /// 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)>) { 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(bpm, fps, midi_durations)); let _ = self.command_tx.push(Command::ApplyBpmChange(from_bpm, to_bpm, fps, midi_durations));
} }
// Node graph operations // Node graph operations

View File

@ -737,6 +737,18 @@ impl AudioGraph {
self.graph.node_indices() 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::<AutomationInputNode>() {
auto_node.apply_beats_to_keyframes(from_bpm, to_bpm, fps);
}
}
}
/// Reallocate a node's output buffers to match its current port list. /// Reallocate a node's output buffers to match its current port list.
/// ///
/// Must be called after `SubtrackInputsNode::update_subtracks` changes the port count, /// Must be called after `SubtrackInputsNode::update_subtracks` changes the port count,
@ -1358,6 +1370,8 @@ impl AudioGraph {
for kf in &serialized_node.automation_keyframes { for kf in &serialized_node.automation_keyframes {
auto_node.add_keyframe(AutomationKeyframe { auto_node.add_keyframe(AutomationKeyframe {
time: kf.time, time: kf.time,
time_beats: 0.0,
time_frames: 0.0,
value: kf.value, value: kf.value,
interpolation: match kf.interpolation.as_str() { interpolation: match kf.interpolation.as_str() {
"bezier" => InterpolationType::Bezier, "bezier" => InterpolationType::Bezier,

View File

@ -18,6 +18,12 @@ pub enum InterpolationType {
pub struct AutomationKeyframe { pub struct AutomationKeyframe {
/// Time in seconds (absolute project time) /// Time in seconds (absolute project time)
pub time: f64, 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 /// CV output value
pub value: f32, pub value: f32,
/// Interpolation type to next keyframe /// Interpolation type to next keyframe
@ -32,12 +38,32 @@ impl AutomationKeyframe {
pub fn new(time: f64, value: f32) -> Self { pub fn new(time: f64, value: f32) -> Self {
Self { Self {
time, time,
time_beats: 0.0,
time_frames: 0.0,
value, value,
interpolation: InterpolationType::Linear, interpolation: InterpolationType::Linear,
ease_out: (0.58, 1.0), ease_out: (0.58, 1.0),
ease_in: (0.42, 0.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 /// Automation Input Node - outputs CV signal controlled by timeline curves
@ -143,6 +169,24 @@ impl AutomationInputNode {
self.keyframes.clear(); 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 /// Evaluate curve at a specific time
fn evaluate_at_time(&self, time: f64) -> f32 { fn evaluate_at_time(&self, time: f64) -> f32 {
if self.keyframes.is_empty() { if self.keyframes.is_empty() {

View File

@ -216,19 +216,25 @@ impl Project {
self.tracks.iter().map(|(&id, node)| (id, node)) 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. /// `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). /// 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() { for (_, track) in self.tracks.iter_mut() {
match track { match track {
crate::audio::track::TrackNode::Audio(t) => { crate::audio::track::TrackNode::Audio(t) => {
for clip in &mut t.clips { 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) => { 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 // Update content durations first so internal_end is correct before sync
for instance in &mut t.clip_instances { for instance in &mut t.clip_instances {
if let Some(&new_dur) = midi_durations.iter() 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.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 // Update pool clip durations
for &(clip_id, new_dur) in midi_durations { for &(clip_id, new_dur) in midi_durations {

View File

@ -144,9 +144,10 @@ pub enum Command {
SetMetronomeEnabled(bool), SetMetronomeEnabled(bool),
/// Set project tempo and time signature (bpm, (numerator, denominator)) /// Set project tempo and time signature (bpm, (numerator, denominator))
SetTempo(f32, (u32, u32)), SetTempo(f32, (u32, u32)),
/// After a BPM change: update MIDI clip durations and sync all clip beats/frames from seconds. /// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale
/// (bpm, fps, midi_durations: Vec<(clip_id, new_duration_seconds)>) /// automation keyframe times to preserve beat positions.
ApplyBpmChange(f64, f64, Vec<(MidiClipId, f64)>), /// (from_bpm, to_bpm, fps, midi_durations: Vec<(clip_id, new_duration_seconds)>)
ApplyBpmChange(f64, f64, f64, Vec<(MidiClipId, f64)>),
// Node graph commands // Node graph commands
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y) /// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)

View File

@ -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 fps = document.framerate;
let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter() let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter()
.map(|s| (s.midi_clip_id, s.new_clip_duration)) .map(|s| (s.midi_clip_id, s.new_clip_duration))
.collect(); .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(()) 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 fps = document.framerate;
let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter() let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter()
.map(|s| (s.midi_clip_id, s.old_clip_duration)) .map(|s| (s.midi_clip_id, s.old_clip_duration))
.collect(); .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(()) Ok(())
} }

View File

@ -215,6 +215,8 @@ pub struct TimelinePane {
automation_cache_generation: u64, automation_cache_generation: u64,
/// Last seen graph_topology_generation; used to detect node additions/removals /// Last seen graph_topology_generation; used to detect node additions/removals
automation_topology_generation: u64, 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) /// Cached metronome icon texture (loaded on first use)
metronome_icon: Option<egui::TextureHandle>, metronome_icon: Option<egui::TextureHandle>,
/// Count-in pre-roll state: set when count-in is active, cleared when recording fires /// 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 }, 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) }, 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 }, 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). /// 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(), pending_automation_actions: Vec::new(),
automation_cache_generation: u64::MAX, automation_cache_generation: u64::MAX,
automation_topology_generation: u64::MAX, automation_topology_generation: u64::MAX,
automation_cache_bpm: f64::NAN,
metronome_icon: None, metronome_icon: None,
pending_recording_start: None, pending_recording_start: None,
renaming_layer: None, renaming_layer: None,
@ -1836,6 +1840,7 @@ impl TimelinePane {
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, daw_backend::TrackId>, layer_to_track_map: &std::collections::HashMap<uuid::Uuid, daw_backend::TrackId>,
track_levels: &std::collections::HashMap<daw_backend::TrackId, f32>, track_levels: &std::collections::HashMap<daw_backend::TrackId, f32>,
input_level: f32, input_level: f32,
playback_time: f64,
) { ) {
// Background for header column // Background for header column
let header_style = theme.style(".timeline-header", ui.ctx()); 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, 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(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()); 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 { if let Some(node_id) = volume_auto_node_id {
// Route through automation: update the t=0 keyframe // Route through automation: update the t=0 keyframe
// Ensure range is 02 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 { self.pending_automation_actions.push(AutomationLaneAction::AddKeyframe {
layer_id, layer_id,
node_id, node_id,
@ -2301,6 +2320,8 @@ impl TimelinePane {
// Optimistic cache update so slider feels immediate // Optimistic cache update so slider feels immediate
if let Some(lanes) = self.automation_cache.get_mut(&layer_id) { 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) { 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) { if let Some(kf) = lane.keyframes.iter_mut().find(|k| k.time < 0.001) {
kf.value = new_volume; kf.value = new_volume;
} }
@ -5211,6 +5232,17 @@ impl PaneRenderer for TimelinePane {
for (clip_id, events) in action.new_midi_events() { for (clip_id, events) in action.new_midi_events() {
shared.midi_event_cache.insert(clip_id, events.clone()); 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)); shared.pending_actions.push(Box::new(action));
} }
} }
@ -5354,7 +5386,7 @@ impl PaneRenderer for TimelinePane {
// Render layer header column with clipping // Render layer header column with clipping
ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); 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) // Render time ruler (clip to ruler rect)
ui.set_clip_rect(ruler_rect.intersect(original_clip_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(); 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. // Refresh automation cache for expanded layers.
// Clears all caches when the project is reloaded (project_generation) or when the node // 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 // graph topology changes (graph_topology_generation — bumped by the node graph pane on