Update automation lanes too
This commit is contained in:
parent
3fc4773ec3
commit
ae146533d9
|
|
@ -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::<AutomationInputNode>() {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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::<AutomationInputNode>() {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<egui::TextureHandle>,
|
||||
/// 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<uuid::Uuid, daw_backend::TrackId>,
|
||||
track_levels: &std::collections::HashMap<daw_backend::TrackId, f32>,
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue