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