Make beats canonical representation rather than seconds
This commit is contained in:
parent
f372a84313
commit
54d5764bd0
|
|
@ -113,6 +113,8 @@ pub struct Engine {
|
||||||
// Current tempo map — kept in sync with SetTempo/SetTempoMap commands.
|
// Current tempo map — kept in sync with SetTempo/SetTempoMap commands.
|
||||||
tempo_map: crate::TempoMap,
|
tempo_map: crate::TempoMap,
|
||||||
current_fps: f64,
|
current_fps: f64,
|
||||||
|
// Current time signature — updated by SetTempo, used when SetTempoMap fires.
|
||||||
|
time_sig: (u32, u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
|
|
@ -191,6 +193,7 @@ impl Engine {
|
||||||
timing_overrun_count: 0,
|
timing_overrun_count: 0,
|
||||||
tempo_map: crate::TempoMap::constant(120.0),
|
tempo_map: crate::TempoMap::constant(120.0),
|
||||||
current_fps: 30.0,
|
current_fps: 30.0,
|
||||||
|
time_sig: (4, 4),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1290,11 +1293,19 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::SetTempo(bpm, time_sig) => {
|
Command::SetTempo(bpm, time_sig) => {
|
||||||
|
self.time_sig = time_sig;
|
||||||
self.metronome.update_timing(bpm, time_sig);
|
self.metronome.update_timing(bpm, time_sig);
|
||||||
self.project.set_tempo(bpm, time_sig.0);
|
self.project.set_tempo(bpm, time_sig.0);
|
||||||
self.tempo_map.set_global_bpm(bpm as f64);
|
self.tempo_map.set_global_bpm(bpm as f64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Command::SetTempoMap(map) => {
|
||||||
|
let bpm0 = map.global_bpm() as f32;
|
||||||
|
self.metronome.update_timing(bpm0, self.time_sig);
|
||||||
|
self.project.set_tempo(bpm0, self.time_sig.0);
|
||||||
|
self.tempo_map = map;
|
||||||
|
}
|
||||||
|
|
||||||
// Node graph commands
|
// Node graph commands
|
||||||
Command::GraphAddNode(track_id, node_type, x, y) => {
|
Command::GraphAddNode(track_id, node_type, x, y) => {
|
||||||
eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", track_id, node_type, x, y);
|
eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", track_id, node_type, x, y);
|
||||||
|
|
@ -1846,9 +1857,17 @@ impl Engine {
|
||||||
let buffer_size = self.buffer_pool.buffer_size();
|
let buffer_size = self.buffer_pool.buffer_size();
|
||||||
if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||||
let current = metatrack.current_subtracks();
|
let current = metatrack.current_subtracks();
|
||||||
|
let has_subtrack_node = metatrack.audio_graph.node_indices().any(|idx| {
|
||||||
|
metatrack.audio_graph.get_graph_node(idx)
|
||||||
|
.map(|n| n.node.node_type() == "SubtrackInputs")
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
// No-op if subtrack list is unchanged (prevents every-frame graph rebuilds)
|
// No-op if subtrack list is unchanged AND the graph is already initialised.
|
||||||
if current == subtracks {
|
// Without the `has_subtrack_node` guard a freshly-created metatrack
|
||||||
|
// (empty graph, no SubtrackInputs) with zero subtracks would never get
|
||||||
|
// its default Volume automation node.
|
||||||
|
if current == subtracks && has_subtrack_node {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2156,8 +2175,13 @@ impl Engine {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
let graph = match self.project.get_track_mut(track_id) {
|
||||||
let graph = &mut track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||||
|
|
@ -2181,8 +2205,13 @@ impl Engine {
|
||||||
Command::AutomationRemoveKeyframe(track_id, node_id, time) => {
|
Command::AutomationRemoveKeyframe(track_id, node_id, time) => {
|
||||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
let graph = match self.project.get_track_mut(track_id) {
|
||||||
let graph = &mut track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||||
|
|
@ -2198,8 +2227,13 @@ impl Engine {
|
||||||
Command::AutomationSetName(track_id, node_id, name) => {
|
Command::AutomationSetName(track_id, node_id, name) => {
|
||||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
let graph = match self.project.get_track_mut(track_id) {
|
||||||
let graph = &mut track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||||
|
|
@ -2508,12 +2542,15 @@ impl Engine {
|
||||||
use crate::audio::node_graph::nodes::{AutomationInputNode, InterpolationType};
|
use crate::audio::node_graph::nodes::{AutomationInputNode, InterpolationType};
|
||||||
use crate::command::types::AutomationKeyframeData;
|
use crate::command::types::AutomationKeyframeData;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
|
let graph = match self.project.get_track(track_id) {
|
||||||
let graph = &track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
||||||
// Downcast to AutomationInputNode
|
|
||||||
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||||
let keyframes: Vec<AutomationKeyframeData> = auto_node.keyframes()
|
let keyframes: Vec<AutomationKeyframeData> = auto_node.keyframes()
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -2524,7 +2561,6 @@ impl Engine {
|
||||||
InterpolationType::Step => "step",
|
InterpolationType::Step => "step",
|
||||||
InterpolationType::Hold => "hold",
|
InterpolationType::Hold => "hold",
|
||||||
}.to_string();
|
}.to_string();
|
||||||
|
|
||||||
AutomationKeyframeData {
|
AutomationKeyframeData {
|
||||||
time: kf.time.0,
|
time: kf.time.0,
|
||||||
value: kf.value,
|
value: kf.value,
|
||||||
|
|
@ -2534,7 +2570,6 @@ impl Engine {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
QueryResponse::AutomationKeyframes(Ok(keyframes))
|
QueryResponse::AutomationKeyframes(Ok(keyframes))
|
||||||
} else {
|
} else {
|
||||||
QueryResponse::AutomationKeyframes(Err(format!("Node {} is not an AutomationInputNode", node_id)))
|
QueryResponse::AutomationKeyframes(Err(format!("Node {} is not an AutomationInputNode", node_id)))
|
||||||
|
|
@ -2543,19 +2578,22 @@ impl Engine {
|
||||||
QueryResponse::AutomationKeyframes(Err(format!("Node {} not found in track {}", node_id, track_id)))
|
QueryResponse::AutomationKeyframes(Err(format!("Node {} not found in track {}", node_id, track_id)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
QueryResponse::AutomationKeyframes(Err(format!("Track {} not found or is not a MIDI track", track_id)))
|
QueryResponse::AutomationKeyframes(Err(format!("Track {} not found", track_id)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Query::GetAutomationName(track_id, node_id) => {
|
Query::GetAutomationName(track_id, node_id) => {
|
||||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
|
let graph = match self.project.get_track(track_id) {
|
||||||
let graph = &track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
||||||
// Downcast to AutomationInputNode
|
|
||||||
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||||
QueryResponse::AutomationName(Ok(auto_node.display_name().to_string()))
|
QueryResponse::AutomationName(Ok(auto_node.display_name().to_string()))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2565,17 +2603,21 @@ impl Engine {
|
||||||
QueryResponse::AutomationName(Err(format!("Node {} not found in track {}", node_id, track_id)))
|
QueryResponse::AutomationName(Err(format!("Node {} not found in track {}", node_id, track_id)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
QueryResponse::AutomationName(Err(format!("Track {} not found or is not a MIDI track", track_id)))
|
QueryResponse::AutomationName(Err(format!("Track {} not found", track_id)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Query::GetAutomationRange(track_id, node_id) => {
|
Query::GetAutomationRange(track_id, node_id) => {
|
||||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
|
let graph = match self.project.get_track(track_id) {
|
||||||
let graph = &track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
||||||
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||||
QueryResponse::AutomationRange(Ok((auto_node.value_min, auto_node.value_max)))
|
QueryResponse::AutomationRange(Ok((auto_node.value_min, auto_node.value_max)))
|
||||||
|
|
@ -2586,7 +2628,7 @@ impl Engine {
|
||||||
QueryResponse::AutomationRange(Err(format!("Node {} not found", node_id)))
|
QueryResponse::AutomationRange(Err(format!("Node {} not found", node_id)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
QueryResponse::AutomationRange(Err(format!("Track {} not found or is not a MIDI track", track_id)))
|
QueryResponse::AutomationRange(Err(format!("Track {} not found", track_id)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3665,6 +3707,11 @@ impl EngineController {
|
||||||
let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature));
|
let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the full tempo map (multi-entry variable tempo)
|
||||||
|
pub fn set_tempo_map(&mut self, map: crate::TempoMap) {
|
||||||
|
let _ = self.command_tx.push(Command::SetTempoMap(map));
|
||||||
|
}
|
||||||
|
|
||||||
/// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale
|
/// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale
|
||||||
/// automation keyframe times to preserve beat positions.
|
/// automation keyframe times to preserve beat positions.
|
||||||
/// `from_bpm` is the BPM before the change; `to_bpm` is the new BPM.
|
/// `from_bpm` is the BPM before the change; `to_bpm` is the new BPM.
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,11 @@ impl Metatrack {
|
||||||
graph
|
graph
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the explicit subtrack mixing graph: SubtrackInputs → Mixer → AudioOutput.
|
/// Build the default subtrack mixing graph: SubtrackInputs → Mixer → Gain → AudioOutput,
|
||||||
|
/// with an AutomationInput ("Volume", range 0..2) feeding the Gain's CV port.
|
||||||
|
///
|
||||||
|
/// Existing Volume keyframes are preserved across rebuilds so that adding/removing
|
||||||
|
/// a child track doesn't reset the automation.
|
||||||
///
|
///
|
||||||
/// `subtracks` is an ordered list of (backend TrackId, display name) for each child.
|
/// `subtracks` is an ordered list of (backend TrackId, display name) for each child.
|
||||||
/// Replaces the current graph and marks `graph_is_default = true`.
|
/// Replaces the current graph and marks `graph_is_default = true`.
|
||||||
|
|
@ -273,35 +277,60 @@ impl Metatrack {
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
buffer_size: usize,
|
buffer_size: usize,
|
||||||
) {
|
) {
|
||||||
use super::node_graph::nodes::{SubtrackInputsNode, MixerNode};
|
use super::node_graph::nodes::{SubtrackInputsNode, MixerNode, GainNode, AutomationInputNode};
|
||||||
|
use super::node_graph::nodes::AutomationKeyframe;
|
||||||
|
use crate::time::Beats;
|
||||||
|
|
||||||
|
// Preserve existing Volume keyframes before rebuilding.
|
||||||
|
let existing_volume_kfs = self.get_volume_automation_keyframes();
|
||||||
|
|
||||||
let n = subtracks.len();
|
let n = subtracks.len();
|
||||||
let mut graph = AudioGraph::new(sample_rate, buffer_size);
|
let mut graph = AudioGraph::new(sample_rate, buffer_size);
|
||||||
|
|
||||||
// SubtrackInputs node (N outputs, one per child)
|
// SubtrackInputs node (N outputs, one per child)
|
||||||
// NOTE: `new()` initialises buffers as zero-length; call `update_subtracks` immediately
|
|
||||||
// to allocate stereo interleaved buffers (buffer_size * 2 samples each).
|
|
||||||
let mut inputs_node = SubtrackInputsNode::new("Subtrack Inputs", subtracks);
|
let mut inputs_node = SubtrackInputsNode::new("Subtrack Inputs", subtracks);
|
||||||
let subtracks_copy = inputs_node.subtracks().to_vec();
|
let subtracks_copy = inputs_node.subtracks().to_vec();
|
||||||
inputs_node.update_subtracks(subtracks_copy, buffer_size);
|
inputs_node.update_subtracks(subtracks_copy, buffer_size);
|
||||||
let inputs_id = graph.add_node(Box::new(inputs_node));
|
let inputs_id = graph.add_node(Box::new(inputs_node));
|
||||||
graph.set_node_position(inputs_id, 100.0, 150.0);
|
graph.set_node_position(inputs_id, 100.0, 150.0);
|
||||||
|
|
||||||
// Mixer node (starts with 1 spare; grows as connections are made)
|
// Mixer node
|
||||||
let mixer_node = Box::new(MixerNode::new("Mixer"));
|
let mixer_node = Box::new(MixerNode::new("Mixer"));
|
||||||
let mixer_id = graph.add_node(mixer_node);
|
let mixer_id = graph.add_node(mixer_node);
|
||||||
graph.set_node_position(mixer_id, 350.0, 150.0);
|
graph.set_node_position(mixer_id, 330.0, 150.0);
|
||||||
|
|
||||||
|
// Gain node — group volume control
|
||||||
|
let gain_id = graph.add_node(Box::new(GainNode::new("Volume")));
|
||||||
|
graph.set_node_position(gain_id, 520.0, 150.0);
|
||||||
|
|
||||||
|
// AutomationInput — drives the Gain's CV port
|
||||||
|
let mut auto_node = AutomationInputNode::new("Volume CV");
|
||||||
|
auto_node.set_display_name("Volume".to_string());
|
||||||
|
auto_node.value_min = 0.0;
|
||||||
|
auto_node.value_max = 2.0;
|
||||||
|
auto_node.clear_keyframes();
|
||||||
|
if existing_volume_kfs.is_empty() {
|
||||||
|
auto_node.add_keyframe(AutomationKeyframe::new(Beats::ZERO, 1.0));
|
||||||
|
} else {
|
||||||
|
for kf in existing_volume_kfs {
|
||||||
|
auto_node.add_keyframe(kf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let auto_id = graph.add_node(Box::new(auto_node));
|
||||||
|
graph.set_node_position(auto_id, 520.0, 320.0);
|
||||||
|
|
||||||
// AudioOutput node
|
// AudioOutput node
|
||||||
let output_node = Box::new(AudioOutputNode::new("Audio Output"));
|
let output_node = Box::new(AudioOutputNode::new("Audio Output"));
|
||||||
let output_id = graph.add_node(output_node);
|
let output_id = graph.add_node(output_node);
|
||||||
graph.set_node_position(output_id, 600.0, 150.0);
|
graph.set_node_position(output_id, 720.0, 150.0);
|
||||||
|
|
||||||
// Connect SubtrackInputs[i] → Mixer[i] for each subtrack
|
// Connect SubtrackInputs[i] → Mixer[i] for each subtrack
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let _ = graph.connect(inputs_id, i, mixer_id, i);
|
let _ = graph.connect(inputs_id, i, mixer_id, i);
|
||||||
}
|
}
|
||||||
let _ = graph.connect(mixer_id, 0, output_id, 0);
|
let _ = graph.connect(mixer_id, 0, gain_id, 0); // Mixer → Gain audio
|
||||||
|
let _ = graph.connect(auto_id, 0, gain_id, 1); // AutomationInput → Gain CV
|
||||||
|
let _ = graph.connect(gain_id, 0, output_id, 0); // Gain → Audio Out
|
||||||
graph.set_output_node(Some(output_id));
|
graph.set_output_node(Some(output_id));
|
||||||
|
|
||||||
self.audio_graph = graph;
|
self.audio_graph = graph;
|
||||||
|
|
@ -309,6 +338,22 @@ impl Metatrack {
|
||||||
self.graph_is_default = true;
|
self.graph_is_default = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract Volume AutomationInput keyframes from the current graph (if any),
|
||||||
|
/// so they can be preserved across `set_subtrack_graph` rebuilds.
|
||||||
|
fn get_volume_automation_keyframes(&self) -> Vec<super::node_graph::nodes::AutomationKeyframe> {
|
||||||
|
use super::node_graph::nodes::AutomationInputNode;
|
||||||
|
for idx in self.audio_graph.node_indices() {
|
||||||
|
if let Some(node) = self.audio_graph.get_graph_node(idx) {
|
||||||
|
if node.node.node_type() == "AutomationInput" {
|
||||||
|
if let Some(auto_node) = node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||||
|
return auto_node.keyframes().to_vec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a new subtrack port to the existing graph.
|
/// Add a new subtrack port to the existing graph.
|
||||||
///
|
///
|
||||||
/// If `graph_is_default`: also connects the new port to a new Mixer input.
|
/// If `graph_is_default`: also connects the new port to a new Mixer input.
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,8 @@ 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)),
|
||||||
|
/// Replace the entire tempo map (multi-entry variable tempo support)
|
||||||
|
SetTempoMap(crate::TempoMap),
|
||||||
// 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)
|
||||||
GraphAddNode(TrackId, String, f32, f32),
|
GraphAddNode(TrackId, String, f32, f32),
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ pub use audio::{
|
||||||
};
|
};
|
||||||
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
|
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
|
||||||
pub use time::{Beats, Seconds};
|
pub use time::{Beats, Seconds};
|
||||||
pub use tempo_map::{TempoEntry, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
|
pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
|
||||||
pub use command::{AudioEvent, Command, OscilloscopeData};
|
pub use command::{AudioEvent, Command, OscilloscopeData};
|
||||||
pub use command::types::AutomationKeyframeData;
|
pub use command::types::AutomationKeyframeData;
|
||||||
pub use io::{load_midi_file, AudioFile, WaveformChunk, WaveformChunkKey, WaveformPeak, WavWriter};
|
pub use io::{load_midi_file, AudioFile, WaveformChunk, WaveformChunkKey, WaveformPeak, WavWriter};
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,17 @@
|
||||||
//! Positions are stored in **beats** throughout the project; `TempoMap` converts
|
//! Positions are stored in **beats** throughout the project; `TempoMap` converts
|
||||||
//! between beats and seconds at render / scheduling time.
|
//! between beats and seconds at render / scheduling time.
|
||||||
//!
|
//!
|
||||||
|
//! # Interpolation
|
||||||
|
//! Each `TempoEntry` has an `interpolation` field that controls how the BPM
|
||||||
|
//! changes between that entry and the next:
|
||||||
|
//! - `Step`: BPM is constant from this entry's beat until the next entry. Instant change.
|
||||||
|
//! - `Linear`: BPM linearly interpolates from this entry's BPM to the next entry's BPM
|
||||||
|
//! over the beat range. The seconds calculation uses the exact integral:
|
||||||
|
//! `Δt = (60 / slope) * ln(bpm_end / bpm_start)` where slope = (bpm_end - bpm_start) / span_beats.
|
||||||
|
//!
|
||||||
//! # Format
|
//! # Format
|
||||||
//! `entries` is a sorted `Vec<TempoEntry>` where each entry marks where a new
|
//! `entries` is a sorted `Vec<TempoEntry>` where the first entry must always
|
||||||
//! constant BPM segment begins. Step interpolation is used: BPM is constant
|
//! have `beat == 0.0`.
|
||||||
//! from `entry.beat` until the next entry. The first entry must always have
|
|
||||||
//! `beat == 0.0`.
|
|
||||||
//!
|
//!
|
||||||
//! # Sequential-access optimisation
|
//! # Sequential-access optimisation
|
||||||
//! An `AtomicUsize` caches the index of the last segment visited by
|
//! An `AtomicUsize` caches the index of the last segment visited by
|
||||||
|
|
@ -19,21 +25,35 @@ use serde::{Deserialize, Serialize};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use crate::time::{Beats, Seconds};
|
use crate::time::{Beats, Seconds};
|
||||||
|
|
||||||
/// A single tempo segment: from `beat` onwards the tempo is `bpm`.
|
/// How the BPM transitions from one `TempoEntry` to the next.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub enum TempoInterpolation {
|
||||||
|
/// BPM stays constant from this entry's beat until the next entry (instant change).
|
||||||
|
#[default]
|
||||||
|
Step,
|
||||||
|
/// BPM linearly interpolates from this entry's BPM to the next entry's BPM
|
||||||
|
/// over the beat span between the two entries.
|
||||||
|
Linear,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single tempo segment: from `beat` onwards the tempo changes according to `interpolation`.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct TempoEntry {
|
pub struct TempoEntry {
|
||||||
/// Start of this tempo segment in beats (quarter-note beats).
|
/// Start of this tempo segment in beats (quarter-note beats).
|
||||||
pub beat: f64,
|
pub beat: f64,
|
||||||
/// Tempo for this segment in beats per minute.
|
/// Tempo at the start of this segment in beats per minute.
|
||||||
pub bpm: f64,
|
pub bpm: f64,
|
||||||
/// Cumulative seconds elapsed at the start of this segment.
|
/// Cumulative seconds elapsed at the start of this segment.
|
||||||
/// **Derived** — not serialised; call [`TempoMap::rebuild_seconds`] after any
|
/// **Derived** — not serialised; call [`TempoMap::rebuild_seconds`] after any
|
||||||
/// mutation or after deserialization.
|
/// mutation or after deserialization.
|
||||||
#[serde(skip, default)]
|
#[serde(skip, default)]
|
||||||
pub seconds: f64,
|
pub seconds: f64,
|
||||||
|
/// How the BPM transitions from this entry to the next.
|
||||||
|
#[serde(default)]
|
||||||
|
pub interpolation: TempoInterpolation,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A piecewise-constant tempo map used to convert between beats and seconds.
|
/// A piecewise tempo map used to convert between beats and seconds.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct TempoMap {
|
pub struct TempoMap {
|
||||||
/// Sorted list of tempo segments. Must always have at least one entry at beat 0.
|
/// Sorted list of tempo segments. Must always have at least one entry at beat 0.
|
||||||
|
|
@ -58,11 +78,49 @@ impl Default for TempoMap {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Seconds elapsed traversing `span_beats` starting at `bpm_start`.
|
||||||
|
/// If `bpm_end` is `Some` (linear segment) and differs from `bpm_start`,
|
||||||
|
/// uses the exact logarithmic integral.
|
||||||
|
#[inline]
|
||||||
|
fn segment_duration(span_beats: f64, bpm_start: f64, bpm_end: Option<f64>) -> f64 {
|
||||||
|
match bpm_end {
|
||||||
|
None => span_beats * 60.0 / bpm_start,
|
||||||
|
Some(b1) if (b1 - bpm_start).abs() < 1e-9 => span_beats * 60.0 / bpm_start,
|
||||||
|
Some(b1) => {
|
||||||
|
// Linear BPM: BPM(b) = bpm_start + slope * (b - b_start)
|
||||||
|
// Δt = ∫₀^span 60 / BPM(b) db = (60/slope) * ln(b1/bpm_start)
|
||||||
|
let slope = (b1 - bpm_start) / span_beats;
|
||||||
|
(60.0 / slope) * (b1 / bpm_start).ln()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beats elapsed given `delta_seconds` starting at `bpm_start`.
|
||||||
|
/// If `bpm_end` is `Some` (linear segment) and differs from `bpm_start`,
|
||||||
|
/// uses the exact exponential inverse.
|
||||||
|
#[inline]
|
||||||
|
fn segment_beats(delta_seconds: f64, span_beats: f64, bpm_start: f64, bpm_end: Option<f64>) -> f64 {
|
||||||
|
match bpm_end {
|
||||||
|
None => delta_seconds * bpm_start / 60.0,
|
||||||
|
Some(b1) if (b1 - bpm_start).abs() < 1e-9 => delta_seconds * bpm_start / 60.0,
|
||||||
|
Some(b1) => {
|
||||||
|
// Inverse of the logarithmic integral:
|
||||||
|
// b = b_start + (bpm_start / slope) * (exp(delta_t * slope / 60) - 1)
|
||||||
|
let slope = (b1 - bpm_start) / span_beats;
|
||||||
|
(bpm_start / slope) * ((delta_seconds * slope / 60.0).exp() - 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TempoMap {
|
impl TempoMap {
|
||||||
/// Create a constant-tempo map.
|
/// Create a constant-tempo map.
|
||||||
pub fn constant(bpm: f64) -> Self {
|
pub fn constant(bpm: f64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
entries: vec![TempoEntry { beat: 0.0, bpm, seconds: 0.0 }],
|
entries: vec![TempoEntry { beat: 0.0, bpm, seconds: 0.0, interpolation: TempoInterpolation::Step }],
|
||||||
last_index: AtomicUsize::new(0),
|
last_index: AtomicUsize::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,20 +130,36 @@ impl TempoMap {
|
||||||
/// after deserialization.
|
/// after deserialization.
|
||||||
pub fn rebuild_seconds(&mut self) {
|
pub fn rebuild_seconds(&mut self) {
|
||||||
let mut cumulative = 0.0_f64;
|
let mut cumulative = 0.0_f64;
|
||||||
for i in 0..self.entries.len() {
|
let n = self.entries.len();
|
||||||
|
for i in 0..n {
|
||||||
self.entries[i].seconds = cumulative;
|
self.entries[i].seconds = cumulative;
|
||||||
if i + 1 < self.entries.len() {
|
if i + 1 < n {
|
||||||
let span_beats = self.entries[i + 1].beat - self.entries[i].beat;
|
let span = self.entries[i + 1].beat - self.entries[i].beat;
|
||||||
cumulative += span_beats * 60.0 / self.entries[i].bpm;
|
let bpm_end = if self.entries[i].interpolation == TempoInterpolation::Linear {
|
||||||
|
Some(self.entries[i + 1].bpm)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
cumulative += segment_duration(span, self.entries[i].bpm, bpm_end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.last_index.store(0, Ordering::Relaxed);
|
self.last_index.store(0, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the BPM active at `beat`.
|
/// Return the instantaneous BPM active at `beat`.
|
||||||
|
/// For linear segments, returns the interpolated value at that beat.
|
||||||
pub fn bpm_at(&self, beat: Beats) -> f64 {
|
pub fn bpm_at(&self, beat: Beats) -> f64 {
|
||||||
|
let n = self.entries.len();
|
||||||
let idx = self.entries.partition_point(|e| e.beat <= beat.0).saturating_sub(1);
|
let idx = self.entries.partition_point(|e| e.beat <= beat.0).saturating_sub(1);
|
||||||
self.entries[idx.max(0)].bpm
|
let idx = idx.min(n - 1);
|
||||||
|
let entry = &self.entries[idx];
|
||||||
|
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
|
||||||
|
let next = &self.entries[idx + 1];
|
||||||
|
let t = (beat.0 - entry.beat) / (next.beat - entry.beat);
|
||||||
|
entry.bpm + (next.bpm - entry.bpm) * t
|
||||||
|
} else {
|
||||||
|
entry.bpm
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert beats to seconds using the tempo map.
|
/// Convert beats to seconds using the tempo map.
|
||||||
|
|
@ -93,33 +167,12 @@ impl TempoMap {
|
||||||
/// Uses the sequential cache: if `beat` is at or after the last cached
|
/// Uses the sequential cache: if `beat` is at or after the last cached
|
||||||
/// segment, the scan starts there instead of from the beginning.
|
/// segment, the scan starts there instead of from the beginning.
|
||||||
pub fn beats_to_seconds(&self, beat: Beats) -> Seconds {
|
pub fn beats_to_seconds(&self, beat: Beats) -> Seconds {
|
||||||
if beat.0 <= 0.0 {
|
Seconds(self.transform(beat.0))
|
||||||
return Seconds::ZERO;
|
|
||||||
}
|
|
||||||
let n = self.entries.len();
|
|
||||||
let cached = self.last_index.load(Ordering::Relaxed).min(n.saturating_sub(1));
|
|
||||||
let start = if beat.0 >= self.entries[cached].beat { cached } else { 0 };
|
|
||||||
|
|
||||||
let mut idx = start;
|
|
||||||
while idx + 1 < n && self.entries[idx + 1].beat <= beat.0 {
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
self.last_index.store(idx, Ordering::Relaxed);
|
|
||||||
|
|
||||||
let entry = &self.entries[idx];
|
|
||||||
Seconds(entry.seconds + (beat.0 - entry.beat) * 60.0 / entry.bpm)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert seconds to beats using binary search on the cached `seconds` offsets.
|
/// Convert seconds to beats using binary search on the cached `seconds` offsets.
|
||||||
pub fn seconds_to_beats(&self, seconds: Seconds) -> Beats {
|
pub fn seconds_to_beats(&self, seconds: Seconds) -> Beats {
|
||||||
if seconds.0 <= 0.0 {
|
Beats(self.inverse_transform(seconds.0))
|
||||||
return Beats::ZERO;
|
|
||||||
}
|
|
||||||
let n = self.entries.len();
|
|
||||||
let idx = self.entries.partition_point(|e| e.seconds <= seconds.0).saturating_sub(1);
|
|
||||||
let idx = idx.min(n - 1);
|
|
||||||
let entry = &self.entries[idx];
|
|
||||||
Beats(entry.beat + (seconds.0 - entry.seconds) * entry.bpm / 60.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global BPM — the BPM of the first entry (at beat 0).
|
/// Global BPM — the BPM of the first entry (at beat 0).
|
||||||
|
|
@ -136,11 +189,7 @@ impl TempoMap {
|
||||||
/// Convert local beats to parent time units (raw `f64`).
|
/// Convert local beats to parent time units (raw `f64`).
|
||||||
///
|
///
|
||||||
/// At the root level the result is absolute seconds. Inside a nested
|
/// At the root level the result is absolute seconds. Inside a nested
|
||||||
/// group the result is the *parent group's* beats — the caller is
|
/// group the result is the *parent group's* beats.
|
||||||
/// responsible for interpreting the units correctly.
|
|
||||||
///
|
|
||||||
/// This is the same arithmetic as `beats_to_seconds` but without the
|
|
||||||
/// `Seconds` newtype so it can be composed across multiple levels.
|
|
||||||
pub fn transform(&self, beat: f64) -> f64 {
|
pub fn transform(&self, beat: f64) -> f64 {
|
||||||
if beat <= 0.0 {
|
if beat <= 0.0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
|
|
@ -153,8 +202,16 @@ impl TempoMap {
|
||||||
idx += 1;
|
idx += 1;
|
||||||
}
|
}
|
||||||
self.last_index.store(idx, Ordering::Relaxed);
|
self.last_index.store(idx, Ordering::Relaxed);
|
||||||
|
|
||||||
let entry = &self.entries[idx];
|
let entry = &self.entries[idx];
|
||||||
entry.seconds + (beat - entry.beat) * 60.0 / entry.bpm
|
let beat_in_seg = beat - entry.beat;
|
||||||
|
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
|
||||||
|
let next = &self.entries[idx + 1];
|
||||||
|
let span = next.beat - entry.beat;
|
||||||
|
entry.seconds + segment_duration(beat_in_seg, entry.bpm, Some(entry.bpm + (next.bpm - entry.bpm) * beat_in_seg / span))
|
||||||
|
} else {
|
||||||
|
entry.seconds + beat_in_seg * 60.0 / entry.bpm
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inverse of [`transform`]: convert parent time units back to local beats.
|
/// Inverse of [`transform`]: convert parent time units back to local beats.
|
||||||
|
|
@ -166,10 +223,17 @@ impl TempoMap {
|
||||||
let idx = self.entries.partition_point(|e| e.seconds <= parent_time).saturating_sub(1);
|
let idx = self.entries.partition_point(|e| e.seconds <= parent_time).saturating_sub(1);
|
||||||
let idx = idx.min(n - 1);
|
let idx = idx.min(n - 1);
|
||||||
let entry = &self.entries[idx];
|
let entry = &self.entries[idx];
|
||||||
entry.beat + (parent_time - entry.seconds) * entry.bpm / 60.0
|
let delta_t = parent_time - entry.seconds;
|
||||||
|
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
|
||||||
|
let next = &self.entries[idx + 1];
|
||||||
|
let span = next.beat - entry.beat;
|
||||||
|
entry.beat + segment_beats(delta_t, span, entry.bpm, Some(next.bpm))
|
||||||
|
} else {
|
||||||
|
entry.beat + delta_t * entry.bpm / 60.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `TempoMap` from a list of `(beat, bpm)` keyframes.
|
/// Build a `TempoMap` from a list of `(beat, bpm)` keyframes (step interpolation).
|
||||||
/// Always inserts a beat-0 entry using the first keyframe's BPM (or 120.0 if empty).
|
/// Always inserts a beat-0 entry using the first keyframe's BPM (or 120.0 if empty).
|
||||||
pub fn from_keyframes(keyframes: &[(f64, f64)]) -> Self {
|
pub fn from_keyframes(keyframes: &[(f64, f64)]) -> Self {
|
||||||
if keyframes.is_empty() {
|
if keyframes.is_empty() {
|
||||||
|
|
@ -177,11 +241,11 @@ impl TempoMap {
|
||||||
}
|
}
|
||||||
let mut entries: Vec<TempoEntry> = keyframes
|
let mut entries: Vec<TempoEntry> = keyframes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&(beat, bpm)| TempoEntry { beat, bpm, seconds: 0.0 })
|
.map(|&(beat, bpm)| TempoEntry { beat, bpm, seconds: 0.0, interpolation: TempoInterpolation::Step })
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap());
|
entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap());
|
||||||
if entries[0].beat > 0.0 {
|
if entries[0].beat > 0.0 {
|
||||||
entries.insert(0, TempoEntry { beat: 0.0, bpm: entries[0].bpm, seconds: 0.0 });
|
entries.insert(0, TempoEntry { beat: 0.0, bpm: entries[0].bpm, seconds: 0.0, interpolation: TempoInterpolation::Step });
|
||||||
}
|
}
|
||||||
let mut map = Self { entries, last_index: AtomicUsize::new(0) };
|
let mut map = Self { entries, last_index: AtomicUsize::new(0) };
|
||||||
map.rebuild_seconds();
|
map.rebuild_seconds();
|
||||||
|
|
@ -190,14 +254,6 @@ impl TempoMap {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert local beats through a stack of tempo maps to absolute seconds.
|
/// Convert local beats through a stack of tempo maps to absolute seconds.
|
||||||
///
|
|
||||||
/// `stack[0]` is the outermost map (root/master); `stack[last]` is the
|
|
||||||
/// innermost (deepest group). Conversion applies maps from innermost to
|
|
||||||
/// outermost — each map's output is the input to the next outer map.
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// clip_beats → [stack[last]] → … → [stack[0]] → absolute seconds
|
|
||||||
/// ```
|
|
||||||
pub fn beats_to_seconds_stack(beat: f64, stack: &[&TempoMap]) -> f64 {
|
pub fn beats_to_seconds_stack(beat: f64, stack: &[&TempoMap]) -> f64 {
|
||||||
let mut t = beat;
|
let mut t = beat;
|
||||||
for tm in stack.iter().rev() {
|
for tm in stack.iter().rev() {
|
||||||
|
|
@ -227,26 +283,40 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn variable_tempo() {
|
fn variable_tempo_step() {
|
||||||
let m = TempoMap::from_keyframes(&[(0.0, 120.0), (4.0, 60.0)]);
|
let m = TempoMap::from_keyframes(&[(0.0, 120.0), (4.0, 60.0)]);
|
||||||
assert!((m.beats_to_seconds(Beats(4.0)).0 - 2.0).abs() < 1e-9);
|
// Beat 0-4: 120 BPM → 4 beats = 2 seconds
|
||||||
|
assert!((m.beats_to_seconds(Beats(4.0)).0 - 2.0).abs() < 1e-9, "got {}", m.beats_to_seconds(Beats(4.0)).0);
|
||||||
|
// Beat 4-5: 60 BPM → 1 beat = 1 second
|
||||||
assert!((m.beats_to_seconds(Beats(5.0)).0 - 3.0).abs() < 1e-9);
|
assert!((m.beats_to_seconds(Beats(5.0)).0 - 3.0).abs() < 1e-9);
|
||||||
assert!((m.seconds_to_beats(Seconds(3.0)).0 - 5.0).abs() < 1e-9);
|
assert!((m.seconds_to_beats(Seconds(3.0)).0 - 5.0).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linear_interpolation_round_trip() {
|
||||||
|
// 120→240 BPM over 4 beats: slope = (240-120)/4 = 30 BPM/beat
|
||||||
|
// Δt = (60/30) * ln(240/120) = 2 * ln(2) ≈ 1.386s for beats 0-4
|
||||||
|
let mut m = TempoMap::constant(120.0);
|
||||||
|
m.entries.push(TempoEntry { beat: 4.0, bpm: 240.0, seconds: 0.0, interpolation: TempoInterpolation::Step });
|
||||||
|
m.entries[0].interpolation = TempoInterpolation::Linear;
|
||||||
|
m.rebuild_seconds();
|
||||||
|
|
||||||
|
let expected = 2.0 * std::f64::consts::LN_2;
|
||||||
|
let got = m.beats_to_seconds(Beats(4.0)).0;
|
||||||
|
assert!((got - expected).abs() < 1e-9, "got {got}, expected {expected}");
|
||||||
|
|
||||||
|
// Round-trip
|
||||||
|
let beats_back = m.seconds_to_beats(Seconds(expected)).0;
|
||||||
|
assert!((beats_back - 4.0).abs() < 1e-9, "round-trip got {beats_back}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stack_composition() {
|
fn stack_composition() {
|
||||||
// Root: 120 BPM (1 beat = 0.5s)
|
|
||||||
// Group: 60 BPM (1 local beat = 1 "parent beat" worth of time)
|
|
||||||
// Clip at local beat 2.0 → 2 parent beats → 1.0s absolute
|
|
||||||
let root = TempoMap::constant(120.0);
|
let root = TempoMap::constant(120.0);
|
||||||
let group = TempoMap::constant(60.0);
|
let group = TempoMap::constant(60.0);
|
||||||
let stack: Vec<&TempoMap> = vec![&root, &group];
|
let stack: Vec<&TempoMap> = vec![&root, &group];
|
||||||
let secs = beats_to_seconds_stack(2.0, &stack);
|
let secs = beats_to_seconds_stack(2.0, &stack);
|
||||||
// group.transform(2.0) = 2.0*60/60 = 2.0 parent_beats
|
|
||||||
// root.transform(2.0) = 2.0*60/120 = 1.0s
|
|
||||||
assert!((secs - 1.0).abs() < 1e-9, "got {secs}");
|
assert!((secs - 1.0).abs() < 1e-9, "got {secs}");
|
||||||
|
|
||||||
let beats = seconds_to_beats_stack(1.0, &stack);
|
let beats = seconds_to_beats_stack(1.0, &stack);
|
||||||
assert!((beats - 2.0).abs() < 1e-9, "got {beats}");
|
assert!((beats - 2.0).abs() < 1e-9, "got {beats}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ use crate::tempo_map::TempoMap;
|
||||||
pub struct ChangeBpmAction {
|
pub struct ChangeBpmAction {
|
||||||
old_map: TempoMap,
|
old_map: TempoMap,
|
||||||
new_map: TempoMap,
|
new_map: TempoMap,
|
||||||
time_sig: (u32, u32),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChangeBpmAction {
|
impl ChangeBpmAction {
|
||||||
|
|
@ -22,14 +21,13 @@ impl ChangeBpmAction {
|
||||||
let old_map = document.tempo_map().clone();
|
let old_map = document.tempo_map().clone();
|
||||||
let mut new_map = old_map.clone();
|
let mut new_map = old_map.clone();
|
||||||
new_map.set_global_bpm(new_bpm);
|
new_map.set_global_bpm(new_bpm);
|
||||||
let time_sig = (document.time_signature.numerator, document.time_signature.denominator);
|
Self { old_map, new_map }
|
||||||
Self { old_map, new_map, time_sig }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build from explicit old/new maps (used when the map already changed in-place,
|
/// Build from explicit old/new maps (used when the map already changed in-place,
|
||||||
/// e.g., after live drag preview; the caller provides start and end states).
|
/// e.g., after live drag preview; the caller provides start and end states).
|
||||||
pub fn from_maps(old_map: TempoMap, new_map: TempoMap, time_sig: (u32, u32)) -> Self {
|
pub fn from_maps(old_map: TempoMap, new_map: TempoMap) -> Self {
|
||||||
Self { old_map, new_map, time_sig }
|
Self { old_map, new_map }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_bpm(&self) -> f64 { self.new_map.global_bpm() }
|
pub fn new_bpm(&self) -> f64 { self.new_map.global_bpm() }
|
||||||
|
|
@ -60,7 +58,7 @@ impl Action for ChangeBpmAction {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
controller.set_tempo(self.new_map.global_bpm() as f32, self.time_sig);
|
controller.set_tempo_map(self.new_map.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +71,7 @@ impl Action for ChangeBpmAction {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
controller.set_tempo(self.old_map.global_bpm() as f32, self.time_sig);
|
controller.set_tempo_map(self.old_map.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -779,7 +779,7 @@ impl GroupLayer {
|
||||||
"Master",
|
"Master",
|
||||||
),
|
),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
expanded: true,
|
expanded: false, // collapsed so it always renders a visible header row
|
||||||
tempo_map: Some(TempoMap::constant(bpm)),
|
tempo_map: Some(TempoMap::constant(bpm)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,7 @@ impl From<MenuAction> for AppAction {
|
||||||
MenuAction::AddTestClip => Self::AddTestClip,
|
MenuAction::AddTestClip => Self::AddTestClip,
|
||||||
MenuAction::DeleteLayer => Self::DeleteLayer,
|
MenuAction::DeleteLayer => Self::DeleteLayer,
|
||||||
MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility,
|
MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility,
|
||||||
|
MenuAction::ShowMasterTrack => Self::ToggleLayerVisibility, // not directly mappable
|
||||||
MenuAction::NewKeyframe => Self::NewKeyframe,
|
MenuAction::NewKeyframe => Self::NewKeyframe,
|
||||||
MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe,
|
MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe,
|
||||||
MenuAction::DeleteFrame => Self::DeleteFrame,
|
MenuAction::DeleteFrame => Self::DeleteFrame,
|
||||||
|
|
|
||||||
|
|
@ -1538,6 +1538,21 @@ impl EditorApp {
|
||||||
fn sync_audio_layers_to_backend(&mut self) {
|
fn sync_audio_layers_to_backend(&mut self) {
|
||||||
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
|
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
|
||||||
|
|
||||||
|
// Ensure the master layer has a backend group track.
|
||||||
|
let master_layer_id = self.action_executor.document().master_layer.layer.id;
|
||||||
|
if !self.layer_to_track_map.contains_key(&master_layer_id) {
|
||||||
|
if let Some(ref controller_arc) = self.audio_controller {
|
||||||
|
let track_id_result = {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
controller.create_group_track_sync("[Master]".to_string(), None)
|
||||||
|
};
|
||||||
|
if let Ok(track_id) = track_id_result {
|
||||||
|
self.layer_to_track_map.insert(master_layer_id, track_id);
|
||||||
|
println!("✅ Created master bus track (TrackId: {})", track_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Collect audio layers from root and inside vector clips
|
// Collect audio layers from root and inside vector clips
|
||||||
// Each entry: (layer_id, layer_name, audio_type, parent_clip_id)
|
// Each entry: (layer_id, layer_name, audio_type, parent_clip_id)
|
||||||
let mut audio_layers_to_sync: Vec<(uuid::Uuid, String, AudioLayerType, Option<uuid::Uuid>)> = Vec::new();
|
let mut audio_layers_to_sync: Vec<(uuid::Uuid, String, AudioLayerType, Option<uuid::Uuid>)> = Vec::new();
|
||||||
|
|
@ -1707,6 +1722,17 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push subtrack graph for the master bus (all root-level tracks as its inputs).
|
||||||
|
let master_layer_id = self.action_executor.document().master_layer.layer.id;
|
||||||
|
if let Some(&master_track_id) = self.layer_to_track_map.get(&master_layer_id) {
|
||||||
|
let root_children = self.action_executor.document().root.children.clone();
|
||||||
|
let master_subtracks = self.build_subtrack_list_for_group(&root_children);
|
||||||
|
if let Some(ref controller_arc) = self.audio_controller {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
controller.set_metatrack_subtrack_graph(master_track_id, master_subtracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the ordered subtrack list for a group layer's direct children.
|
/// Build the ordered subtrack list for a group layer's direct children.
|
||||||
|
|
@ -3483,6 +3509,14 @@ impl EditorApp {
|
||||||
println!("Menu: Toggle Layer Visibility");
|
println!("Menu: Toggle Layer Visibility");
|
||||||
// TODO: Implement toggle layer visibility
|
// TODO: Implement toggle layer visibility
|
||||||
}
|
}
|
||||||
|
MenuAction::ShowMasterTrack => {
|
||||||
|
// Toggle show_master_track on all Timeline pane instances
|
||||||
|
for pane in self.pane_instances.values_mut() {
|
||||||
|
if let panes::PaneInstance::Timeline(t) = pane {
|
||||||
|
t.show_master_track = !t.show_master_track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Timeline menu
|
// Timeline menu
|
||||||
MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => {
|
MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => {
|
||||||
|
|
@ -5623,10 +5657,14 @@ impl eframe::App for EditorApp {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Checked actions show "✔ Label"; hidden actions are not rendered at all
|
// Checked actions show "✔ Label"; hidden actions are not rendered at all
|
||||||
let checked: &[crate::menu::MenuAction] = if self.count_in_enabled && self.metronome_enabled {
|
let master_track_shown = self.pane_instances.values().any(|p| {
|
||||||
&[crate::menu::MenuAction::ToggleCountIn]
|
if let panes::PaneInstance::Timeline(t) = p { t.show_master_track } else { false }
|
||||||
} else {
|
});
|
||||||
&[]
|
let checked: &[crate::menu::MenuAction] = match (self.count_in_enabled && self.metronome_enabled, master_track_shown) {
|
||||||
|
(true, true) => &[crate::menu::MenuAction::ToggleCountIn, crate::menu::MenuAction::ShowMasterTrack],
|
||||||
|
(true, false) => &[crate::menu::MenuAction::ToggleCountIn],
|
||||||
|
(false, true) => &[crate::menu::MenuAction::ShowMasterTrack],
|
||||||
|
(false, false) => &[],
|
||||||
};
|
};
|
||||||
let hidden: &[crate::menu::MenuAction] = if timeline_is_measures && self.metronome_enabled {
|
let hidden: &[crate::menu::MenuAction] = if timeline_is_measures && self.metronome_enabled {
|
||||||
&[]
|
&[]
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,7 @@ pub enum MenuAction {
|
||||||
AddTestClip, // For testing: adds a test clip to the asset library
|
AddTestClip, // For testing: adds a test clip to the asset library
|
||||||
DeleteLayer,
|
DeleteLayer,
|
||||||
ToggleLayerVisibility,
|
ToggleLayerVisibility,
|
||||||
|
ShowMasterTrack,
|
||||||
|
|
||||||
// Timeline menu
|
// Timeline menu
|
||||||
NewKeyframe,
|
NewKeyframe,
|
||||||
|
|
@ -412,6 +413,7 @@ impl MenuItemDef {
|
||||||
const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip to Library", action: MenuAction::AddTestClip, shortcut: None };
|
const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip to Library", action: MenuAction::AddTestClip, shortcut: None };
|
||||||
const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None };
|
const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None };
|
||||||
const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None };
|
const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None };
|
||||||
|
const SHOW_MASTER_TRACK: Self = Self { label: "Show Master Track", action: MenuAction::ShowMasterTrack, shortcut: None };
|
||||||
|
|
||||||
// Timeline menu items
|
// Timeline menu items
|
||||||
const NEW_KEYFRAME: Self = Self { label: "New Keyframe", action: MenuAction::NewKeyframe, shortcut: Some(Shortcut::new(ShortcutKey::K, NO_CTRL, NO_SHIFT, NO_ALT)) };
|
const NEW_KEYFRAME: Self = Self { label: "New Keyframe", action: MenuAction::NewKeyframe, shortcut: Some(Shortcut::new(ShortcutKey::K, NO_CTRL, NO_SHIFT, NO_ALT)) };
|
||||||
|
|
@ -533,6 +535,8 @@ impl MenuItemDef {
|
||||||
MenuDef::Separator,
|
MenuDef::Separator,
|
||||||
MenuDef::Item(&Self::DELETE_LAYER),
|
MenuDef::Item(&Self::DELETE_LAYER),
|
||||||
MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY),
|
MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY),
|
||||||
|
MenuDef::Separator,
|
||||||
|
MenuDef::Item(&Self::SHOW_MASTER_TRACK),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Timeline menu
|
// Timeline menu
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,9 @@ pub struct TimelinePane {
|
||||||
|
|
||||||
/// BPM value captured when a drag/focus-in starts (for single-undo-action on commit)
|
/// BPM value captured when a drag/focus-in starts (for single-undo-action on commit)
|
||||||
bpm_drag_start: Option<f64>,
|
bpm_drag_start: Option<f64>,
|
||||||
|
|
||||||
|
/// Whether to show the master track row in the timeline
|
||||||
|
pub show_master_track: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deferred recording start created during count-in pre-roll
|
/// Deferred recording start created during count-in pre-roll
|
||||||
|
|
@ -355,12 +358,25 @@ fn flatten_layer<'a>(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached automation lane data for timeline rendering
|
/// Cached automation lane data for timeline rendering
|
||||||
|
/// Distinguishes special master-track lanes from regular AutomationInput node lanes.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum AutomationLaneKind {
|
||||||
|
/// Standard AutomationInput node in the audio graph
|
||||||
|
Regular,
|
||||||
|
/// Master track tempo lane — x-axis is beats, y-axis is BPM; purple accent
|
||||||
|
MasterTempo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sentinel node_id value for the master tempo virtual lane (never used by audio graph nodes)
|
||||||
|
const MASTER_TEMPO_NODE_ID: u32 = u32::MAX;
|
||||||
|
|
||||||
struct AutomationLaneInfo {
|
struct AutomationLaneInfo {
|
||||||
node_id: u32,
|
node_id: u32,
|
||||||
name: String,
|
name: String,
|
||||||
keyframes: Vec<crate::curve_editor::CurvePoint>,
|
keyframes: Vec<crate::curve_editor::CurvePoint>,
|
||||||
value_min: f32,
|
value_min: f32,
|
||||||
value_max: f32,
|
value_max: f32,
|
||||||
|
kind: AutomationLaneKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Data collected during render_layers for a single automation lane, used to call
|
/// Data collected during render_layers for a single automation lane, used to call
|
||||||
|
|
@ -374,6 +390,7 @@ struct AutomationLaneRender {
|
||||||
value_max: f32,
|
value_max: f32,
|
||||||
accent_color: egui::Color32,
|
accent_color: egui::Color32,
|
||||||
playback_time: f64,
|
playback_time: f64,
|
||||||
|
kind: AutomationLaneKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pending automation keyframe edit action from curve lane interaction
|
/// Pending automation keyframe edit action from curve lane interaction
|
||||||
|
|
@ -689,11 +706,12 @@ 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,
|
automation_cache_bpm: 0.0,
|
||||||
metronome_icon: None,
|
metronome_icon: None,
|
||||||
pending_recording_start: None,
|
pending_recording_start: None,
|
||||||
renaming_layer: None,
|
renaming_layer: None,
|
||||||
bpm_drag_start: None,
|
bpm_drag_start: None,
|
||||||
|
show_master_track: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -755,6 +773,8 @@ impl TimelinePane {
|
||||||
layer_id: uuid::Uuid,
|
layer_id: uuid::Uuid,
|
||||||
controller: &mut daw_backend::EngineController,
|
controller: &mut daw_backend::EngineController,
|
||||||
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, daw_backend::TrackId>,
|
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, daw_backend::TrackId>,
|
||||||
|
master_layer_id: uuid::Uuid,
|
||||||
|
tempo_map: &daw_backend::TempoMap,
|
||||||
) {
|
) {
|
||||||
let track_id = match layer_to_track_map.get(&layer_id) {
|
let track_id = match layer_to_track_map.get(&layer_id) {
|
||||||
Some(t) => *t,
|
Some(t) => *t,
|
||||||
|
|
@ -785,7 +805,7 @@ impl TimelinePane {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|k| crate::curve_editor::CurvePoint {
|
.map(|k| crate::curve_editor::CurvePoint {
|
||||||
time: k.time,
|
time: k.time, // beats (backend stores beats; curve editor x-axis is beats)
|
||||||
value: k.value,
|
value: k.value,
|
||||||
interpolation: match k.interpolation.as_str() {
|
interpolation: match k.interpolation.as_str() {
|
||||||
"bezier" => crate::curve_editor::CurveInterpolation::Bezier,
|
"bezier" => crate::curve_editor::CurveInterpolation::Bezier,
|
||||||
|
|
@ -800,8 +820,33 @@ impl TimelinePane {
|
||||||
let (value_min, value_max) = controller
|
let (value_min, value_max) = controller
|
||||||
.query_automation_range(track_id, node.id)
|
.query_automation_range(track_id, node.id)
|
||||||
.unwrap_or((-1.0, 1.0));
|
.unwrap_or((-1.0, 1.0));
|
||||||
lanes.push(AutomationLaneInfo { node_id: node.id, name, keyframes, value_min, value_max });
|
lanes.push(AutomationLaneInfo { node_id: node.id, name, keyframes, value_min, value_max, kind: AutomationLaneKind::Regular });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For the master layer, append the special Tempo virtual lane after the regular lanes.
|
||||||
|
if layer_id == master_layer_id {
|
||||||
|
let tempo_keyframes = tempo_map.entries.iter().map(|entry| {
|
||||||
|
crate::curve_editor::CurvePoint {
|
||||||
|
time: entry.beat, // beats (not seconds); beat-based closures in render_curve_lane
|
||||||
|
value: entry.bpm as f32,
|
||||||
|
interpolation: match entry.interpolation {
|
||||||
|
daw_backend::TempoInterpolation::Linear => crate::curve_editor::CurveInterpolation::Linear,
|
||||||
|
daw_backend::TempoInterpolation::Step => crate::curve_editor::CurveInterpolation::Step,
|
||||||
|
},
|
||||||
|
ease_out: (0.0, 0.0),
|
||||||
|
ease_in: (0.0, 0.0),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
lanes.push(AutomationLaneInfo {
|
||||||
|
node_id: MASTER_TEMPO_NODE_ID,
|
||||||
|
name: "Tempo".to_string(),
|
||||||
|
keyframes: tempo_keyframes,
|
||||||
|
value_min: 20.0,
|
||||||
|
value_max: 300.0,
|
||||||
|
kind: AutomationLaneKind::MasterTempo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
self.automation_cache.insert(layer_id, lanes);
|
self.automation_cache.insert(layer_id, lanes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1528,21 +1573,23 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TimelineMode::Measures => {
|
TimelineMode::Measures => {
|
||||||
let beats_per_second = tempo_map.bpm_at(daw_backend::Beats(0.0)) / 60.0;
|
|
||||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
|
|
||||||
let bpm_count = time_sig.numerator;
|
let bpm_count = time_sig.numerator;
|
||||||
let px_per_beat = beat_dur as f32 * self.pixels_per_second;
|
|
||||||
|
|
||||||
let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
|
// Find beat range from viewport using the actual tempo map
|
||||||
let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64;
|
let viewport_end_secs = self.viewport_start_time + (rect.width() / self.pixels_per_second) as f64;
|
||||||
|
let start_beat = tempo_map.inverse_transform(self.viewport_start_time.max(0.0)).floor() as i64;
|
||||||
|
let end_beat = tempo_map.inverse_transform(viewport_end_secs).ceil() as i64 + 1;
|
||||||
|
|
||||||
// Adaptive: how often to label measures
|
// Estimate px_per_beat for adaptive labeling (sample two adjacent beats near the start)
|
||||||
|
let ref_beat = start_beat.max(0) as f64;
|
||||||
|
let px_per_beat = (self.time_to_x(tempo_map.transform(ref_beat + 1.0))
|
||||||
|
- self.time_to_x(tempo_map.transform(ref_beat))).abs();
|
||||||
let measure_px = px_per_beat * bpm_count as f32;
|
let measure_px = px_per_beat * bpm_count as f32;
|
||||||
let label_every = if measure_px > 60.0 { 1u32 } else if measure_px > 20.0 { 4 } else { 16 };
|
let label_every = if measure_px > 60.0 { 1u32 } else if measure_px > 20.0 { 4 } else { 16 };
|
||||||
|
|
||||||
for beat_idx in start_beat..=end_beat {
|
for beat_idx in start_beat..=end_beat {
|
||||||
if beat_idx < 0 { continue; }
|
if beat_idx < 0 { continue; }
|
||||||
let x = self.time_to_x(beat_idx as f64 / beats_per_second);
|
let x = self.time_to_x(tempo_map.transform(beat_idx as f64));
|
||||||
if x < 0.0 || x > rect.width() { continue; }
|
if x < 0.0 || x > rect.width() { continue; }
|
||||||
|
|
||||||
let beat_in_measure = (beat_idx as u32) % bpm_count;
|
let beat_in_measure = (beat_idx as u32) % bpm_count;
|
||||||
|
|
@ -1673,7 +1720,7 @@ impl TimelinePane {
|
||||||
theme: &crate::theme::Theme,
|
theme: &crate::theme::Theme,
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
faded: bool,
|
faded: bool,
|
||||||
display_bpm: Option<f64>,
|
tempo_map: &daw_backend::TempoMap,
|
||||||
) {
|
) {
|
||||||
let clip_height = clip_rect.height();
|
let clip_height = clip_rect.height();
|
||||||
let note_height = clip_height / 12.0; // 12 semitones per octave
|
let note_height = clip_height / 12.0; // 12 semitones per octave
|
||||||
|
|
@ -1682,8 +1729,7 @@ impl TimelinePane {
|
||||||
let note_style = theme.style(".timeline-midi-note", ctx);
|
let note_style = theme.style(".timeline-midi-note", ctx);
|
||||||
let note_color = note_style.background_color().unwrap_or(egui::Color32::BLACK);
|
let note_color = note_style.background_color().unwrap_or(egui::Color32::BLACK);
|
||||||
|
|
||||||
// `timestamp` is in beats; convert to display-seconds using the current (preview) BPM.
|
// `timestamp` is in beats; convert to display-seconds using the full tempo map.
|
||||||
let tempo_map = daw_backend::TempoMap::constant(display_bpm.unwrap_or(120.0));
|
|
||||||
let event_display_time = |ev: &daw_backend::audio::midi::MidiEvent| -> f64 {
|
let event_display_time = |ev: &daw_backend::audio::midi::MidiEvent| -> f64 {
|
||||||
tempo_map.transform(ev.timestamp.beats_to_f64())
|
tempo_map.transform(ev.timestamp.beats_to_f64())
|
||||||
};
|
};
|
||||||
|
|
@ -1788,7 +1834,7 @@ impl TimelinePane {
|
||||||
active_layer_id: &Option<uuid::Uuid>,
|
active_layer_id: &Option<uuid::Uuid>,
|
||||||
focus: &lightningbeam_core::selection::FocusSelection,
|
focus: &lightningbeam_core::selection::FocusSelection,
|
||||||
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||||
_document: &lightningbeam_core::document::Document,
|
document: &lightningbeam_core::document::Document,
|
||||||
context_layers: &[&lightningbeam_core::layer::AnyLayer],
|
context_layers: &[&lightningbeam_core::layer::AnyLayer],
|
||||||
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>,
|
||||||
|
|
@ -2106,7 +2152,8 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
Some((nid, kfs)) => {
|
Some((nid, kfs)) => {
|
||||||
// Multiple keyframes — slider is read-only; show the curve value at playback pos
|
// Multiple keyframes — slider is read-only; show the curve value at playback pos
|
||||||
let v = crate::curve_editor::evaluate_curve(kfs, playback_time);
|
let playback_beats = document.tempo_map().inverse_transform(playback_time);
|
||||||
|
let v = crate::curve_editor::evaluate_curve(kfs, playback_beats);
|
||||||
(Some(*nid), Some(v as f64), true)
|
(Some(*nid), Some(v as f64), true)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -2386,13 +2433,14 @@ impl TimelinePane {
|
||||||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Automation expand/collapse button for Audio/MIDI layers
|
// Automation expand/collapse button for Audio/MIDI layers and the master layer
|
||||||
|
let is_master_layer = matches!(row, TimelineRow::CollapsedGroup { group: g, .. } if g.layer.id == document.master_layer.layer.id);
|
||||||
let is_audio_or_midi = matches!(
|
let is_audio_or_midi = matches!(
|
||||||
row,
|
row,
|
||||||
TimelineRow::Normal(AnyLayer::Audio(_))
|
TimelineRow::Normal(AnyLayer::Audio(_))
|
||||||
| TimelineRow::GroupChild { child: AnyLayer::Audio(_), .. }
|
| TimelineRow::GroupChild { child: AnyLayer::Audio(_), .. }
|
||||||
);
|
);
|
||||||
if is_audio_or_midi {
|
if is_audio_or_midi || is_master_layer {
|
||||||
let btn_rect = egui::Rect::from_min_size(
|
let btn_rect = egui::Rect::from_min_size(
|
||||||
egui::pos2(rect.min.x + 4.0, y + LAYER_HEIGHT - 18.0),
|
egui::pos2(rect.min.x + 4.0, y + LAYER_HEIGHT - 18.0),
|
||||||
egui::vec2(16.0, 14.0),
|
egui::vec2(16.0, 14.0),
|
||||||
|
|
@ -2735,13 +2783,14 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TimelineMode::Measures => {
|
TimelineMode::Measures => {
|
||||||
let beats_per_second = document.tempo_map().bpm_at(daw_backend::Beats(0.0)) / 60.0;
|
let tm = document.tempo_map();
|
||||||
let bpm_count = document.time_signature.numerator;
|
let bpm_count = document.time_signature.numerator;
|
||||||
let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
|
let viewport_end_secs = self.viewport_start_time + (rect.width() / self.pixels_per_second) as f64;
|
||||||
let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64;
|
let start_beat = tm.inverse_transform(self.viewport_start_time.max(0.0)).floor() as i64;
|
||||||
|
let end_beat = tm.inverse_transform(viewport_end_secs).ceil() as i64 + 1;
|
||||||
for beat_idx in start_beat..=end_beat {
|
for beat_idx in start_beat..=end_beat {
|
||||||
if beat_idx < 0 { continue; }
|
if beat_idx < 0 { continue; }
|
||||||
let x = self.time_to_x(beat_idx as f64 / beats_per_second);
|
let x = self.time_to_x(tm.transform(beat_idx as f64));
|
||||||
if x < 0.0 || x > rect.width() { continue; }
|
if x < 0.0 || x > rect.width() { continue; }
|
||||||
let is_measure_boundary = (beat_idx as u32) % bpm_count == 0;
|
let is_measure_boundary = (beat_idx as u32) % bpm_count == 0;
|
||||||
let gray = if is_measure_boundary { 45 } else { 25 };
|
let gray = if is_measure_boundary { 45 } else { 25 };
|
||||||
|
|
@ -3084,6 +3133,39 @@ impl TimelinePane {
|
||||||
],
|
],
|
||||||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Collect automation lane renders for collapsed groups (e.g. master track)
|
||||||
|
if self.automation_expanded.contains(&row_layer_id) {
|
||||||
|
if let Some(lanes) = self.automation_cache.get(&row_layer_id) {
|
||||||
|
let group_color = theme.bg_color(&["#timeline", ".layer-type-group"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180));
|
||||||
|
for (lane_idx, lane) in lanes.iter().enumerate() {
|
||||||
|
let lane_top = y + LAYER_HEIGHT + lane_idx as f32 * AUTOMATION_LANE_HEIGHT;
|
||||||
|
if lane_top + AUTOMATION_LANE_HEIGHT < rect.min.y || lane_top > rect.max.y {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let lane_rect = egui::Rect::from_min_size(
|
||||||
|
egui::pos2(rect.min.x, lane_top),
|
||||||
|
egui::vec2(rect.width(), AUTOMATION_LANE_HEIGHT),
|
||||||
|
);
|
||||||
|
let lane_accent = match lane.kind {
|
||||||
|
AutomationLaneKind::MasterTempo => egui::Color32::from_rgb(160, 80, 220),
|
||||||
|
AutomationLaneKind::Regular => group_color,
|
||||||
|
};
|
||||||
|
pending_lane_renders.push(AutomationLaneRender {
|
||||||
|
layer_id: row_layer_id,
|
||||||
|
node_id: lane.node_id,
|
||||||
|
lane_rect,
|
||||||
|
keyframes: lane.keyframes.clone(),
|
||||||
|
value_min: lane.value_min,
|
||||||
|
value_max: lane.value_max,
|
||||||
|
accent_color: lane_accent,
|
||||||
|
playback_time,
|
||||||
|
kind: lane.kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
continue; // Skip normal clip rendering for collapsed groups
|
continue; // Skip normal clip rendering for collapsed groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3477,11 +3559,6 @@ impl TimelinePane {
|
||||||
let iter_duration = iter_end - iter_start;
|
let iter_duration = iter_end - iter_start;
|
||||||
if iter_duration <= 0.0 { continue; }
|
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(
|
Self::render_midi_piano_roll(
|
||||||
&painter,
|
&painter,
|
||||||
clip_rect,
|
clip_rect,
|
||||||
|
|
@ -3495,15 +3572,10 @@ impl TimelinePane {
|
||||||
theme,
|
theme,
|
||||||
ui.ctx(),
|
ui.ctx(),
|
||||||
si != 0, // fade non-content iterations
|
si != 0, // fade non-content iterations
|
||||||
note_bpm,
|
document.tempo_map(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let note_bpm = if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures {
|
|
||||||
Some(document.bpm())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
Self::render_midi_piano_roll(
|
Self::render_midi_piano_roll(
|
||||||
&painter,
|
&painter,
|
||||||
clip_rect,
|
clip_rect,
|
||||||
|
|
@ -3517,7 +3589,7 @@ impl TimelinePane {
|
||||||
theme,
|
theme,
|
||||||
ui.ctx(),
|
ui.ctx(),
|
||||||
false,
|
false,
|
||||||
note_bpm,
|
document.tempo_map(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3899,6 +3971,10 @@ impl TimelinePane {
|
||||||
egui::pos2(rect.min.x, lane_top),
|
egui::pos2(rect.min.x, lane_top),
|
||||||
egui::vec2(rect.width(), AUTOMATION_LANE_HEIGHT),
|
egui::vec2(rect.width(), AUTOMATION_LANE_HEIGHT),
|
||||||
);
|
);
|
||||||
|
let lane_accent = match lane.kind {
|
||||||
|
AutomationLaneKind::MasterTempo => egui::Color32::from_rgb(160, 80, 220),
|
||||||
|
AutomationLaneKind::Regular => tc,
|
||||||
|
};
|
||||||
pending_lane_renders.push(AutomationLaneRender {
|
pending_lane_renders.push(AutomationLaneRender {
|
||||||
layer_id: row_layer_id,
|
layer_id: row_layer_id,
|
||||||
node_id: lane.node_id,
|
node_id: lane.node_id,
|
||||||
|
|
@ -3906,8 +3982,9 @@ impl TimelinePane {
|
||||||
keyframes: lane.keyframes.clone(),
|
keyframes: lane.keyframes.clone(),
|
||||||
value_min: lane.value_min,
|
value_min: lane.value_min,
|
||||||
value_max: lane.value_max,
|
value_max: lane.value_max,
|
||||||
accent_color: tc,
|
accent_color: lane_accent,
|
||||||
playback_time,
|
playback_time,
|
||||||
|
kind: lane.kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5133,14 +5210,19 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
// BPM control
|
// BPM control — disabled when tempo automation has been edited (multiple entries)
|
||||||
|
let tempo_is_automated = tempo_map.entries.len() > 1;
|
||||||
let mut bpm_val = bpm;
|
let mut bpm_val = bpm;
|
||||||
ui.label("BPM:");
|
ui.label("BPM:");
|
||||||
let bpm_response = ui.add(egui::DragValue::new(&mut bpm_val)
|
let bpm_response = ui.add_enabled(
|
||||||
|
!tempo_is_automated,
|
||||||
|
egui::DragValue::new(&mut bpm_val)
|
||||||
.range(20.0..=300.0)
|
.range(20.0..=300.0)
|
||||||
.speed(0.5)
|
.speed(0.5)
|
||||||
.fixed_decimals(1));
|
.fixed_decimals(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !tempo_is_automated {
|
||||||
// Capture start BPM on drag/focus start
|
// Capture start BPM on drag/focus start
|
||||||
if bpm_response.gained_focus() || bpm_response.drag_started() {
|
if bpm_response.gained_focus() || bpm_response.drag_started() {
|
||||||
if self.bpm_drag_start.is_none() {
|
if self.bpm_drag_start.is_none() {
|
||||||
|
|
@ -5180,6 +5262,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
|
|
@ -5230,7 +5313,15 @@ impl PaneRenderer for TimelinePane {
|
||||||
// Get document from action executor
|
// Get document from action executor
|
||||||
let document = shared.action_executor.document();
|
let document = shared.action_executor.document();
|
||||||
let editing_clip_id = shared.editing_clip_id;
|
let editing_clip_id = shared.editing_clip_id;
|
||||||
let context_layers = document.context_layers(editing_clip_id.as_ref());
|
let mut context_layers = document.context_layers(editing_clip_id.as_ref());
|
||||||
|
// Prepend master track as the first row when enabled (only at root context, not inside clips)
|
||||||
|
let master_any_layer;
|
||||||
|
if self.show_master_track && editing_clip_id.is_none() {
|
||||||
|
master_any_layer = Some(AnyLayer::Group(document.master_layer.clone()));
|
||||||
|
context_layers.insert(0, master_any_layer.as_ref().unwrap());
|
||||||
|
} else {
|
||||||
|
master_any_layer = None;
|
||||||
|
}
|
||||||
// Use virtual row count (includes expanded group children) for height calculations
|
// Use virtual row count (includes expanded group children) for height calculations
|
||||||
let layer_count = build_timeline_rows(&context_layers).len();
|
let layer_count = build_timeline_rows(&context_layers).len();
|
||||||
|
|
||||||
|
|
@ -5339,17 +5430,30 @@ impl PaneRenderer for TimelinePane {
|
||||||
// Process pending automation lane edit actions
|
// Process pending automation lane edit actions
|
||||||
if !self.pending_automation_actions.is_empty() {
|
if !self.pending_automation_actions.is_empty() {
|
||||||
let actions = std::mem::take(&mut self.pending_automation_actions);
|
let actions = std::mem::take(&mut self.pending_automation_actions);
|
||||||
|
let tempo_map = document.tempo_map().clone();
|
||||||
if let Some(controller_arc) = shared.audio_controller {
|
if let Some(controller_arc) = shared.audio_controller {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
for action in actions {
|
for action in actions {
|
||||||
match action {
|
match action {
|
||||||
AutomationLaneAction::AddKeyframe { layer_id, node_id, time, value } => {
|
AutomationLaneAction::AddKeyframe { layer_id, node_id, time, value } => {
|
||||||
if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
if node_id == MASTER_TEMPO_NODE_ID {
|
||||||
|
// Tempo lane: `time` is beats (curve editor uses beat-based closures)
|
||||||
|
let old_map = tempo_map.clone();
|
||||||
|
let beat = time;
|
||||||
|
let mut new_map = old_map.clone();
|
||||||
|
new_map.entries.push(daw_backend::TempoEntry { beat, bpm: value as f64, seconds: 0.0, interpolation: daw_backend::TempoInterpolation::Linear });
|
||||||
|
new_map.entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
new_map.rebuild_seconds();
|
||||||
|
let bpm_action = lightningbeam_core::actions::ChangeBpmAction::from_maps(old_map, new_map);
|
||||||
|
shared.pending_actions.push(Box::new(bpm_action));
|
||||||
|
// Drop cache so it rebuilds (from updated tempo_map) after action executes
|
||||||
|
self.automation_cache.remove(&layer_id);
|
||||||
|
} else if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
||||||
|
// time is already in beats (all automation x-axes use beats)
|
||||||
controller.automation_add_keyframe(track_id, node_id, time, value, "linear".to_string(), (0.0, 0.0), (0.0, 0.0));
|
controller.automation_add_keyframe(track_id, node_id, time, value, "linear".to_string(), (0.0, 0.0), (0.0, 0.0));
|
||||||
// Optimistic cache update
|
// Optimistic cache update (beats)
|
||||||
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) {
|
||||||
// Replace existing keyframe at same time, or insert new one
|
|
||||||
if let Some(existing) = lane.keyframes.iter_mut().find(|k| (k.time - time).abs() < 0.001) {
|
if let Some(existing) = lane.keyframes.iter_mut().find(|k| (k.time - time).abs() < 0.001) {
|
||||||
existing.value = value;
|
existing.value = value;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -5367,10 +5471,36 @@ impl PaneRenderer for TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AutomationLaneAction::MoveKeyframe { layer_id, node_id, old_time, new_time, new_value, interpolation, ease_out, ease_in } => {
|
AutomationLaneAction::MoveKeyframe { layer_id, node_id, old_time, new_time, new_value, interpolation, ease_out, ease_in } => {
|
||||||
if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
if node_id == MASTER_TEMPO_NODE_ID {
|
||||||
|
// Tempo lane: old_time/new_time are beats (beat-based closures)
|
||||||
|
let old_beat = old_time;
|
||||||
|
let new_beat = new_time;
|
||||||
|
let old_map = tempo_map.clone();
|
||||||
|
let mut new_map = old_map.clone();
|
||||||
|
if old_beat > 1e-9 {
|
||||||
|
// Non-anchor entry: remove old, add at new position (preserve interpolation)
|
||||||
|
let orig_interp = old_map.entries.iter()
|
||||||
|
.find(|e| (e.beat - old_beat).abs() < 1e-9)
|
||||||
|
.map(|e| e.interpolation.clone())
|
||||||
|
.unwrap_or(daw_backend::TempoInterpolation::Linear);
|
||||||
|
new_map.entries.retain(|e| (e.beat - old_beat).abs() >= 1e-9);
|
||||||
|
new_map.entries.push(daw_backend::TempoEntry { beat: new_beat.max(1e-9), bpm: new_value as f64, seconds: 0.0, interpolation: orig_interp });
|
||||||
|
} else {
|
||||||
|
// Beat-0 anchor: only the BPM value can change, position is fixed at beat 0
|
||||||
|
if let Some(e) = new_map.entries.iter_mut().find(|e| e.beat < 1e-9) {
|
||||||
|
e.bpm = new_value as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new_map.entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
new_map.rebuild_seconds();
|
||||||
|
let bpm_action = lightningbeam_core::actions::ChangeBpmAction::from_maps(old_map, new_map);
|
||||||
|
shared.pending_actions.push(Box::new(bpm_action));
|
||||||
|
self.automation_cache.remove(&layer_id);
|
||||||
|
} else if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
||||||
|
// old_time / new_time are already in beats
|
||||||
controller.automation_remove_keyframe(track_id, node_id, old_time);
|
controller.automation_remove_keyframe(track_id, node_id, old_time);
|
||||||
controller.automation_add_keyframe(track_id, node_id, new_time, new_value, interpolation.clone(), ease_out, ease_in);
|
controller.automation_add_keyframe(track_id, node_id, new_time, new_value, interpolation.clone(), ease_out, ease_in);
|
||||||
// Optimistic cache update
|
// Optimistic cache update (beats)
|
||||||
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) {
|
||||||
if let Some(kf) = lane.keyframes.iter_mut().find(|k| (k.time - old_time).abs() < 0.001) {
|
if let Some(kf) = lane.keyframes.iter_mut().find(|k| (k.time - old_time).abs() < 0.001) {
|
||||||
|
|
@ -5383,9 +5513,22 @@ impl PaneRenderer for TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AutomationLaneAction::DeleteKeyframe { layer_id, node_id, time } => {
|
AutomationLaneAction::DeleteKeyframe { layer_id, node_id, time } => {
|
||||||
if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
if node_id == MASTER_TEMPO_NODE_ID {
|
||||||
|
// Tempo lane: time is beats; never remove beat-0 anchor
|
||||||
|
let old_map = tempo_map.clone();
|
||||||
|
let beat = time;
|
||||||
|
if beat > 1e-9 {
|
||||||
|
let mut new_map = old_map.clone();
|
||||||
|
new_map.entries.retain(|e| (e.beat - beat).abs() >= 1e-9);
|
||||||
|
new_map.rebuild_seconds();
|
||||||
|
let bpm_action = lightningbeam_core::actions::ChangeBpmAction::from_maps(old_map, new_map);
|
||||||
|
shared.pending_actions.push(Box::new(bpm_action));
|
||||||
|
self.automation_cache.remove(&layer_id);
|
||||||
|
}
|
||||||
|
} else if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
||||||
|
// time is already in beats
|
||||||
controller.automation_remove_keyframe(track_id, node_id, time);
|
controller.automation_remove_keyframe(track_id, node_id, time);
|
||||||
// Optimistic cache update
|
// Optimistic cache update (beats)
|
||||||
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.keyframes.retain(|k| (k.time - time).abs() >= 0.001);
|
lane.keyframes.retain(|k| (k.time - time).abs() >= 0.001);
|
||||||
|
|
@ -5394,6 +5537,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AutomationLaneAction::SetRange { layer_id, node_id, min, max } => {
|
AutomationLaneAction::SetRange { layer_id, node_id, min, max } => {
|
||||||
|
if node_id != MASTER_TEMPO_NODE_ID {
|
||||||
if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
||||||
controller.automation_set_range(track_id, node_id, min, max);
|
controller.automation_set_range(track_id, node_id, min, max);
|
||||||
// Optimistic cache update
|
// Optimistic cache update
|
||||||
|
|
@ -5409,6 +5553,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Invalidate automation cache when the project changes (new node added, etc.)
|
// Invalidate automation cache when the project changes (new node added, etc.)
|
||||||
if *shared.project_generation != self.automation_cache_generation {
|
if *shared.project_generation != self.automation_cache_generation {
|
||||||
|
|
@ -5416,12 +5561,15 @@ impl PaneRenderer for TimelinePane {
|
||||||
self.automation_cache.clear();
|
self.automation_cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate automation cache when BPM changes (but not during a live drag —
|
// Invalidate the master layer's tempo lane cache when BPM changes.
|
||||||
// during drag the backend hasn't rescaled yet so re-querying would give stale data).
|
// Use `automation_cache_bpm` as a sentinel: if the document's global BPM differs from
|
||||||
|
// what was cached, drop the master layer entry so it rebuilds with the new tempo_map.
|
||||||
|
// We skip this during live BPM drag (bpm_drag_start.is_some()) to avoid flickering.
|
||||||
let bpm_committed = self.bpm_drag_start.is_none();
|
let bpm_committed = self.bpm_drag_start.is_none();
|
||||||
if bpm_committed && (document.bpm() - self.automation_cache_bpm).abs() > 1e-9 {
|
if bpm_committed && (document.bpm() - self.automation_cache_bpm).abs() > 1e-9 {
|
||||||
self.automation_cache_bpm = document.bpm();
|
self.automation_cache_bpm = document.bpm();
|
||||||
self.automation_cache.clear();
|
let master_layer_id_for_bpm = document.master_layer.layer.id;
|
||||||
|
self.automation_cache.remove(&master_layer_id_for_bpm);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh automation cache for expanded layers.
|
// Refresh automation cache for expanded layers.
|
||||||
|
|
@ -5433,11 +5581,13 @@ impl PaneRenderer for TimelinePane {
|
||||||
self.automation_topology_generation = *shared.graph_topology_generation;
|
self.automation_topology_generation = *shared.graph_topology_generation;
|
||||||
self.automation_cache.clear();
|
self.automation_cache.clear();
|
||||||
}
|
}
|
||||||
|
let master_layer_id = document.master_layer.layer.id;
|
||||||
|
let tempo_map_clone = document.tempo_map().clone();
|
||||||
for layer_id in self.automation_expanded.iter().copied().collect::<Vec<_>>() {
|
for layer_id in self.automation_expanded.iter().copied().collect::<Vec<_>>() {
|
||||||
if !self.automation_cache.contains_key(&layer_id) {
|
if !self.automation_cache.contains_key(&layer_id) {
|
||||||
if let Some(controller_arc) = shared.audio_controller {
|
if let Some(controller_arc) = shared.audio_controller {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
self.refresh_automation_cache(layer_id, &mut controller, shared.layer_to_track_map);
|
self.refresh_automation_cache(layer_id, &mut controller, shared.layer_to_track_map, master_layer_id, &tempo_map_clone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5465,7 +5615,9 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// Render automation lanes AFTER handle_input so our ui.interact registers last and wins
|
// Render automation lanes AFTER handle_input so our ui.interact registers last and wins
|
||||||
// egui's interaction priority over handle_input's full-content-area allocation.
|
// egui's interaction priority over handle_input's full-content-area allocation.
|
||||||
|
// All automation lanes use beats as the x-axis; convert via the tempo map.
|
||||||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
||||||
|
let tempo_map_for_lanes = document.tempo_map().clone();
|
||||||
for lane in &pending_lane_renders {
|
for lane in &pending_lane_renders {
|
||||||
let drag_key = (lane.layer_id, lane.node_id);
|
let drag_key = (lane.layer_id, lane.node_id);
|
||||||
let mut drag_state_local = self.automation_drag
|
let mut drag_state_local = self.automation_drag
|
||||||
|
|
@ -5475,18 +5627,21 @@ impl PaneRenderer for TimelinePane {
|
||||||
.with(lane.layer_id)
|
.with(lane.layer_id)
|
||||||
.with(lane.node_id);
|
.with(lane.node_id);
|
||||||
let lane_min_x = lane.lane_rect.min.x;
|
let lane_min_x = lane.lane_rect.min.x;
|
||||||
|
let tm = &tempo_map_for_lanes;
|
||||||
|
// playback_time is seconds; convert to beats for the beat-axis curve editor
|
||||||
|
let playback_beats = tm.inverse_transform(lane.playback_time);
|
||||||
let action = crate::curve_editor::render_curve_lane(
|
let action = crate::curve_editor::render_curve_lane(
|
||||||
ui,
|
ui,
|
||||||
lane.lane_rect,
|
lane.lane_rect,
|
||||||
&lane.keyframes,
|
&lane.keyframes,
|
||||||
&mut drag_state_local,
|
&mut drag_state_local,
|
||||||
lane.playback_time,
|
playback_beats,
|
||||||
lane.accent_color,
|
lane.accent_color,
|
||||||
lane_id,
|
lane_id,
|
||||||
lane.value_min,
|
lane.value_min,
|
||||||
lane.value_max,
|
lane.value_max,
|
||||||
|t| lane_min_x + self.time_to_x(t),
|
|beat| lane_min_x + self.time_to_x(tm.transform(beat)),
|
||||||
|x| self.x_to_time(x - lane_min_x),
|
|x| tm.inverse_transform(self.x_to_time(x - lane_min_x)),
|
||||||
);
|
);
|
||||||
self.automation_drag.insert(drag_key, drag_state_local);
|
self.automation_drag.insert(drag_key, drag_state_local);
|
||||||
let layer_id = lane.layer_id;
|
let layer_id = lane.layer_id;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue