Make beats canonical representation rather than seconds

This commit is contained in:
Skyler Lehmkuhl 2026-06-02 13:06:36 -04:00
parent f372a84313
commit 54d5764bd0
11 changed files with 561 additions and 201 deletions

View File

@ -113,6 +113,8 @@ pub struct Engine {
// Current tempo map — kept in sync with SetTempo/SetTempoMap commands.
tempo_map: crate::TempoMap,
current_fps: f64,
// Current time signature — updated by SetTempo, used when SetTempoMap fires.
time_sig: (u32, u32),
}
impl Engine {
@ -191,6 +193,7 @@ impl Engine {
timing_overrun_count: 0,
tempo_map: crate::TempoMap::constant(120.0),
current_fps: 30.0,
time_sig: (4, 4),
}
}
@ -1290,11 +1293,19 @@ impl Engine {
}
Command::SetTempo(bpm, time_sig) => {
self.time_sig = time_sig;
self.metronome.update_timing(bpm, time_sig);
self.project.set_tempo(bpm, time_sig.0);
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
Command::GraphAddNode(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();
if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
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)
if current == subtracks {
// No-op if subtrack list is unchanged AND the graph is already initialised.
// 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;
}
@ -2156,8 +2175,13 @@ impl Engine {
}
};
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
let graph = &mut track.instrument_graph;
let graph = match self.project.get_track_mut(track_id) {
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);
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) => {
use crate::audio::node_graph::nodes::AutomationInputNode;
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
let graph = &mut track.instrument_graph;
let graph = match self.project.get_track_mut(track_id) {
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);
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) => {
use crate::audio::node_graph::nodes::AutomationInputNode;
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
let graph = &mut track.instrument_graph;
let graph = match self.project.get_track_mut(track_id) {
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);
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::command::types::AutomationKeyframeData;
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
let graph = &track.instrument_graph;
let graph = match self.project.get_track(track_id) {
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);
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>() {
let keyframes: Vec<AutomationKeyframeData> = auto_node.keyframes()
.iter()
@ -2524,7 +2561,6 @@ impl Engine {
InterpolationType::Step => "step",
InterpolationType::Hold => "hold",
}.to_string();
AutomationKeyframeData {
time: kf.time.0,
value: kf.value,
@ -2534,7 +2570,6 @@ impl Engine {
}
})
.collect();
QueryResponse::AutomationKeyframes(Ok(keyframes))
} else {
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)))
}
} 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) => {
use crate::audio::node_graph::nodes::AutomationInputNode;
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
let graph = &track.instrument_graph;
let graph = match self.project.get_track(track_id) {
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);
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>() {
QueryResponse::AutomationName(Ok(auto_node.display_name().to_string()))
} else {
@ -2565,17 +2603,21 @@ impl Engine {
QueryResponse::AutomationName(Err(format!("Node {} not found in track {}", node_id, track_id)))
}
} 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) => {
use crate::audio::node_graph::nodes::AutomationInputNode;
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
let graph = &track.instrument_graph;
let graph = match self.project.get_track(track_id) {
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);
if let Some(graph_node) = graph.get_graph_node(node_idx) {
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
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)))
}
} 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));
}
/// 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
/// automation keyframe times to preserve beat positions.
/// `from_bpm` is the BPM before the change; `to_bpm` is the new BPM.

View File

