From 54d5764bd0b270b7dd0a0ab2f21c3130a9e0daa7 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 2 Jun 2026 13:06:36 -0400 Subject: [PATCH] Make beats canonical representation rather than seconds --- daw-backend/src/audio/engine.rs | 95 +++-- daw-backend/src/audio/track.rs | 61 +++- daw-backend/src/command/types.rs | 2 + daw-backend/src/lib.rs | 2 +- daw-backend/src/tempo_map.rs | 196 ++++++---- .../src/actions/change_bpm.rs | 12 +- .../lightningbeam-core/src/layer.rs | 2 +- .../lightningbeam-editor/src/keymap.rs | 1 + .../lightningbeam-editor/src/main.rs | 46 ++- .../lightningbeam-editor/src/menu.rs | 4 + .../src/panes/timeline.rs | 341 +++++++++++++----- 11 files changed, 561 insertions(+), 201 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 1547993..8d59698 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -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::() { let keyframes: Vec = 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::() { 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::() { 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. diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index c066cc1..f7da3d2 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -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 { + 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::() { + 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. diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 1ca4020..5d481cb 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -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), diff --git a/daw-backend/src/lib.rs b/daw-backend/src/lib.rs index 733eae0..a8153e6 100644 --- a/daw-backend/src/lib.rs +++ b/daw-backend/src/lib.rs @@ -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}; diff --git a/daw-backend/src/tempo_map.rs b/daw-backend/src/tempo_map.rs index a6a5577..b34751a 100644 --- a/daw-backend/src/tempo_map.rs +++ b/daw-backend/src/tempo_map.rs @@ -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` 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` 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 { + 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 { + 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 = 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}"); } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs b/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs index f47023b..c510391 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs @@ -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(()) } } diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index dd900c0..821bbe6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -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)), } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs index 220e8ef..29791c4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs @@ -330,6 +330,7 @@ impl From 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, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index e641187..a902d2a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -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)> = 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 { &[] diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index 840ce4e..2d55671 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -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 diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 93ae067..fbfa360 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -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, + + /// 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, 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, + 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, + 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, focus: &lightningbeam_core::selection::FocusSelection, pending_actions: &mut Vec>, - _document: &lightningbeam_core::document::Document, + document: &lightningbeam_core::document::Document, context_layers: &[&lightningbeam_core::layer::AnyLayer], layer_to_track_map: &std::collections::HashMap, track_levels: &std::collections::HashMap, @@ -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,50 +5210,56 @@ 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) - .range(20.0..=300.0) - .speed(0.5) - .fixed_decimals(1)); + 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), + ); - // Capture start BPM on drag/focus start - if bpm_response.gained_focus() || bpm_response.drag_started() { - if self.bpm_drag_start.is_none() { - self.bpm_drag_start = Some(bpm); + 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() { + self.bpm_drag_start = Some(bpm); + } } - } - if bpm_response.changed() { - // Fallback capture: if gained_focus/drag_started didn't fire (e.g. rapid input), - // capture start BPM on the first change before updating the document. - if self.bpm_drag_start.is_none() { - self.bpm_drag_start = Some(bpm); + if bpm_response.changed() { + // Fallback capture: if gained_focus/drag_started didn't fire (e.g. rapid input), + // capture start BPM on the first change before updating the document. + if self.bpm_drag_start.is_none() { + self.bpm_drag_start = Some(bpm); + } + // Live preview: update document directly so grid reflows immediately + shared.action_executor.document_mut().set_bpm(bpm_val); + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_tempo(bpm_val as f32, (time_sig_num, time_sig_den)); + } } - // Live preview: update document directly so grid reflows immediately - shared.action_executor.document_mut().set_bpm(bpm_val); - if let Some(controller_arc) = shared.audio_controller { - let mut controller = controller_arc.lock().unwrap(); - controller.set_tempo(bpm_val as f32, (time_sig_num, time_sig_den)); - } - } - // On commit, dispatch a single ChangeBpmAction (single undo entry) - if bpm_response.drag_stopped() || bpm_response.lost_focus() { - if let Some(start_bpm) = self.bpm_drag_start.take() { - let new_bpm = shared.action_executor.document().bpm(); - if (start_bpm - new_bpm).abs() > 1e-6 - && self.time_display_format == lightningbeam_core::document::TimelineMode::Measures - { - use lightningbeam_core::actions::ChangeBpmAction; - // document.bpm() is already new_bpm from the live preview — build action - // with new_bpm so it records the correct undo state. - let action = ChangeBpmAction::new( - new_bpm, - shared.action_executor.document(), - ); - shared.pending_actions.push(Box::new(action)); + // On commit, dispatch a single ChangeBpmAction (single undo entry) + if bpm_response.drag_stopped() || bpm_response.lost_focus() { + if let Some(start_bpm) = self.bpm_drag_start.take() { + let new_bpm = shared.action_executor.document().bpm(); + if (start_bpm - new_bpm).abs() > 1e-6 + && self.time_display_format == lightningbeam_core::document::TimelineMode::Measures + { + use lightningbeam_core::actions::ChangeBpmAction; + // document.bpm() is already new_bpm from the live preview — build action + // with new_bpm so it records the correct undo state. + let action = ChangeBpmAction::new( + new_bpm, + shared.action_executor.document(), + ); + shared.pending_actions.push(Box::new(action)); + } } } } @@ -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,13 +5537,15 @@ impl PaneRenderer for TimelinePane { } } AutomationLaneAction::SetRange { layer_id, node_id, min, max } => { - if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) { - controller.automation_set_range(track_id, node_id, min, max); - // Optimistic cache update - if let Some(lanes) = self.automation_cache.get_mut(&layer_id) { - if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) { - lane.value_min = min; - lane.value_max = max; + 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 + if let Some(lanes) = self.automation_cache.get_mut(&layer_id) { + if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) { + lane.value_min = min; + lane.value_max = max; + } } } } @@ -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::>() { 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;