@ -263,7 +263,11 @@ impl Metatrack {
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.
/// Replaces the current graph and marks `graph_is_default = true`.
@ -273,35 +277,60 @@ impl Metatrack {
sample_rate: u32,
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 mut graph = AudioGraph::new(sample_rate, buffer_size);
// 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 subtracks_copy = inputs_node.subtracks().to_vec();
inputs_node.update_subtracks(subtracks_copy, buffer_size);
let inputs_id = graph.add_node(Box::new(inputs_node));
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_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
let output_node = Box::new(AudioOutputNode::new("Audio Output"));
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
for i in 0..n {
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));
self.audio_graph = graph;
@ -309,6 +338,22 @@ impl Metatrack {
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.
///
/// If `graph_is_default`: also connects the new port to a new Mixer input.

View File

@ -145,6 +145,8 @@ pub enum Command {
SetMetronomeEnabled(bool),
/// Set project tempo and time signature (bpm, (numerator, denominator))
SetTempo(f32, (u32, u32)),
/// Replace the entire tempo map (multi-entry variable tempo support)
SetTempoMap(crate::TempoMap),
// Node graph commands
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
GraphAddNode(TrackId, String, f32, f32),

View File

@ -21,7 +21,7 @@ pub use audio::{
};
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
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::types::AutomationKeyframeData;
pub use io::{load_midi_file, AudioFile, WaveformChunk, WaveformChunkKey, WaveformPeak, WavWriter};

View File

@ -3,11 +3,17 @@
//! Positions are stored in **beats** throughout the project; `TempoMap` converts
//! 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
//! `entries` is a sorted `Vec<TempoEntry>` where each entry marks where a new
//! constant BPM segment begins. Step interpolation is used: BPM is constant
//! from `entry.beat` until the next entry. The first entry must always have
//! `beat == 0.0`.
//! `entries` is a sorted `Vec<TempoEntry>` where the first entry must always
//! have `beat == 0.0`.
//!
//! # Sequential-access optimisation
//! 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 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)]
pub struct TempoEntry {
/// Start of this tempo segment in beats (quarter-note beats).
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,
/// Cumulative seconds elapsed at the start of this segment.
/// **Derived** — not serialised; call [`TempoMap::rebuild_seconds`] after any
/// mutation or after deserialization.
#[serde(skip, default)]
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)]
pub struct TempoMap {
/// 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 {
/// Create a constant-tempo map.
pub fn constant(bpm: f64) -> 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),
}
}
@ -72,20 +130,36 @@ impl TempoMap {
/// after deserialization.
pub fn rebuild_seconds(&mut self) {
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;
if i + 1 < self.entries.len() {
let span_beats = self.entries[i + 1].beat - self.entries[i].beat;
cumulative += span_beats * 60.0 / self.entries[i].bpm;
if i + 1 < n {
let span = self.entries[i + 1].beat - self.entries[i].beat;
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);
}
/// 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 {
let n = self.entries.len();
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.
@ -93,33 +167,12 @@ impl TempoMap {
/// Uses the sequential cache: if `beat` is at or after the last cached
/// segment, the scan starts there instead of from the beginning.
pub fn beats_to_seconds(&self, beat: Beats) -> Seconds {
if beat.0 <= 0.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)
Seconds(self.transform(beat.0))
}
/// Convert seconds to beats using binary search on the cached `seconds` offsets.
pub fn seconds_to_beats(&self, seconds: Seconds) -> Beats {
if seconds.0 <= 0.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)
Beats(self.inverse_transform(seconds.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`).
///
/// At the root level the result is absolute seconds. Inside a nested
/// group the result is the *parent group's* beats — the caller is
/// 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.
/// group the result is the *parent group's* beats.
pub fn transform(&self, beat: f64) -> f64 {
if beat <= 0.0 {
return 0.0;
@ -153,8 +202,16 @@ impl TempoMap {
idx += 1;
}
self.last_index.store(idx, Ordering::Relaxed);
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.
@ -166,10 +223,17 @@ impl TempoMap {
let idx = self.entries.partition_point(|e| e.seconds <= parent_time).saturating_sub(1);
let idx = idx.min(n - 1);
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).
pub fn from_keyframes(keyframes: &[(f64, f64)]) -> Self {
if keyframes.is_empty() {
@ -177,11 +241,11 @@ impl TempoMap {
}
let mut entries: Vec<TempoEntry> = keyframes
.iter()
.map(|&(beat, bpm)| TempoEntry { beat, bpm, seconds: 0.0 })
.map(|&(beat, bpm)| TempoEntry { beat, bpm, seconds: 0.0, interpolation: TempoInterpolation::Step })
.collect();
entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap());
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) };
map.rebuild_seconds();
@ -190,14 +254,6 @@ impl TempoMap {
}
/// 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 {
let mut t = beat;
for tm in stack.iter().rev() {
@ -227,26 +283,40 @@ mod tests {
}
#[test]
fn variable_tempo() {
fn variable_tempo_step() {
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.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]
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 group = TempoMap::constant(60.0);
let stack: Vec<&TempoMap> = vec![&root, &group];
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}");
let beats = seconds_to_beats_stack(1.0, &stack);
assert!((beats - 2.0).abs() < 1e-9, "got {beats}");
}

View File

@ -13,7 +13,6 @@ use crate::tempo_map::TempoMap;
pub struct ChangeBpmAction {
old_map: TempoMap,
new_map: TempoMap,
time_sig: (u32, u32),
}
impl ChangeBpmAction {
@ -22,14 +21,13 @@ impl ChangeBpmAction {
let old_map = document.tempo_map().clone();
let mut new_map = old_map.clone();
new_map.set_global_bpm(new_bpm);
let time_sig = (document.time_signature.numerator, document.time_signature.denominator);
Self { old_map, new_map, time_sig }
Self { old_map, new_map }
}
/// 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).
pub fn from_maps(old_map: TempoMap, new_map: TempoMap, time_sig: (u32, u32)) -> Self {
Self { old_map, new_map, time_sig }
pub fn from_maps(old_map: TempoMap, new_map: TempoMap) -> Self {
Self { old_map, new_map }
}
pub fn new_bpm(&self) -> f64 { self.new_map.global_bpm() }
@ -60,7 +58,7 @@ impl Action for ChangeBpmAction {
Some(c) => c,
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(())
}
@ -73,7 +71,7 @@ impl Action for ChangeBpmAction {
Some(c) => c,
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(())
}
}

View File

@ -779,7 +779,7 @@ impl GroupLayer {
"Master",
),
children: Vec::new(),
expanded: true,
expanded: false, // collapsed so it always renders a visible header row
tempo_map: Some(TempoMap::constant(bpm)),
}
}

View File

@ -330,6 +330,7 @@ impl From<MenuAction> for AppAction {
MenuAction::AddTestClip => Self::AddTestClip,
MenuAction::DeleteLayer => Self::DeleteLayer,
MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility,
MenuAction::ShowMasterTrack => Self::ToggleLayerVisibility, // not directly mappable
MenuAction::NewKeyframe => Self::NewKeyframe,
MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe,
MenuAction::DeleteFrame => Self::DeleteFrame,

View File

@ -1538,6 +1538,21 @@ impl EditorApp {
fn sync_audio_layers_to_backend(&mut self) {
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
// 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();
@ -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.
@ -3483,6 +3509,14 @@ impl EditorApp {
println!("Menu: 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
MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => {
@ -5623,10 +5657,14 @@ impl eframe::App for EditorApp {
});
// Checked actions show "✔ Label"; hidden actions are not rendered at all
let checked: &[crate::menu::MenuAction] = if self.count_in_enabled && self.metronome_enabled {
&[crate::menu::MenuAction::ToggleCountIn]
} else {
&[]
let master_track_shown = self.pane_instances.values().any(|p| {
if let panes::PaneInstance::Timeline(t) = p { t.show_master_track } else { false }
});
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 {
&[]

View File

@ -313,6 +313,7 @@ pub enum MenuAction {
AddTestClip, // For testing: adds a test clip to the asset library
DeleteLayer,
ToggleLayerVisibility,
ShowMasterTrack,
// Timeline menu
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 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 SHOW_MASTER_TRACK: Self = Self { label: "Show Master Track", action: MenuAction::ShowMasterTrack, shortcut: None };
// 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)) };
@ -533,6 +535,8 @@ impl MenuItemDef {
MenuDef::Separator,
MenuDef::Item(&Self::DELETE_LAYER),
MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY),
MenuDef::Separator,
MenuDef::Item(&Self::SHOW_MASTER_TRACK),
],
},
// Timeline menu

View File

@ -228,6 +228,9 @@ pub struct TimelinePane {
/// BPM value captured when a drag/focus-in starts (for single-undo-action on commit)
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
@ -355,12 +358,25 @@ fn flatten_layer<'a>(
}
/// 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 {
node_id: u32,
name: String,
keyframes: Vec<crate::curve_editor::CurvePoint>,
value_min: f32,
value_max: f32,
kind: AutomationLaneKind,
}
/// Data collected during render_layers for a single automation lane, used to call
@ -374,6 +390,7 @@ struct AutomationLaneRender {
value_max: f32,
accent_color: egui::Color32,
playback_time: f64,
kind: AutomationLaneKind,
}
/// Pending automation keyframe edit action from curve lane interaction
@ -689,11 +706,12 @@ impl TimelinePane {
pending_automation_actions: Vec::new(),
automation_cache_generation: u64::MAX,
automation_topology_generation: u64::MAX,
automation_cache_bpm: f64::NAN,
automation_cache_bpm: 0.0,
metronome_icon: None,
pending_recording_start: None,
renaming_layer: None,
bpm_drag_start: None,
show_master_track: false,
}
}
@ -755,6 +773,8 @@ impl TimelinePane {
layer_id: uuid::Uuid,
controller: &mut daw_backend::EngineController,
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) {
Some(t) => *t,
@ -785,7 +805,7 @@ impl TimelinePane {
.unwrap_or_default()
.iter()
.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,
interpolation: match k.interpolation.as_str() {
"bezier" => crate::curve_editor::CurveInterpolation::Bezier,
@ -800,8 +820,33 @@ impl TimelinePane {
let (value_min, value_max) = controller
.query_automation_range(track_id, node.id)
.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);
}
@ -1528,21 +1573,23 @@ impl TimelinePane {
}
}
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 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;
let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64;
// Find beat range from viewport using the actual tempo map
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 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 {
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; }
let beat_in_measure = (beat_idx as u32) % bpm_count;
@ -1673,7 +1720,7 @@ impl TimelinePane {
theme: &crate::theme::Theme,
ctx: &egui::Context,
faded: bool,
display_bpm: Option<f64>,
tempo_map: &daw_backend::TempoMap,
) {
let clip_height = clip_rect.height();
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_color = note_style.background_color().unwrap_or(egui::Color32::BLACK);
// `timestamp` is in beats; convert to display-seconds using the current (preview) BPM.
let tempo_map = daw_backend::TempoMap::constant(display_bpm.unwrap_or(120.0));
// `timestamp` is in beats; convert to display-seconds using the full tempo map.
let event_display_time = |ev: &daw_backend::audio::midi::MidiEvent| -> f64 {
tempo_map.transform(ev.timestamp.beats_to_f64())
};
@ -1788,7 +1834,7 @@ impl TimelinePane {
active_layer_id: &Option<uuid::Uuid>,
focus: &lightningbeam_core::selection::FocusSelection,
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],
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, daw_backend::TrackId>,
track_levels: &std::collections::HashMap<daw_backend::TrackId, f32>,
@ -2106,7 +2152,8 @@ impl TimelinePane {
}
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);
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)
}
};
@ -2386,13 +2433,14 @@ impl TimelinePane {
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!(
row,
TimelineRow::Normal(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(
egui::pos2(rect.min.x + 4.0, y + LAYER_HEIGHT - 18.0),
egui::vec2(16.0, 14.0),
@ -2735,13 +2783,14 @@ impl TimelinePane {
}
}
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 start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
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 = 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 {
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; }
let is_measure_boundary = (beat_idx as u32) % bpm_count == 0;
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))),
);
// 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
}
@ -3477,11 +3559,6 @@ impl TimelinePane {
let iter_duration = iter_end - iter_start;
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(
&painter,
clip_rect,
@ -3495,15 +3572,10 @@ impl TimelinePane {
theme,
ui.ctx(),
si != 0, // fade non-content iterations
note_bpm,
document.tempo_map(),
);
}
} else {
let note_bpm = if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures {
Some(document.bpm())
} else {
None
};
Self::render_midi_piano_roll(
&painter,
clip_rect,
@ -3517,7 +3589,7 @@ impl TimelinePane {
theme,
ui.ctx(),
false,
note_bpm,
document.tempo_map(),
);
}
}
@ -3899,6 +3971,10 @@ impl TimelinePane {
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 => tc,
};
pending_lane_renders.push(AutomationLaneRender {
layer_id: row_layer_id,
node_id: lane.node_id,
@ -3906,8 +3982,9 @@ impl TimelinePane {
keyframes: lane.keyframes.clone(),
value_min: lane.value_min,
value_max: lane.value_max,
accent_color: tc,
accent_color: lane_accent,
playback_time,
kind: lane.kind,
});
}
}
@ -5133,14 +5210,19 @@ impl PaneRenderer for TimelinePane {
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;
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)
.speed(0.5)
.fixed_decimals(1));
.fixed_decimals(1),
);
if !tempo_is_automated {
// Capture start BPM on drag/focus start
if bpm_response.gained_focus() || bpm_response.drag_started() {
if self.bpm_drag_start.is_none() {
@ -5180,6 +5262,7 @@ impl PaneRenderer for TimelinePane {
}
}
}
}
ui.separator();
@ -5230,7 +5313,15 @@ impl PaneRenderer for TimelinePane {
// Get document from action executor
let document = shared.action_executor.document();
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
let layer_count = build_timeline_rows(&context_layers).len();
@ -5339,17 +5430,30 @@ impl PaneRenderer for TimelinePane {
// Process pending automation lane edit actions
if !self.pending_automation_actions.is_empty() {
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 {
let mut controller = controller_arc.lock().unwrap();
for action in actions {
match action {
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));
// Optimistic cache update
// Optimistic cache update (beats)
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) {
// 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) {
existing.value = value;
} 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 } => {
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_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(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) {
@ -5383,9 +5513,22 @@ impl PaneRenderer for TimelinePane {
}
}
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);
// Optimistic cache update
// Optimistic cache update (beats)
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.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 } => {
if node_id != MASTER_TEMPO_NODE_ID {
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
@ -5409,6 +5553,7 @@ impl PaneRenderer for TimelinePane {
}
}
}
}
// Invalidate automation cache when the project changes (new node added, etc.)
if *shared.project_generation != self.automation_cache_generation {
@ -5416,12 +5561,15 @@ impl PaneRenderer for TimelinePane {
self.automation_cache.clear();
}
// Invalidate automation cache when BPM changes (but not during a live drag —
// during drag the backend hasn't rescaled yet so re-querying would give stale data).
// Invalidate the master layer's tempo lane cache when BPM changes.
// 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();
if bpm_committed && (document.bpm() - self.automation_cache_bpm).abs() > 1e-9 {
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.
@ -5433,11 +5581,13 @@ impl PaneRenderer for TimelinePane {
self.automation_topology_generation = *shared.graph_topology_generation;
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<_>>() {
if !self.automation_cache.contains_key(&layer_id) {
if let Some(controller_arc) = shared.audio_controller {
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
// 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));
let tempo_map_for_lanes = document.tempo_map().clone();
for lane in &pending_lane_renders {
let drag_key = (lane.layer_id, lane.node_id);
let mut drag_state_local = self.automation_drag
@ -5475,18 +5627,21 @@ impl PaneRenderer for TimelinePane {
.with(lane.layer_id)
.with(lane.node_id);
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(
ui,
lane.lane_rect,
&lane.keyframes,
&mut drag_state_local,
lane.playback_time,
playback_beats,
lane.accent_color,
lane_id,
lane.value_min,
lane.value_max,
|t| lane_min_x + self.time_to_x(t),
|x| self.x_to_time(x - lane_min_x),
|beat| lane_min_x + self.time_to_x(tm.transform(beat)),
|x| tm.inverse_transform(self.x_to_time(x - lane_min_x)),
);
self.automation_drag.insert(drag_key, drag_state_local);
let layer_id = lane.layer_id;