From f372a84313a684d19b1af7ea4d6280274b7fffd0 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 2 Apr 2026 10:26:01 -0400 Subject: [PATCH] Massive tempo refactor - make beats canonical time rep and allow them to be non constant --- daw-backend/src/audio/automation.rs | 25 +- daw-backend/src/audio/clip.rs | 177 +++------ daw-backend/src/audio/engine.rs | 234 +++++------- daw-backend/src/audio/export.rs | 52 +-- daw-backend/src/audio/midi.rs | 297 +++------------ daw-backend/src/audio/midi_pool.rs | 3 +- daw-backend/src/audio/node_graph/graph.rs | 28 +- .../node_graph/nodes/automation_input.rs | 104 ++---- .../src/audio/node_graph/nodes/beat.rs | 37 +- .../src/audio/node_graph/nodes/sequencer.rs | 5 +- .../audio/node_graph/nodes/voice_allocator.rs | 2 +- daw-backend/src/audio/pool.rs | 6 +- daw-backend/src/audio/project.rs | 69 +--- daw-backend/src/audio/recording.rs | 92 ++--- daw-backend/src/audio/track.rs | 166 ++++----- daw-backend/src/command/types.rs | 16 +- daw-backend/src/io/midi_file.rs | 159 ++------ daw-backend/src/lib.rs | 6 +- daw-backend/src/tempo_map.rs | 262 ++++++++++++++ daw-backend/src/time.rs | 125 +++++++ daw-backend/src/tui/mod.rs | 4 +- .../src/actions/add_clip_instance.rs | 10 +- .../src/actions/change_bpm.rs | 338 ++---------------- .../src/actions/change_fps.rs | 193 +--------- .../src/actions/move_clip_instances.rs | 7 - .../src/actions/remove_clip_instances.rs | 8 +- .../src/actions/split_clip_instance.rs | 28 +- .../src/actions/trim_clip_instances.rs | 13 +- .../lightningbeam-core/src/beat_time.rs | 23 +- .../lightningbeam-core/src/clip.rs | 272 +++++--------- .../lightningbeam-core/src/document.rs | 108 +++--- .../lightningbeam-core/src/effect_layer.rs | 18 +- .../lightningbeam-core/src/export.rs | 4 + .../lightningbeam-core/src/file_io.rs | 2 + .../lightningbeam-core/src/hit_test.rs | 21 +- .../lightningbeam-core/src/layer.rs | 30 +- .../lightningbeam-core/src/lib.rs | 1 + .../lightningbeam-core/src/renderer.rs | 28 +- .../lightningbeam-core/src/tempo_map.rs | 1 + .../src/export/audio_exporter.rs | 15 +- .../lightningbeam-editor/src/export/mod.rs | 5 +- .../src/export/video_exporter.rs | 2 +- .../lightningbeam-editor/src/main.rs | 62 +--- .../src/panes/asset_library.rs | 4 +- .../src/panes/infopanel.rs | 6 +- .../src/panes/piano_roll.rs | 121 +++---- .../lightningbeam-editor/src/panes/stage.rs | 8 +- .../src/panes/timeline.rs | 273 ++++++-------- 48 files changed, 1334 insertions(+), 2136 deletions(-) create mode 100644 daw-backend/src/tempo_map.rs create mode 100644 daw-backend/src/time.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/tempo_map.rs diff --git a/daw-backend/src/audio/automation.rs b/daw-backend/src/audio/automation.rs index 639e75d..e592905 100644 --- a/daw-backend/src/audio/automation.rs +++ b/daw-backend/src/audio/automation.rs @@ -1,5 +1,6 @@ /// Automation system for parameter modulation over time use serde::{Deserialize, Serialize}; +use crate::time::Beats; /// Unique identifier for automation lanes pub type AutomationLaneId = u32; @@ -35,17 +36,13 @@ pub enum CurveType { /// A single automation point #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct AutomationPoint { - /// Time in seconds - pub time: f64, - /// Parameter value (normalized 0.0 to 1.0, or actual value depending on parameter) + pub time: Beats, pub value: f32, - /// Curve type to next point pub curve: CurveType, } impl AutomationPoint { - /// Create a new automation point - pub fn new(time: f64, value: f32, curve: CurveType) -> Self { + pub fn new(time: Beats, value: f32, curve: CurveType) -> Self { Self { time, value, curve } } } @@ -93,8 +90,7 @@ impl AutomationLane { } } - /// Remove point at specific time - pub fn remove_point_at_time(&mut self, time: f64, tolerance: f64) -> bool { + pub fn remove_point_at_time(&mut self, time: Beats, tolerance: Beats) -> bool { if let Some(idx) = self.points.iter().position(|p| (p.time - time).abs() < tolerance) { self.points.remove(idx); true @@ -113,8 +109,7 @@ impl AutomationLane { &self.points } - /// Get value at a specific time with interpolation - pub fn evaluate(&self, time: f64) -> Option { + pub fn evaluate(&self, time: Beats) -> Option { if !self.enabled || self.points.is_empty() { return None; } @@ -148,14 +143,12 @@ impl AutomationLane { } } -/// Interpolate between two automation points based on curve type -fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: f64) -> f32 { - // Calculate normalized position between points (0.0 to 1.0) +fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: Beats) -> f32 { let t = if p2.time == p1.time { - 0.0 + 0.0f64 } else { - ((time - p1.time) / (p2.time - p1.time)) as f32 - }; + (time - p1.time) / (p2.time - p1.time) + } as f32; // Apply curve let curved_t = match p1.curve { diff --git a/daw-backend/src/audio/clip.rs b/daw-backend/src/audio/clip.rs index 9ae021b..c3f7f79 100644 --- a/daw-backend/src/audio/clip.rs +++ b/daw-backend/src/audio/clip.rs @@ -1,5 +1,7 @@ use std::sync::Arc; use serde::{Serialize, Deserialize}; +use crate::time::{Beats, Seconds}; +use crate::tempo_map::TempoMap; /// Audio clip instance ID type pub type AudioClipInstanceId = u32; @@ -9,47 +11,31 @@ pub type ClipId = AudioClipInstanceId; /// Audio clip instance that references content in the AudioClipPool /// -/// This represents a placed instance of audio content on the timeline. -/// The actual audio data is stored in the AudioClipPool and referenced by `audio_pool_index`. -/// /// ## Timing Model -/// - `internal_start` / `internal_end`: Define the region of the source audio to play (trimming) -/// - `external_start` / `external_duration`: Define where the clip appears on the timeline and how long -/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display +/// - `internal_start` / `internal_end`: Region of the source audio to play (seconds — audio file seek positions) +/// - `external_start` / `external_duration`: Where the clip appears on the timeline (**beats**) /// /// ## Looping -/// If `external_duration` is greater than `internal_end - internal_start`, -/// the clip will seamlessly loop back to `internal_start` when it reaches `internal_end`. +/// If `external_duration_secs(bpm)` > `internal_end - internal_start`, the clip loops. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AudioClipInstance { pub id: AudioClipInstanceId, pub audio_pool_index: usize, - /// Start position within the audio content (seconds) - pub internal_start: f64, - #[serde(default)] pub internal_start_beats: f64, - #[serde(default)] pub internal_start_frames: f64, - /// End position within the audio content (seconds) - pub internal_end: f64, - #[serde(default)] pub internal_end_beats: f64, - #[serde(default)] pub internal_end_frames: f64, + /// Start position within the audio content + pub internal_start: Seconds, + /// End position within the audio content + pub internal_end: Seconds, - /// Start position on the timeline (seconds) - pub external_start: f64, - #[serde(default)] pub external_start_beats: f64, - #[serde(default)] pub external_start_frames: f64, - /// Duration on the timeline (seconds) - can be longer than internal duration for looping - pub external_duration: f64, - #[serde(default)] pub external_duration_beats: f64, - #[serde(default)] pub external_duration_frames: f64, + /// Start position on the timeline + pub external_start: Beats, + /// Duration on the timeline + pub external_duration: Beats, /// Clip-level gain pub gain: f32, /// Per-instance read-ahead buffer for compressed audio streaming. - /// Each clip instance gets its own buffer so multiple instances of the - /// same file (on different tracks or at different positions) don't fight - /// over a single target_frame. #[serde(skip)] pub read_ahead: Option>, } @@ -58,154 +44,71 @@ pub struct AudioClipInstance { pub type Clip = AudioClipInstance; impl AudioClipInstance { - /// Create a new audio clip instance pub fn new( id: AudioClipInstanceId, audio_pool_index: usize, - internal_start: f64, - internal_end: f64, - external_start: f64, - external_duration: f64, + internal_start: Seconds, + internal_end: Seconds, + external_start: Beats, + external_duration: Beats, ) -> Self { Self { id, audio_pool_index, internal_start, - internal_start_beats: 0.0, - internal_start_frames: 0.0, internal_end, - internal_end_beats: 0.0, - internal_end_frames: 0.0, external_start, - external_start_beats: 0.0, - external_start_frames: 0.0, external_duration, - external_duration_beats: 0.0, - external_duration_frames: 0.0, gain: 1.0, read_ahead: None, } } - /// Create a clip instance from legacy parameters (for backwards compatibility) - /// Maps old start_time/duration/offset to new timing model - pub fn from_legacy( - id: AudioClipInstanceId, - audio_pool_index: usize, - start_time: f64, - duration: f64, - offset: f64, - ) -> Self { - Self { - id, - audio_pool_index, - internal_start: offset, - internal_start_beats: 0.0, - internal_start_frames: 0.0, - internal_end: offset + duration, - internal_end_beats: 0.0, - internal_end_frames: 0.0, - external_start: start_time, - external_start_beats: 0.0, - external_start_frames: 0.0, - external_duration: duration, - external_duration_beats: 0.0, - external_duration_frames: 0.0, - gain: 1.0, - read_ahead: None, - } - } - - /// Check if this clip instance is active at a given timeline position - pub fn is_active_at(&self, time_seconds: f64) -> bool { - time_seconds >= self.external_start && time_seconds < self.external_end() - } - - /// Get the end time of this clip instance on the timeline - pub fn external_end(&self) -> f64 { + pub fn external_end(&self) -> Beats { self.external_start + self.external_duration } - /// Get the end time of this clip instance on the timeline - /// (Alias for external_end(), for backwards compatibility) - pub fn end_time(&self) -> f64 { - self.external_end() + pub fn external_start_secs(&self, tempo_map: &TempoMap) -> Seconds { + tempo_map.beats_to_seconds(self.external_start) } - /// Get the start time on the timeline - /// (Alias for external_start, for backwards compatibility) - pub fn start_time(&self) -> f64 { - self.external_start + pub fn external_end_secs(&self, tempo_map: &TempoMap) -> Seconds { + tempo_map.beats_to_seconds(self.external_end()) } - /// Get the internal (content) duration - pub fn internal_duration(&self) -> f64 { + pub fn external_duration_secs(&self, tempo_map: &TempoMap) -> Seconds { + tempo_map.beats_to_seconds(self.external_end()) - tempo_map.beats_to_seconds(self.external_start) + } + + pub fn is_active_at(&self, time: Seconds, tempo_map: &TempoMap) -> bool { + time >= self.external_start_secs(tempo_map) && time < self.external_end_secs(tempo_map) + } + + pub fn internal_duration(&self) -> Seconds { self.internal_end - self.internal_start } - /// Check if this clip instance loops - pub fn is_looping(&self) -> bool { - self.external_duration > self.internal_duration() + pub fn is_looping(&self, tempo_map: &TempoMap) -> bool { + self.external_duration_secs(tempo_map) > self.internal_duration() } - /// Get the position within the audio content for a given timeline position - /// Returns None if the timeline position is outside this clip instance - /// Handles looping automatically - pub fn get_content_position(&self, timeline_pos: f64) -> Option { - if timeline_pos < self.external_start || timeline_pos >= self.external_end() { + /// Get the audio content position for a given timeline position. Handles looping. + pub fn get_content_position(&self, timeline_pos: Seconds, tempo_map: &TempoMap) -> Option { + let start_secs = self.external_start_secs(tempo_map); + let end_secs = self.external_end_secs(tempo_map); + if timeline_pos < start_secs || timeline_pos >= end_secs { return None; } - - let relative_pos = timeline_pos - self.external_start; + let relative_pos = timeline_pos - start_secs; let internal_duration = self.internal_duration(); - - if internal_duration <= 0.0 { + if internal_duration.0 <= 0.0 { return None; } - - // Wrap around for looping let content_offset = relative_pos % internal_duration; Some(self.internal_start + content_offset) } - /// Set clip gain pub fn set_gain(&mut self, gain: f32) { self.gain = gain.max(0.0); } - - /// Populate beats/frames from the current seconds values. - pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { - self.external_start_beats = self.external_start * bpm / 60.0; - self.external_start_frames = self.external_start * fps; - self.external_duration_beats = self.external_duration * bpm / 60.0; - self.external_duration_frames = self.external_duration * fps; - self.internal_start_beats = self.internal_start * bpm / 60.0; - self.internal_start_frames = self.internal_start * fps; - self.internal_end_beats = self.internal_end * bpm / 60.0; - self.internal_end_frames = self.internal_end * fps; - } - - /// BPM changed; recompute seconds/frames from the stored beats values. - pub fn apply_beats(&mut self, bpm: f64, fps: f64) { - self.external_start = self.external_start_beats * 60.0 / bpm; - self.external_start_frames = self.external_start * fps; - self.external_duration = self.external_duration_beats * 60.0 / bpm; - self.external_duration_frames = self.external_duration * fps; - self.internal_start = self.internal_start_beats * 60.0 / bpm; - self.internal_start_frames = self.internal_start * fps; - self.internal_end = self.internal_end_beats * 60.0 / bpm; - self.internal_end_frames = self.internal_end * fps; - } - - /// FPS changed; recompute seconds/beats from the stored frames values. - pub fn apply_frames(&mut self, fps: f64, bpm: f64) { - self.external_start = self.external_start_frames / fps; - self.external_start_beats = self.external_start * bpm / 60.0; - self.external_duration = self.external_duration_frames / fps; - self.external_duration_beats = self.external_duration * bpm / 60.0; - self.internal_start = self.internal_start_frames / fps; - self.internal_start_beats = self.internal_start * bpm / 60.0; - self.internal_end = self.internal_end_frames / fps; - self.internal_end_beats = self.internal_end * bpm / 60.0; - } } diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 622b211..1547993 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -9,6 +9,7 @@ use crate::audio::recording::{MidiRecordingState, RecordingState}; use crate::audio::track::{Track, TrackId, TrackNode}; use crate::command::{AudioEvent, Command, Query, QueryResponse}; use crate::io::MidiInputManager; +use crate::time::{Beats, Seconds}; use petgraph::stable_graph::NodeIndex; use std::collections::HashMap; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; @@ -109,9 +110,8 @@ pub struct Engine { timing_sum_total_us: u64, timing_overrun_count: u64, - // Current tempo/framerate — kept in sync with SetTempo/ApplyBpmChange so that - // newly-created clip instances can be immediately synced via sync_from_seconds. - current_bpm: f64, + // Current tempo map — kept in sync with SetTempo/SetTempoMap commands. + tempo_map: crate::TempoMap, current_fps: f64, } @@ -189,7 +189,7 @@ impl Engine { timing_worst_render_us: 0, timing_sum_total_us: 0, timing_overrun_count: 0, - current_bpm: 120.0, + tempo_map: crate::TempoMap::constant(120.0), current_fps: 30.0, } } @@ -379,7 +379,7 @@ impl Engine { } // Convert playhead from frames to seconds for timeline-based rendering - let playhead_seconds = self.playhead as f64 / self.sample_rate as f64; + let playhead_seconds = Seconds(self.playhead as f64 / self.sample_rate as f64); // Reset per-clip read-ahead targets before rendering. self.project.reset_read_ahead_targets(); @@ -390,6 +390,7 @@ impl Engine { &self.audio_pool, &mut self.buffer_pool, playhead_seconds, + &self.tempo_map, self.sample_rate, self.channels, false, @@ -427,7 +428,8 @@ impl Engine { // Send MIDI recording progress if active if let Some(recording) = &self.midi_recording_state { - let current_time = self.playhead as f64 / self.sample_rate as f64; + let current_time_secs = Seconds(self.playhead as f64 / self.sample_rate as f64); + let current_time = self.tempo_map.seconds_to_beats(current_time_secs); let duration = current_time - recording.start_time; let notes = recording.get_notes_with_active(current_time); let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress( @@ -451,7 +453,7 @@ impl Engine { } else { // Not playing: render live MIDI (keyboard input + note-off tails) through the // normal group hierarchy so mixer gain is correctly applied. - let playhead_seconds = self.playhead as f64 / self.sample_rate as f64; + let playhead_seconds = Seconds(self.playhead as f64 / self.sample_rate as f64); if self.mix_buffer.len() != output.len() { self.mix_buffer.resize(output.len(), 0.0); } @@ -463,6 +465,7 @@ impl Engine { &self.audio_pool, &mut self.buffer_pool, playhead_seconds, + &self.tempo_map, self.sample_rate, self.channels, true, // live_only @@ -562,7 +565,8 @@ impl Engine { if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { clip.internal_end = clip.internal_start + duration; - clip.external_duration = duration; + let duration_beats = self.tempo_map.seconds_to_beats(duration); + clip.external_duration = duration_beats; } } @@ -734,20 +738,16 @@ impl Engine { } } Command::MoveClip(track_id, clip_id, new_start_time) => { - // Moving just changes external_start, external_duration stays the same - let bpm = self.current_bpm; - let fps = self.current_fps; + // Moving just changes external_start (beats), external_duration stays the same match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.external_start = new_start_time; - clip.sync_from_seconds(bpm, fps); + clip.external_start = Beats(new_start_time); } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) { - instance.external_start = new_start_time; - instance.sync_from_seconds(bpm, fps); + instance.external_start = Beats(new_start_time); } } _ => {} @@ -757,24 +757,19 @@ impl Engine { Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => { // Trim changes which portion of the source content is used // Also updates external_duration to match internal duration (no looping after trim) - let bpm = self.current_bpm; - let fps = self.current_fps; match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.internal_start = new_internal_start; - clip.internal_end = new_internal_end; - clip.external_duration = new_internal_end - new_internal_start; - clip.sync_from_seconds(bpm, fps); + clip.internal_start = Seconds(new_internal_start); + clip.internal_end = Seconds(new_internal_end); + clip.external_duration = Beats(new_internal_end - new_internal_start); } } Some(crate::audio::track::TrackNode::Midi(track)) => { - // Note: clip_id here is the pool clip ID, not instance ID if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { - instance.internal_start = new_internal_start; - instance.internal_end = new_internal_end; - instance.external_duration = new_internal_end - new_internal_start; - instance.sync_from_seconds(bpm, fps); + instance.internal_start = Beats(new_internal_start); + instance.internal_end = Beats(new_internal_end); + instance.external_duration = Beats(new_internal_end - new_internal_start); } } _ => {} @@ -782,21 +777,16 @@ impl Engine { self.refresh_clip_snapshot(); } Command::ExtendClip(track_id, clip_id, new_external_duration) => { - // Extend changes the external duration (enables looping if > internal duration) - let bpm = self.current_bpm; - let fps = self.current_fps; + // Extend changes the external duration (beats; enables looping if > internal duration) match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.external_duration = new_external_duration; - clip.sync_from_seconds(bpm, fps); + clip.external_duration = Beats(new_external_duration); } } Some(crate::audio::track::TrackNode::Midi(track)) => { - // Note: clip_id here is the pool clip ID, not instance ID if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { - instance.external_duration = new_external_duration; - instance.sync_from_seconds(bpm, fps); + instance.external_duration = Beats(new_external_duration); } } _ => {} @@ -823,7 +813,7 @@ impl Engine { } Command::SetOffset(track_id, offset) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.offset = offset; + metatrack.offset = Seconds(offset); } } Command::SetPitchShift(track_id, semitones) => { @@ -833,12 +823,12 @@ impl Engine { } Command::SetTrimStart(track_id, trim_start) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.trim_start = trim_start.max(0.0); + metatrack.trim_start = Seconds(trim_start.max(0.0)); } } Command::SetTrimEnd(track_id, trim_end) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.trim_end = trim_end.map(|t| t.max(0.0)); + metatrack.trim_end = trim_end.map(|t| Seconds(t.max(0.0))); } } Command::CreateAudioTrack(name, parent_id) => { @@ -915,14 +905,20 @@ impl Engine { } Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => { // Create a new clip instance with the pre-assigned clip_id - let mut clip = AudioClipInstance::from_legacy( + // start_time and duration are in beats; offset (internal_start) is seconds + let start_beats = Beats(start_time); + let end_beats = Beats(start_time + duration); + let start_secs = self.tempo_map.beats_to_seconds(start_beats); + let end_secs = self.tempo_map.beats_to_seconds(end_beats); + let content_dur_secs = (end_secs - start_secs).seconds_to_f64(); + let clip = AudioClipInstance::new( clip_id, pool_index, - start_time, - duration, - offset, + Seconds(offset), + Seconds(offset + content_dur_secs), + start_beats, + Beats(duration), ); - clip.sync_from_seconds(self.current_bpm, self.current_fps); // Add clip to track if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { @@ -945,13 +941,12 @@ impl Engine { let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); // Create clip content in the pool - let clip = MidiClip::empty(clip_id, duration, format!("MIDI Clip {}", clip_id)); + let clip = MidiClip::empty(clip_id, Beats(duration), format!("MIDI Clip {}", clip_id)); self.project.midi_clip_pool.add_existing_clip(clip); // Create an instance for this clip on the track let instance_id = self.project.next_midi_clip_instance_id(); - let mut instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time); - instance.sync_from_seconds(self.current_bpm, self.current_fps); + let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time)); if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { track.clip_instances.push(instance); @@ -965,12 +960,12 @@ impl Engine { // Add a MIDI note event to the specified clip in the pool // Note: clip_id here refers to the clip in the pool, not the instance if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { - // Timestamp is now in seconds (sample-rate independent) - let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); + // Timestamp is in beats (canonical) + let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); clip.add_event(note_on); // Add note off event - let note_off_time = time_offset + duration; + let note_off_time = Beats(time_offset + duration); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.add_event(note_off); } else { @@ -979,9 +974,9 @@ impl Engine { if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) { let actual_clip_id = instance.clip_id; if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) { - let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); + let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); clip.add_event(note_on); - let note_off_time = time_offset + duration; + let note_off_time = Beats(time_offset + duration); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.add_event(note_off); } @@ -991,14 +986,8 @@ impl Engine { } Command::AddLoadedMidiClip(track_id, clip, start_time) => { // Add a pre-loaded MIDI clip to the track with the given start time - let bpm = self.current_bpm; - let fps = self.current_fps; - if let Ok(instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) { - if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { - if let Some(inst) = track.clip_instances.iter_mut().find(|i| i.id == instance_id) { - inst.sync_from_seconds(bpm, fps); - } - } + if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { + // instance positions are already in beats; nothing to sync } self.refresh_clip_snapshot(); } @@ -1009,13 +998,13 @@ impl Engine { clip.events.clear(); // Add new events from the notes array - // Timestamps are now stored in seconds (sample-rate independent) + // Timestamps are in beats (canonical) for (start_time, note, velocity, duration) in notes { - let note_on = MidiEvent::note_on(start_time, 0, note, velocity); + let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity); clip.events.push(note_on); // Add note off event - let note_off_time = start_time + duration; + let note_off_time = Beats(start_time + duration); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.events.push(note_off); } @@ -1077,7 +1066,7 @@ impl Engine { } Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => { // Add an automation point to the specified lane - let point = crate::audio::AutomationPoint::new(time, value, curve); + let point = crate::audio::AutomationPoint::new(Beats(time), value, curve); match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { @@ -1103,17 +1092,17 @@ impl Engine { match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(lane) = track.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(time, tolerance); + lane.remove_point_at_time(Beats(time), Beats(tolerance)); } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(lane) = track.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(time, tolerance); + lane.remove_point_at_time(Beats(time), Beats(tolerance)); } } Some(crate::audio::track::TrackNode::Group(group)) => { if let Some(lane) = group.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(time, tolerance); + lane.remove_point_at_time(Beats(time), Beats(tolerance)); } } None => {} @@ -1250,9 +1239,9 @@ impl Engine { // If MIDI recording is active on this track, capture the event if let Some(recording) = &mut self.midi_recording_state { if recording.track_id == track_id { - let absolute_time = self.playhead as f64 / self.sample_rate as f64; - eprintln!("[MIDI_RECORDING] NoteOn captured: note={}, velocity={}, absolute_time={:.3}s, playhead={}, sample_rate={}", - note, velocity, absolute_time, self.playhead, self.sample_rate); + let absolute_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64)); + eprintln!("[MIDI_RECORDING] NoteOn captured: note={}, velocity={}, absolute_time={:.3} beats, playhead={}, sample_rate={}", + note, velocity, absolute_time.0, self.playhead, self.sample_rate); recording.note_on(note, velocity, absolute_time); } } @@ -1273,9 +1262,9 @@ impl Engine { // If MIDI recording is active on this track, capture the event if let Some(recording) = &mut self.midi_recording_state { if recording.track_id == track_id { - let absolute_time = self.playhead as f64 / self.sample_rate as f64; - eprintln!("[MIDI_RECORDING] NoteOff captured: note={}, absolute_time={:.3}s, playhead={}, sample_rate={}", - note, absolute_time, self.playhead, self.sample_rate); + let absolute_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64)); + eprintln!("[MIDI_RECORDING] NoteOff captured: note={}, absolute_time={:.3} beats, playhead={}, sample_rate={}", + note, absolute_time.0, self.playhead, self.sample_rate); recording.note_off(note, absolute_time); } } @@ -1303,14 +1292,7 @@ impl Engine { Command::SetTempo(bpm, time_sig) => { self.metronome.update_timing(bpm, time_sig); self.project.set_tempo(bpm, time_sig.0); - self.current_bpm = bpm as f64; - } - - Command::ApplyBpmChange(from_bpm, to_bpm, fps, midi_durations) => { - self.current_bpm = to_bpm; - self.current_fps = fps; - self.project.apply_bpm_change(from_bpm, to_bpm, fps, &midi_durations); - self.refresh_clip_snapshot(); + self.tempo_map.set_global_bpm(bpm as f64); } // Node graph commands @@ -2181,16 +2163,13 @@ impl Engine { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { // Downcast to AutomationInputNode using as_any_mut if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { - let mut keyframe = AutomationKeyframe { - time, - time_beats: 0.0, - time_frames: 0.0, + let keyframe = AutomationKeyframe { + time: Beats(time), value, interpolation, ease_out, ease_in, }; - keyframe.sync_from_seconds(self.current_bpm, self.current_fps); auto_node.add_keyframe(keyframe); } else { eprintln!("Node {} is not an AutomationInputNode", node_id); @@ -2208,7 +2187,7 @@ impl Engine { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { - auto_node.remove_keyframe_at_time(time, 0.001); // 1ms tolerance + auto_node.remove_keyframe_at_time(Beats(time), Beats(0.001)); // 1ms tolerance } else { eprintln!("Node {} is not an AutomationInputNode", node_id); } @@ -2517,7 +2496,7 @@ impl Engine { if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) { use crate::command::MidiClipData; QueryResponse::MidiClipData(Ok(MidiClipData { - duration: clip.duration, + duration: clip.duration.0, events: clip.events.clone(), })) } else { @@ -2547,7 +2526,7 @@ impl Engine { }.to_string(); AutomationKeyframeData { - time: kf.time, + time: kf.time.0, value: kf.value, interpolation: interpolation_str, ease_out: kf.ease_out, @@ -2752,19 +2731,9 @@ impl Engine { } } Query::AddMidiClipSync(track_id, clip, start_time) => { - // Add MIDI clip to track and return the instance ID - let bpm = self.current_bpm; - let fps = self.current_fps; - let result = match self.project.add_midi_clip_at(track_id, clip, start_time) { - Ok(instance_id) => { - // Sync beats/frames on the newly created instance - if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { - if let Some(inst) = track.clip_instances.iter_mut().find(|i| i.id == instance_id) { - inst.sync_from_seconds(bpm, fps); - } - } - QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) - } + // Add MIDI clip to track and return the instance ID (positions already in beats) + let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { + Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), }; self.refresh_clip_snapshot(); @@ -2772,10 +2741,9 @@ impl Engine { } Query::AddMidiClipInstanceSync(track_id, mut instance) => { // Add MIDI clip instance to track (clip must already be in pool) - // Assign instance ID + // Assign instance ID; positions are already in beats let instance_id = self.project.next_midi_clip_instance_id(); instance.id = instance_id; - instance.sync_from_seconds(self.current_bpm, self.current_fps); let result = match self.project.add_midi_clip_instance(track_id, instance) { Ok(_) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), @@ -2943,7 +2911,7 @@ impl Engine { } /// Handle starting a recording - fn handle_start_recording(&mut self, track_id: TrackId, start_time: f64) { + fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats) { use crate::io::WavWriter; use std::env; @@ -2966,10 +2934,10 @@ impl Engine { let clip = crate::audio::clip::Clip::new( clip_id, 0, // Temporary pool index, will be updated on finalization - 0.0, // internal_start - 0.0, // internal_end - Duration starts at 0, will be updated during recording - start_time, // external_start (timeline position) - start_time, // external_end - will be updated during recording + Seconds(0.0), // internal_start + Seconds(0.0), // internal_end - Duration starts at 0, will be updated during recording + start_time, // external_start (timeline position, already Beats) + Beats(0.0), // external_duration - will be updated during recording ); // Add clip to track @@ -3098,7 +3066,7 @@ impl Engine { } /// Handle starting MIDI recording - fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) { + fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { // Check if track exists and is a MIDI track if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) { // Create MIDI recording state @@ -3108,7 +3076,7 @@ impl Engine { // so they start at t=0 of the recording rather than being lost if let Some(held) = self.midi_held_notes.get(&track_id) { for (¬e, &velocity) in held { - eprintln!("[MIDI_RECORDING] Injecting held note {} vel {} at start_time {:.3}s", note, velocity, start_time); + eprintln!("[MIDI_RECORDING] Injecting held note {} vel {} at start_time {:.3}", note, velocity, start_time.0); recording_state.note_on(note, velocity, start_time); } } @@ -3135,8 +3103,8 @@ impl Engine { } // Close out any active notes at the current playhead position - let end_time = self.playhead as f64 / self.sample_rate as f64; - eprintln!("[MIDI_RECORDING] Closing active notes at time {}", end_time); + let end_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64)); + eprintln!("[MIDI_RECORDING] Closing active notes at time {}", end_time.0); recording.close_active_notes(end_time); let clip_id = recording.clip_id; @@ -3145,8 +3113,8 @@ impl Engine { let note_count = notes.len(); let recording_duration = end_time - recording.start_time; - eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3}s", - clip_id, track_id, note_count, recording_duration); + eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats", + clip_id, track_id, note_count, recording_duration.0); // Update the MIDI clip in the pool (new model: clips are stored centrally in the pool) eprintln!("[MIDI_RECORDING] Looking for clip {} in midi_clip_pool", clip_id); @@ -3155,41 +3123,37 @@ impl Engine { // Clear existing events clip.events.clear(); - // Update clip duration to match the actual recording time - clip.duration = recording_duration; + // Recording duration is already in beats (canonical) + let duration_beats = recording_duration; + clip.duration = duration_beats; - // Add new events from the recorded notes - // Timestamps are now stored in seconds (sample-rate independent) - let bpm = self.current_bpm; - let fps = self.current_fps; + // Add new events from the recorded notes. + // Recorded timestamps are already in beats (canonical). for (start_time, note, velocity, duration) in notes.iter() { - let mut note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity); - note_on.sync_from_seconds(bpm, fps); + let note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity); - eprintln!("[MIDI_RECORDING] Note {}: start_time={:.3}s, duration={:.3}s", - note, start_time, duration); + eprintln!("[MIDI_RECORDING] Note {}: start={:.3} beats, duration={:.3} beats", + note, start_time.0, duration.0); clip.events.push(note_on); // Add note off event let note_off_time = *start_time + *duration; - let mut note_off = MidiEvent::note_off(note_off_time, 0, *note, 64); - note_off.sync_from_seconds(bpm, fps); + let note_off = MidiEvent::note_off(note_off_time, 0, *note, 64); clip.events.push(note_off); } - // Sort events by timestamp (using partial_cmp for f64) + // Sort events by timestamp (using partial_cmp for Beats) clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); eprintln!("[MIDI_RECORDING] Updated clip {} with {} notes ({} events)", clip_id, note_count, clip.events.len()); // Also update the clip instance's internal_end and external_duration to match the recording duration if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { if let Some(instance) = track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) { - instance.internal_end = recording_duration; - instance.external_duration = recording_duration; - instance.sync_from_seconds(bpm, fps); - eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3}s, external_duration={:.3}s", - instance.internal_end, instance.external_duration); + instance.internal_end = duration_beats; + instance.external_duration = duration_beats; + eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3} beats, external_duration={:.3} beats", + instance.internal_end.0, instance.external_duration.0); } } } else { @@ -3643,7 +3607,7 @@ impl EngineController { /// Start recording on a track pub fn start_recording(&mut self, track_id: TrackId, start_time: f64) { - let _ = self.command_tx.push(Command::StartRecording(track_id, start_time)); + let _ = self.command_tx.push(Command::StartRecording(track_id, Beats(start_time))); } /// Stop the current recording @@ -3663,7 +3627,7 @@ impl EngineController { /// Start MIDI recording on a track pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) { - let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time)); + let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, Beats(start_time))); } /// Stop the current MIDI recording @@ -3705,10 +3669,6 @@ impl EngineController { /// automation keyframe times to preserve beat positions. /// `from_bpm` is the BPM before the change; `to_bpm` is the new BPM. /// Call this after move_clip() has been called for all affected clips. - pub fn apply_bpm_change(&mut self, from_bpm: f64, to_bpm: f64, fps: f64, midi_durations: Vec<(crate::audio::MidiClipId, f64)>) { - let _ = self.command_tx.push(Command::ApplyBpmChange(from_bpm, to_bpm, fps, midi_durations)); - } - // Node graph operations /// Add a node to a track's instrument graph diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs index 9d5bc00..bdc940b 100644 --- a/daw-backend/src/audio/export.rs +++ b/daw-backend/src/audio/export.rs @@ -2,6 +2,8 @@ use super::buffer_pool::BufferPool; use super::pool::AudioPool; use super::project::Project; use crate::command::AudioEvent; +use crate::tempo_map::TempoMap; +use crate::time::Seconds; use std::path::Path; /// Render chunk size for offline export. Matches the real-time playback buffer size @@ -42,10 +44,12 @@ pub struct ExportSettings { pub bit_depth: u16, /// MP3 bitrate in kbps (128, 192, 256, 320) pub mp3_bitrate: u32, - /// Start time in seconds - pub start_time: f64, - /// End time in seconds - pub end_time: f64, + /// Start time + pub start_time: Seconds, + /// End time + pub end_time: Seconds, + /// Tempo map for beat-position scheduling + pub tempo_map: TempoMap, } impl Default for ExportSettings { @@ -56,8 +60,9 @@ impl Default for ExportSettings { channels: 2, bit_depth: 16, mp3_bitrate: 320, - start_time: 0.0, - end_time: 60.0, + start_time: Seconds::ZERO, + end_time: Seconds(60.0), + tempo_map: TempoMap::constant(120.0), } } } @@ -79,15 +84,15 @@ pub fn export_audio>( { // Validate duration let duration = settings.end_time - settings.start_time; - if duration <= 0.0 { + if duration <= Seconds::ZERO { return Err(format!( "Export duration is zero or negative (start={:.3}s, end={:.3}s). \ Check that the timeline has content.", - settings.start_time, settings.end_time + settings.start_time.seconds_to_f64(), settings.end_time.seconds_to_f64() )); } - let total_frames = (duration * settings.sample_rate as f64).round() as usize; + let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize; if total_frames == 0 { return Err("Export would produce zero audio frames".to_string()); } @@ -148,11 +153,11 @@ pub fn render_to_memory( { // Calculate total number of frames let duration = settings.end_time - settings.start_time; - let total_frames = (duration * settings.sample_rate as f64).round() as usize; + let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize; let total_samples = total_frames * settings.channels as usize; println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}", - duration, total_frames, total_samples, settings.channels); + duration.seconds_to_f64(), total_frames, total_samples, settings.channels); let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize; @@ -178,6 +183,7 @@ pub fn render_to_memory( pool, &mut buffer_pool, playhead, + &settings.tempo_map, settings.sample_rate, settings.channels, false, @@ -185,9 +191,9 @@ pub fn render_to_memory( // Calculate how many samples we actually need from this chunk let remaining_time = settings.end_time - playhead; - let samples_needed = if remaining_time < chunk_duration { + let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration { // Calculate frames needed and ensure it's a whole number - let frames_needed = (remaining_time * settings.sample_rate as f64).round() as usize; + let frames_needed = (remaining_time.seconds_to_f64() * settings.sample_rate as f64).round() as usize; let samples = frames_needed * settings.channels as usize; // Ensure we don't exceed chunk size samples.min(chunk_samples) @@ -207,7 +213,7 @@ pub fn render_to_memory( }); } - playhead += chunk_duration; + playhead = playhead + Seconds(chunk_duration); } println!("Export: rendered {} samples total", all_samples.len()); @@ -361,7 +367,7 @@ fn export_mp3>( // Calculate rendering parameters let duration = settings.end_time - settings.start_time; - let total_frames = (duration * settings.sample_rate as f64).round() as usize; + let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize; let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize; let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64; @@ -396,6 +402,7 @@ fn export_mp3>( pool, &mut buffer_pool, playhead, + &settings.tempo_map, settings.sample_rate, settings.channels, false, @@ -403,8 +410,8 @@ fn export_mp3>( // Calculate how many samples we need from this chunk let remaining_time = settings.end_time - playhead; - let samples_needed = if remaining_time < chunk_duration { - ((remaining_time * settings.sample_rate as f64) as usize * settings.channels as usize) + let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration { + ((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize) .min(chunk_samples) } else { chunk_samples @@ -445,7 +452,7 @@ fn export_mp3>( } } - playhead += chunk_duration; + playhead = playhead + Seconds(chunk_duration); } // Encode any remaining samples as the final frame @@ -529,7 +536,7 @@ fn export_aac>( // Calculate rendering parameters let duration = settings.end_time - settings.start_time; - let total_frames = (duration * settings.sample_rate as f64).round() as usize; + let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize; let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize; let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64; @@ -564,6 +571,7 @@ fn export_aac>( pool, &mut buffer_pool, playhead, + &settings.tempo_map, settings.sample_rate, settings.channels, false, @@ -571,8 +579,8 @@ fn export_aac>( // Calculate how many samples we need from this chunk let remaining_time = settings.end_time - playhead; - let samples_needed = if remaining_time < chunk_duration { - ((remaining_time * settings.sample_rate as f64) as usize * settings.channels as usize) + let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration { + ((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize) .min(chunk_samples) } else { chunk_samples @@ -613,7 +621,7 @@ fn export_aac>( } } - playhead += chunk_duration; + playhead = playhead + Seconds(chunk_duration); } // Encode any remaining samples as the final frame diff --git a/daw-backend/src/audio/midi.rs b/daw-backend/src/audio/midi.rs index 79747b4..a941e7a 100644 --- a/daw-backend/src/audio/midi.rs +++ b/daw-backend/src/audio/midi.rs @@ -1,14 +1,10 @@ +use crate::time::Beats; + /// MIDI event representing a single MIDI message #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] pub struct MidiEvent { - /// Time position within the clip in seconds (sample-rate independent) - pub timestamp: f64, - /// Time position in beats (quarter-note beats from clip start); derived from timestamp - #[serde(default)] - pub timestamp_beats: f64, - /// Time position in frames; derived from timestamp - #[serde(default)] - pub timestamp_frames: f64, + /// Time position in beats (quarter-note beats) + pub timestamp: Beats, /// MIDI status byte (includes channel) pub status: u8, /// First data byte (note number, CC number, etc.) @@ -18,79 +14,28 @@ pub struct MidiEvent { } impl MidiEvent { - /// Create a new MIDI event - pub fn new(timestamp: f64, status: u8, data1: u8, data2: u8) -> Self { - Self { - timestamp, - timestamp_beats: 0.0, - timestamp_frames: 0.0, - status, - data1, - data2, - } + pub fn new(timestamp: Beats, status: u8, data1: u8, data2: u8) -> Self { + Self { timestamp, status, data1, data2 } } - /// Create a note on event - pub fn note_on(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self { - Self { - timestamp, - timestamp_beats: 0.0, - timestamp_frames: 0.0, - status: 0x90 | (channel & 0x0F), - data1: note, - data2: velocity, - } + pub fn note_on(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self { + Self { timestamp, status: 0x90 | (channel & 0x0F), data1: note, data2: velocity } } - /// Create a note off event - pub fn note_off(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self { - Self { - timestamp, - timestamp_beats: 0.0, - timestamp_frames: 0.0, - status: 0x80 | (channel & 0x0F), - data1: note, - data2: velocity, - } + pub fn note_off(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self { + Self { timestamp, status: 0x80 | (channel & 0x0F), data1: note, data2: velocity } } - /// Sync beats and frames from seconds (call after constructing or when seconds is canonical) - pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { - self.timestamp_beats = self.timestamp * bpm / 60.0; - self.timestamp_frames = self.timestamp * fps; - } - - /// Recompute seconds and frames from beats (call when BPM changes in Measures mode) - pub fn apply_beats(&mut self, bpm: f64, fps: f64) { - self.timestamp = self.timestamp_beats * 60.0 / bpm; - self.timestamp_frames = self.timestamp * fps; - } - - /// Recompute seconds and beats from frames (call when FPS changes in Frames mode) - pub fn apply_frames(&mut self, fps: f64, bpm: f64) { - self.timestamp = self.timestamp_frames / fps; - self.timestamp_beats = self.timestamp * bpm / 60.0; - } - - /// Check if this is a note on event (with non-zero velocity) pub fn is_note_on(&self) -> bool { (self.status & 0xF0) == 0x90 && self.data2 > 0 } - /// Check if this is a note off event (or note on with zero velocity) pub fn is_note_off(&self) -> bool { (self.status & 0xF0) == 0x80 || ((self.status & 0xF0) == 0x90 && self.data2 == 0) } - /// Get the MIDI channel (0-15) - pub fn channel(&self) -> u8 { - self.status & 0x0F - } - - /// Get the message type (upper 4 bits of status) - pub fn message_type(&self) -> u8 { - self.status & 0xF0 - } + pub fn channel(&self) -> u8 { self.status & 0x0F } + pub fn message_type(&self) -> u8 { self.status & 0xF0 } } /// MIDI clip ID type (for clips stored in the pool) @@ -99,240 +44,118 @@ pub type MidiClipId = u32; /// MIDI clip instance ID type (for instances placed on tracks) pub type MidiClipInstanceId = u32; -/// MIDI clip content - stores the actual MIDI events -/// -/// This represents the content data stored in the MidiClipPool. -/// Events have timestamps relative to the start of the clip (0.0 = clip beginning). +/// MIDI clip content — stores the actual MIDI events. +/// `duration` is in beats. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MidiClip { pub id: MidiClipId, pub events: Vec, - pub duration: f64, // Total content duration in seconds + /// Total content duration in beats + pub duration: Beats, pub name: String, } impl MidiClip { - /// Create a new MIDI clip with content - pub fn new(id: MidiClipId, events: Vec, duration: f64, name: String) -> Self { - let mut clip = Self { - id, - events, - duration, - name, - }; - // Sort events by timestamp + pub fn new(id: MidiClipId, events: Vec, duration: Beats, name: String) -> Self { + let mut clip = Self { id, events, duration, name }; clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); clip } - /// Create an empty MIDI clip - pub fn empty(id: MidiClipId, duration: f64, name: String) -> Self { - Self { - id, - events: Vec::new(), - duration, - name, - } + pub fn empty(id: MidiClipId, duration: Beats, name: String) -> Self { + Self { id, events: Vec::new(), duration, name } } - /// Add a MIDI event to the clip pub fn add_event(&mut self, event: MidiEvent) { self.events.push(event); - // Keep events sorted by timestamp self.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); } - /// Get events within a time range (relative to clip start) - /// This is used by MidiClipInstance to fetch events for a given portion - pub fn get_events_in_range(&self, start: f64, end: f64) -> Vec { - self.events - .iter() + /// Get events within a beat range (relative to clip start) + pub fn get_events_in_range(&self, start: Beats, end: Beats) -> Vec { + self.events.iter() .filter(|e| e.timestamp >= start && e.timestamp < end) .copied() .collect() } } -/// MIDI clip instance - a reference to MidiClip content with timeline positioning +/// MIDI clip instance — a reference to MidiClip content with timeline positioning. /// -/// ## Timing Model -/// - `internal_start` / `internal_end`: Define the region of the source clip to play (trimming) -/// - `external_start` / `external_duration`: Define where the instance appears on the timeline and how long -/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display -/// -/// ## Looping -/// If `external_duration` is greater than `internal_end - internal_start`, -/// the instance will seamlessly loop back to `internal_start` when it reaches `internal_end`. +/// All timing fields are in beats. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MidiClipInstance { pub id: MidiClipInstanceId, - pub clip_id: MidiClipId, // Reference to MidiClip in pool + pub clip_id: MidiClipId, - /// Start position within the clip content (seconds) - pub internal_start: f64, - #[serde(default)] pub internal_start_beats: f64, - #[serde(default)] pub internal_start_frames: f64, - /// End position within the clip content (seconds) - pub internal_end: f64, - #[serde(default)] pub internal_end_beats: f64, - #[serde(default)] pub internal_end_frames: f64, + /// Start of the trimmed region within the clip content (beats) + pub internal_start: Beats, + /// End of the trimmed region within the clip content (beats) + pub internal_end: Beats, - /// Start position on the timeline (seconds) - pub external_start: f64, - #[serde(default)] pub external_start_beats: f64, - #[serde(default)] pub external_start_frames: f64, - /// Duration on the timeline (seconds) - can be longer than internal duration for looping - pub external_duration: f64, - #[serde(default)] pub external_duration_beats: f64, - #[serde(default)] pub external_duration_frames: f64, + /// Start position on the timeline (beats) + pub external_start: Beats, + /// Duration on the timeline (beats); > internal duration = looping + pub external_duration: Beats, } impl MidiClipInstance { - /// Create a new MIDI clip instance pub fn new( id: MidiClipInstanceId, clip_id: MidiClipId, - internal_start: f64, - internal_end: f64, - external_start: f64, - external_duration: f64, + internal_start: Beats, + internal_end: Beats, + external_start: Beats, + external_duration: Beats, ) -> Self { - Self { - id, - clip_id, - internal_start, - internal_start_beats: 0.0, - internal_start_frames: 0.0, - internal_end, - internal_end_beats: 0.0, - internal_end_frames: 0.0, - external_start, - external_start_beats: 0.0, - external_start_frames: 0.0, - external_duration, - external_duration_beats: 0.0, - external_duration_frames: 0.0, - } + Self { id, clip_id, internal_start, internal_end, external_start, external_duration } } - /// Create an instance that uses the full clip content (no trimming, no looping) + /// Create an instance covering the full clip with no trim pub fn from_full_clip( id: MidiClipInstanceId, clip_id: MidiClipId, - clip_duration: f64, - external_start: f64, + clip_duration: Beats, + external_start: Beats, ) -> Self { Self { id, clip_id, - internal_start: 0.0, - internal_start_beats: 0.0, - internal_start_frames: 0.0, + internal_start: Beats::ZERO, internal_end: clip_duration, - internal_end_beats: 0.0, - internal_end_frames: 0.0, external_start, - external_start_beats: 0.0, - external_start_frames: 0.0, external_duration: clip_duration, - external_duration_beats: 0.0, - external_duration_frames: 0.0, } } - /// Get the internal (content) duration - pub fn internal_duration(&self) -> f64 { - self.internal_end - self.internal_start - } + pub fn internal_duration(&self) -> Beats { self.internal_end - self.internal_start } + pub fn external_end(&self) -> Beats { self.external_start + self.external_duration } + pub fn is_looping(&self) -> bool { self.external_duration > self.internal_duration() } - /// Get the end time on the timeline - pub fn external_end(&self) -> f64 { - self.external_start + self.external_duration - } - - /// Check if this instance loops - pub fn is_looping(&self) -> bool { - self.external_duration > self.internal_duration() - } - - /// Get the end time on the timeline (for backwards compatibility) - pub fn end_time(&self) -> f64 { - self.external_end() - } - - /// Get the start time on the timeline (for backwards compatibility) - pub fn start_time(&self) -> f64 { - self.external_start - } - - /// Check if this instance overlaps with a time range - pub fn overlaps_range(&self, range_start: f64, range_end: f64) -> bool { + /// Check if this instance overlaps with a beat range + pub fn overlaps_range(&self, range_start: Beats, range_end: Beats) -> bool { self.external_start < range_end && self.external_end() > range_start } - /// Populate beats/frames from the current seconds values. - pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { - self.external_start_beats = self.external_start * bpm / 60.0; - self.external_start_frames = self.external_start * fps; - self.external_duration_beats = self.external_duration * bpm / 60.0; - self.external_duration_frames = self.external_duration * fps; - self.internal_start_beats = self.internal_start * bpm / 60.0; - self.internal_start_frames = self.internal_start * fps; - self.internal_end_beats = self.internal_end * bpm / 60.0; - self.internal_end_frames = self.internal_end * fps; - } - - /// BPM changed; recompute seconds/frames from the stored beats values. - pub fn apply_beats(&mut self, bpm: f64, fps: f64) { - self.external_start = self.external_start_beats * 60.0 / bpm; - self.external_start_frames = self.external_start * fps; - self.external_duration = self.external_duration_beats * 60.0 / bpm; - self.external_duration_frames = self.external_duration * fps; - self.internal_start = self.internal_start_beats * 60.0 / bpm; - self.internal_start_frames = self.internal_start * fps; - self.internal_end = self.internal_end_beats * 60.0 / bpm; - self.internal_end_frames = self.internal_end * fps; - } - - /// FPS changed; recompute seconds/beats from the stored frames values. - pub fn apply_frames(&mut self, fps: f64, bpm: f64) { - self.external_start = self.external_start_frames / fps; - self.external_start_beats = self.external_start * bpm / 60.0; - self.external_duration = self.external_duration_frames / fps; - self.external_duration_beats = self.external_duration * bpm / 60.0; - self.internal_start = self.internal_start_frames / fps; - self.internal_start_beats = self.internal_start * bpm / 60.0; - self.internal_end = self.internal_end_frames / fps; - self.internal_end_beats = self.internal_end * bpm / 60.0; - } - - /// Get events that should be triggered in a given timeline range - /// - /// This handles: - /// - Trimming (internal_start/internal_end) - /// - Looping (when external duration > internal duration) - /// - Time mapping from timeline to clip content - /// - /// Returns events with timestamps adjusted to timeline time (not clip-relative) + /// Get events that should fire in a given beat range on the timeline. + /// Returns events with `timestamp` set to their global timeline beat position. pub fn get_events_in_range( &self, clip: &MidiClip, - range_start_seconds: f64, - range_end_seconds: f64, + range_start: Beats, + range_end: Beats, ) -> Vec { let mut result = Vec::new(); - // Check if instance overlaps with the range - if !self.overlaps_range(range_start_seconds, range_end_seconds) { + if !self.overlaps_range(range_start, range_end) { return result; } let internal_duration = self.internal_duration(); - if internal_duration <= 0.0 { + if internal_duration <= Beats::ZERO { return result; } - // Calculate how many complete loops fit in the external duration let num_loops = if self.external_duration > internal_duration { (self.external_duration / internal_duration).ceil() as usize } else { @@ -342,24 +165,18 @@ impl MidiClipInstance { let external_end = self.external_end(); for loop_idx in 0..num_loops { - let loop_offset = loop_idx as f64 * internal_duration; + let loop_offset = internal_duration * loop_idx as f64; - // Get events from the clip that fall within the internal range for event in &clip.events { - // Skip events outside the trimmed region - // Use > (not >=) for internal_end so note-offs at the clip boundary are included if event.timestamp < self.internal_start || event.timestamp > self.internal_end { continue; } - // Convert to timeline time let relative_content_time = event.timestamp - self.internal_start; let timeline_time = self.external_start + loop_offset + relative_content_time; - // Check if within current buffer range and instance bounds - // Use <= for external_end so note-offs at the clip boundary are included - if timeline_time >= range_start_seconds - && timeline_time < range_end_seconds + if timeline_time >= range_start + && timeline_time < range_end && timeline_time <= external_end { let mut adjusted_event = *event; diff --git a/daw-backend/src/audio/midi_pool.rs b/daw-backend/src/audio/midi_pool.rs index b368d72..70b7c33 100644 --- a/daw-backend/src/audio/midi_pool.rs +++ b/daw-backend/src/audio/midi_pool.rs @@ -1,6 +1,7 @@ use serde::{Serialize, Deserialize}; use std::collections::HashMap; use super::midi::{MidiClip, MidiClipId, MidiEvent}; +use crate::time::Beats; /// Pool for storing MIDI clip content /// Similar to AudioClipPool but for MIDI data @@ -21,7 +22,7 @@ impl MidiClipPool { /// Add a new clip to the pool with the given events and duration /// Returns the ID of the newly created clip - pub fn add_clip(&mut self, events: Vec, duration: f64, name: String) -> MidiClipId { + pub fn add_clip(&mut self, events: Vec, duration: Beats, name: String) -> MidiClipId { let id = self.next_id; self.next_id += 1; diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index 9f9f9c6..fc77d49 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -1,6 +1,7 @@ use super::node_trait::AudioNode; use super::types::{ConnectionError, SignalType}; use crate::audio::midi::MidiEvent; +use crate::time::Beats; use petgraph::algo::has_path_connecting; use petgraph::stable_graph::{NodeIndex, StableGraph}; use petgraph::visit::{EdgeRef, IntoEdgeReferences}; @@ -93,8 +94,8 @@ pub struct AudioGraph { /// UI positions for nodes (node_index -> (x, y)) node_positions: std::collections::HashMap, - /// Current playback time (for automation nodes) - playback_time: f64, + /// Current playback time in beats (for automation nodes) + playback_time: Beats, /// Project tempo (synced from Engine via SetTempo) bpm: f32, @@ -123,7 +124,7 @@ impl AudioGraph { // Pre-allocate MIDI input buffers (max 128 events per port) midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(), node_positions: std::collections::HashMap::new(), - playback_time: 0.0, + playback_time: Beats::ZERO, bpm: 120.0, beats_per_bar: 4, topo_cache: None, @@ -475,7 +476,7 @@ impl AudioGraph { } /// Process the graph and produce audio output - pub fn process(&mut self, output_buffer: &mut [f32], midi_events: &[MidiEvent], playback_time: f64) { + pub fn process(&mut self, output_buffer: &mut [f32], midi_events: &[MidiEvent], playback_time: Beats) { // Update playback time self.playback_time = playback_time; @@ -484,6 +485,7 @@ impl AudioGraph { for node in self.graph.node_weights_mut() { if let Some(auto_node) = node.node.as_any_mut().downcast_mut::() { auto_node.set_playback_time(playback_time); + auto_node.set_bpm(self.bpm as f64); } else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::() { beat_node.set_playback_time(playback_time); beat_node.set_tempo(self.bpm, self.beats_per_bar); @@ -737,18 +739,6 @@ impl AudioGraph { self.graph.node_indices() } - /// BPM changed: rescale all AutomationInput keyframe times to preserve beat positions. - /// `from_bpm` is the BPM before the change (used to bootstrap beats if not yet populated). - /// `to_bpm` is the new BPM (used to re-derive seconds from beats). - pub fn apply_beats_to_automation_keyframes(&mut self, from_bpm: f64, to_bpm: f64, fps: f64) { - use super::nodes::AutomationInputNode; - for node in self.graph.node_weights_mut() { - if let Some(auto_node) = node.node.as_any_mut().downcast_mut::() { - auto_node.apply_beats_to_keyframes(from_bpm, to_bpm, fps); - } - } - } - /// Reallocate a node's output buffers to match its current port list. /// /// Must be called after `SubtrackInputsNode::update_subtracks` changes the port count, @@ -1029,7 +1019,7 @@ impl AudioGraph { serialized.automation_display_name = Some(auto_node.display_name().to_string()); serialized.automation_keyframes = auto_node.keyframes().iter().map(|kf| { SerializedKeyframe { - time: kf.time, + time: kf.time.0, value: kf.value, interpolation: match kf.interpolation { InterpolationType::Linear => "linear", @@ -1369,9 +1359,7 @@ impl AudioGraph { auto_node.clear_keyframes(); for kf in &serialized_node.automation_keyframes { auto_node.add_keyframe(AutomationKeyframe { - time: kf.time, - time_beats: 0.0, - time_frames: 0.0, + time: crate::time::Beats(kf.time), value: kf.value, interpolation: match kf.interpolation.as_str() { "bezier" => InterpolationType::Bezier, diff --git a/daw-backend/src/audio/node_graph/nodes/automation_input.rs b/daw-backend/src/audio/node_graph/nodes/automation_input.rs index 0dc9d43..6f28ac8 100644 --- a/daw-backend/src/audio/node_graph/nodes/automation_input.rs +++ b/daw-backend/src/audio/node_graph/nodes/automation_input.rs @@ -1,5 +1,6 @@ use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; use crate::audio::midi::MidiEvent; +use crate::time::Beats; use serde::{Deserialize, Serialize}; use std::sync::{Arc, RwLock}; @@ -16,14 +17,7 @@ pub enum InterpolationType { /// A single keyframe in an automation curve #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AutomationKeyframe { - /// Time in seconds (absolute project time) - pub time: f64, - /// Time in beats (derived; canonical in Measures mode) - #[serde(default)] - pub time_beats: f64, - /// Time in frames (derived; canonical in Frames mode) - #[serde(default)] - pub time_frames: f64, + pub time: Beats, /// CV output value pub value: f32, /// Interpolation type to next keyframe @@ -35,35 +29,15 @@ pub struct AutomationKeyframe { } impl AutomationKeyframe { - pub fn new(time: f64, value: f32) -> Self { + pub fn new(time: Beats, value: f32) -> Self { Self { time, - time_beats: 0.0, - time_frames: 0.0, value, interpolation: InterpolationType::Linear, ease_out: (0.58, 1.0), ease_in: (0.42, 0.0), } } - - /// Populate beats/frames from the current seconds value. - pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { - self.time_beats = self.time * bpm / 60.0; - self.time_frames = self.time * fps; - } - - /// BPM changed; beats are canonical → recompute seconds and frames. - pub fn apply_beats(&mut self, bpm: f64, fps: f64) { - self.time = self.time_beats * 60.0 / bpm; - self.time_frames = self.time * fps; - } - - /// FPS changed; frames are canonical → recompute seconds and beats. - pub fn apply_frames(&mut self, fps: f64, bpm: f64) { - self.time = self.time_frames / fps; - self.time_beats = self.time * bpm / 60.0; - } } /// Automation Input Node - outputs CV signal controlled by timeline curves @@ -73,8 +47,10 @@ pub struct AutomationInputNode { keyframes: Vec, outputs: Vec, parameters: Vec, - /// Shared playback time (set by the graph before processing) - playback_time: Arc>, + /// Shared playback time in beats (set by the graph before processing) + playback_time: Arc>, + /// Current BPM (set by the graph before processing, used for per-sample beat advancement) + bpm: f64, /// Minimum output value (for UI display range) pub value_min: f32, /// Maximum output value (for UI display range) @@ -97,22 +73,26 @@ impl AutomationInputNode { Self { name: name.clone(), display_name: "Automation".to_string(), - keyframes: vec![AutomationKeyframe::new(0.0, 0.0)], + keyframes: vec![AutomationKeyframe::new(Beats::ZERO, 0.0)], outputs, parameters, - playback_time: Arc::new(RwLock::new(0.0)), + playback_time: Arc::new(RwLock::new(Beats::ZERO)), + bpm: 120.0, value_min: -1.0, value_max: 1.0, } } - /// Set the playback time (called by graph before processing) - pub fn set_playback_time(&mut self, time: f64) { + pub fn set_playback_time(&mut self, time: Beats) { if let Ok(mut playback) = self.playback_time.write() { *playback = time; } } + pub fn set_bpm(&mut self, bpm: f64) { + self.bpm = bpm; + } + /// Get the display name (shown in UI) pub fn display_name(&self) -> &str { &self.display_name @@ -142,8 +122,7 @@ impl AutomationInputNode { } } - /// Remove keyframe at specific time (with tolerance) - pub fn remove_keyframe_at_time(&mut self, time: f64, tolerance: f64) -> bool { + pub fn remove_keyframe_at_time(&mut self, time: Beats, tolerance: Beats) -> bool { if let Some(idx) = self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) { self.keyframes.remove(idx); true @@ -152,10 +131,8 @@ impl AutomationInputNode { } } - /// Update an existing keyframe pub fn update_keyframe(&mut self, keyframe: AutomationKeyframe) { - // Remove old keyframe at this time, then add new one - self.remove_keyframe_at_time(keyframe.time, 0.001); + self.remove_keyframe_at_time(keyframe.time, Beats(0.001)); self.add_keyframe(keyframe); } @@ -169,42 +146,20 @@ impl AutomationInputNode { self.keyframes.clear(); } - /// Populate beats/frames on all keyframes from their current seconds values. - pub fn sync_keyframes_from_seconds(&mut self, bpm: f64, fps: f64) { - for kf in &mut self.keyframes { - kf.sync_from_seconds(bpm, fps); - } - } - - /// BPM changed: for each keyframe, bootstrap beats from seconds (using `from_bpm`) if not yet - /// set, then re-derive seconds and frames from beats using `to_bpm`. - pub fn apply_beats_to_keyframes(&mut self, from_bpm: f64, to_bpm: f64, fps: f64) { - for kf in &mut self.keyframes { - if kf.time_beats == 0.0 && kf.time.abs() > 1e-9 { - kf.sync_from_seconds(from_bpm, fps); - } - kf.apply_beats(to_bpm, fps); - } - } - - /// Evaluate curve at a specific time - fn evaluate_at_time(&self, time: f64) -> f32 { + fn evaluate_at_time(&self, time: Beats) -> f32 { if self.keyframes.is_empty() { return 0.0; } - // Before first keyframe if time <= self.keyframes[0].time { return self.keyframes[0].value; } - // After last keyframe let last_idx = self.keyframes.len() - 1; if time >= self.keyframes[last_idx].time { return self.keyframes[last_idx].value; } - // Find bracketing keyframes for i in 0..self.keyframes.len() - 1 { let kf1 = &self.keyframes[i]; let kf2 = &self.keyframes[i + 1]; @@ -217,14 +172,12 @@ impl AutomationInputNode { 0.0 } - /// Interpolate between two keyframes - fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: f64) -> f32 { - // Calculate normalized position between keyframes (0.0 to 1.0) + fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: Beats) -> f32 { let t = if kf2.time == kf1.time { - 0.0 + 0.0f64 } else { - ((time - kf1.time) / (kf2.time - kf1.time)) as f32 - }; + (time - kf1.time) / (kf2.time - kf1.time) + } as f32; match kf1.interpolation { InterpolationType::Linear => { @@ -301,19 +254,17 @@ impl AudioNode for AutomationInputNode { let output = &mut outputs[0]; let length = output.len(); - // Get the starting playback time let playhead = if let Ok(playback) = self.playback_time.read() { *playback } else { - 0.0 + Beats::ZERO }; - // Calculate time per sample - let sample_duration = 1.0 / sample_rate as f64; + // Advance per sample in beats: beats_per_sample = bpm / 60 / sample_rate + let beats_per_sample = self.bpm / 60.0 / sample_rate as f64; - // Evaluate curve for each sample for i in 0..length { - let time = playhead + (i as f64 * sample_duration); + let time = playhead + Beats(i as f64 * beats_per_sample); output[i] = self.evaluate_at_time(time); } } @@ -337,7 +288,8 @@ impl AudioNode for AutomationInputNode { keyframes: self.keyframes.clone(), outputs: self.outputs.clone(), parameters: self.parameters.clone(), - playback_time: Arc::new(RwLock::new(0.0)), + playback_time: Arc::new(RwLock::new(Beats::ZERO)), + bpm: self.bpm, value_min: self.value_min, value_max: self.value_max, }) diff --git a/daw-backend/src/audio/node_graph/nodes/beat.rs b/daw-backend/src/audio/node_graph/nodes/beat.rs index 6b0113d..9f14111 100644 --- a/daw-backend/src/audio/node_graph/nodes/beat.rs +++ b/daw-backend/src/audio/node_graph/nodes/beat.rs @@ -1,5 +1,6 @@ use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; use crate::audio::midi::MidiEvent; +use crate::time::Beats; const PARAM_RESOLUTION: u32 = 0; @@ -61,12 +62,12 @@ pub struct BeatNode { bpm: f32, beats_per_bar: u32, resolution: BeatResolution, - /// Playback time in seconds, set by the graph before process() - playback_time: f64, + /// Playback time in beats, set by the graph before process() + playback_time: Beats, /// Previous playback_time to detect paused state - prev_playback_time: f64, - /// Free-running time accumulator for when playback is stopped - free_run_time: f64, + prev_playback_time: Beats, + /// Free-running beat accumulator for when playback is stopped + free_run_time: Beats, inputs: Vec, outputs: Vec, parameters: Vec, @@ -92,16 +93,16 @@ impl BeatNode { bpm: DEFAULT_BPM, beats_per_bar: DEFAULT_BEATS_PER_BAR, resolution: BeatResolution::Quarter, - playback_time: 0.0, - prev_playback_time: -1.0, - free_run_time: 0.0, + playback_time: Beats::ZERO, + prev_playback_time: Beats(-1.0), + free_run_time: Beats::ZERO, inputs, outputs, parameters, } } - pub fn set_playback_time(&mut self, time: f64) { + pub fn set_playback_time(&mut self, time: Beats) { self.playback_time = time; } @@ -169,8 +170,8 @@ impl AudioNode for BeatNode { let base_time = if paused { self.free_run_time } else { self.playback_time }; for i in 0..len { - let time = base_time + i as f64 * sample_period; - let beat_pos = time * beats_per_second; + // base_time is already in beats; advance by beats_per_second per second + let beat_pos = base_time.0 + i as f64 * beats_per_second * sample_period; // Beat subdivision phase: 0→1 sawtooth let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32; @@ -188,13 +189,13 @@ impl AudioNode for BeatNode { } // Advance free-run time (always ticks, so it's ready when playback stops) - self.free_run_time += len as f64 * sample_period; + self.free_run_time += Beats(len as f64 * beats_per_second * sample_period); } fn reset(&mut self) { - self.playback_time = 0.0; - self.prev_playback_time = -1.0; - self.free_run_time = 0.0; + self.playback_time = Beats::ZERO; + self.prev_playback_time = Beats(-1.0); + self.free_run_time = Beats::ZERO; } fn node_type(&self) -> &str { @@ -211,9 +212,9 @@ impl AudioNode for BeatNode { bpm: self.bpm, beats_per_bar: self.beats_per_bar, resolution: self.resolution, - playback_time: 0.0, - prev_playback_time: -1.0, - free_run_time: 0.0, + playback_time: Beats::ZERO, + prev_playback_time: Beats(-1.0), + free_run_time: Beats::ZERO, inputs: self.inputs.clone(), outputs: self.outputs.clone(), parameters: self.parameters.clone(), diff --git a/daw-backend/src/audio/node_graph/nodes/sequencer.rs b/daw-backend/src/audio/node_graph/nodes/sequencer.rs index 4cd928b..14ac7d3 100644 --- a/daw-backend/src/audio/node_graph/nodes/sequencer.rs +++ b/daw-backend/src/audio/node_graph/nodes/sequencer.rs @@ -1,5 +1,6 @@ use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; use crate::audio::midi::MidiEvent; +use crate::time::Beats; const PARAM_MODE: u32 = 0; const PARAM_STEPS: u32 = 1; @@ -244,14 +245,14 @@ impl AudioNode for SequencerNode { // Note-off for notes no longer active for ¬e in &self.prev_active_notes { if !new_notes.contains(¬e) { - midi_outputs[0].push(MidiEvent::note_off(0.0, 0, note, 0)); + midi_outputs[0].push(MidiEvent::note_off(Beats::ZERO, 0, note, 0)); } } // Note-on for newly active notes for ¬e in &new_notes { if !self.prev_active_notes.contains(¬e) { - midi_outputs[0].push(MidiEvent::note_on(0.0, 0, note, self.velocity)); + midi_outputs[0].push(MidiEvent::note_on(Beats::ZERO, 0, note, self.velocity)); } } diff --git a/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs b/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs index e43c9e3..8e0cb3c 100644 --- a/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs +++ b/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs @@ -348,7 +348,7 @@ impl AudioNode for VoiceAllocatorNode { // Process this voice's graph with its MIDI events // Note: playback_time is 0.0 since voice allocator doesn't track time - self.voice_instances[voice_idx].process(mix_slice, &midi_events, 0.0); + self.voice_instances[voice_idx].process(mix_slice, &midi_events, crate::time::Beats::ZERO); // Auto-deactivate releasing voices that have gone silent if voice_state.releasing { diff --git a/daw-backend/src/audio/pool.rs b/daw-backend/src/audio/pool.rs index ebf46f4..ecf0745 100644 --- a/daw-backend/src/audio/pool.rs +++ b/daw-backend/src/audio/pool.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::f32::consts::PI; use serde::{Deserialize, Serialize}; +use crate::time::Seconds; /// Windowed sinc interpolation for high-quality time stretching /// This is stateless and can handle arbitrary fractional positions @@ -442,19 +443,20 @@ impl AudioClipPool { } /// Render audio from a file in the pool with high-quality windowed sinc interpolation - /// start_time_seconds: position in the audio file to start reading from (in seconds) + /// start_time: position in the audio file to start reading from (in seconds) /// clip_read_ahead: per-clip-instance read-ahead buffer for compressed audio streaming /// Returns the number of samples actually rendered pub fn render_from_file( &self, pool_index: usize, output: &mut [f32], - start_time_seconds: f64, + start_time: Seconds, gain: f32, engine_sample_rate: u32, engine_channels: u32, clip_read_ahead: Option<&super::disk_reader::ReadAheadBuffer>, ) -> usize { + let start_time_seconds = start_time.0; let Some(audio_file) = self.files.get(pool_index) else { return 0; }; diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index 967b34e..d301445 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -4,6 +4,8 @@ use super::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, Mi use super::midi_pool::MidiClipPool; use super::pool::AudioClipPool; use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode}; +use crate::tempo_map::TempoMap; +use crate::time::{Beats, Seconds}; use serde::{Serialize, Deserialize}; use std::collections::HashMap; @@ -216,52 +218,6 @@ impl Project { self.tracks.iter().map(|(&id, node)| (id, node)) } - /// After a BPM change, update MIDI clip durations, sync clip beats/frames, and rescale - /// automation keyframe times to preserve beat positions. - /// - /// `from_bpm` is the BPM before the change (used to bootstrap beats on legacy data). - /// `to_bpm` is the new BPM. - /// `midi_durations` maps each MidiClipId to its new content duration in seconds. - /// Call this after the seconds positions have already been updated (e.g. via MoveClip). - pub fn apply_bpm_change(&mut self, from_bpm: f64, to_bpm: f64, fps: f64, midi_durations: &[(crate::audio::midi::MidiClipId, f64)]) { - for (_, track) in self.tracks.iter_mut() { - match track { - crate::audio::track::TrackNode::Audio(t) => { - for clip in &mut t.clips { - clip.sync_from_seconds(to_bpm, fps); - } - } - crate::audio::track::TrackNode::Midi(t) => { - // Rescale automation keyframe times in this track's graph - t.instrument_graph.apply_beats_to_automation_keyframes(from_bpm, to_bpm, fps); - - // Update content durations first so internal_end is correct before sync - for instance in &mut t.clip_instances { - if let Some(&new_dur) = midi_durations.iter() - .find(|(id, _)| *id == instance.clip_id) - .map(|(_, d)| d) - { - let old_internal_dur = instance.internal_duration(); - instance.internal_end = instance.internal_start + new_dur; - // Scale external_duration by the same ratio (works for both looping and non-looping) - if old_internal_dur > 1e-12 { - instance.external_duration = instance.external_duration * new_dur / old_internal_dur; - } - } - instance.sync_from_seconds(to_bpm, fps); - } - // Update pool clip durations - for &(clip_id, new_dur) in midi_durations { - if let Some(clip) = self.midi_clip_pool.get_clip_mut(clip_id) { - clip.duration = new_dur; - } - } - } - _ => {} - } - } - } - /// Get oscilloscope data from a node in a track's graph pub fn get_oscilloscope_data(&self, track_id: TrackId, node_id: u32, sample_count: usize) -> Option<(Vec, Vec)> { if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) { @@ -334,9 +290,9 @@ impl Project { &mut self, track_id: TrackId, events: Vec, - duration: f64, + duration: Beats, name: String, - external_start: f64, + external_start: Beats, ) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> { // Verify track exists and is a MIDI track if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) { @@ -369,11 +325,11 @@ impl Project { /// Legacy method for backwards compatibility - creates clip and instance from old MidiClip format pub fn add_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) -> Result { - self.add_midi_clip_at(track_id, clip, 0.0) + self.add_midi_clip_at(track_id, clip, Beats::ZERO) } /// Add a MIDI clip to the pool and create an instance at the given timeline position - pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: f64) -> Result { + pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) -> Result { // Add the clip to the pool (it already has events and duration) let duration = clip.duration; let clip_id = clip.id; @@ -417,7 +373,8 @@ impl Project { output: &mut [f32], audio_pool: &AudioClipPool, buffer_pool: &mut BufferPool, - playhead_seconds: f64, + playhead_seconds: Seconds, + tempo_map: &TempoMap, sample_rate: u32, channels: u32, live_only: bool, @@ -429,7 +386,7 @@ impl Project { // Create initial render context let ctx = RenderContext { live_only, - ..RenderContext::new(playhead_seconds, sample_rate, channels, output.len()) + ..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len()) }; // Render each root track (index-based to avoid clone) @@ -503,7 +460,7 @@ impl Project { let mut track_buffer = buffer_pool.acquire(); track_buffer.resize(output.len(), 0.0); track_buffer.fill(0.0); - track.render(&mut track_buffer, audio_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels); + track.render(&mut track_buffer, audio_pool, ctx); // Accumulate peak level for VU metering (max over meter interval) let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max); track.peak_level = track.peak_level.max(buffer_peak); @@ -591,7 +548,7 @@ impl Project { let mut graph_output = buffer_pool.acquire(); graph_output.resize(output.len(), 0.0); graph_output.fill(0.0); - group.audio_graph.process(&mut graph_output, &[], ctx.playhead_seconds); + group.audio_graph.process(&mut graph_output, &[], ctx.playhead_beats()); for (out_sample, graph_sample) in output.iter_mut().zip(graph_output.iter()) { *out_sample += graph_sample * group.volume; @@ -686,7 +643,7 @@ impl Project { pub fn send_midi_note_on(&mut self, track_id: TrackId, note: u8, velocity: u8) { // Queue the MIDI note-on event to the track's live MIDI queue if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) { - let event = MidiEvent::note_on(0.0, 0, note, velocity); + let event = MidiEvent::note_on(Beats::ZERO, 0, note, velocity); track.queue_live_midi(event); } } @@ -695,7 +652,7 @@ impl Project { pub fn send_midi_note_off(&mut self, track_id: TrackId, note: u8) { // Queue the MIDI note-off event to the track's live MIDI queue if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) { - let event = MidiEvent::note_off(0.0, 0, note, 0); + let event = MidiEvent::note_off(Beats::ZERO, 0, note, 0); track.queue_live_midi(event); } } diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index 392da27..8203081 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -1,6 +1,7 @@ /// Audio recording system for capturing microphone input use crate::audio::{ClipId, MidiClipId, TrackId}; use crate::io::{WavWriter, WaveformPeak}; +use crate::time::{Beats, Seconds}; use std::collections::HashMap; use std::path::PathBuf; @@ -18,8 +19,8 @@ pub struct RecordingState { pub sample_rate: u32, /// Number of channels pub channels: u32, - /// Timeline start position in seconds - pub start_time: f64, + /// Timeline start position + pub start_time: Beats, /// Total frames recorded pub frames_written: usize, /// Whether recording is currently paused @@ -45,7 +46,7 @@ impl RecordingState { writer: WavWriter, sample_rate: u32, channels: u32, - start_time: f64, + start_time: Beats, _flush_interval_seconds: f64, // No longer used - kept for API compatibility ) -> Self { // Calculate frames per waveform peak @@ -130,9 +131,9 @@ impl RecordingState { } } - /// Get current recording duration in seconds - pub fn duration(&self) -> f64 { - self.frames_written as f64 / self.sample_rate as f64 + /// Get current recording duration + pub fn duration(&self) -> Seconds { + Seconds(self.frames_written as f64 / self.sample_rate as f64) } /// Finalize the recording and return the temp file path, waveform, and audio data @@ -175,33 +176,23 @@ impl RecordingState { /// Active MIDI note waiting for its noteOff event #[derive(Debug, Clone)] struct ActiveMidiNote { - /// MIDI note number (0-127) note: u8, - /// Velocity (0-127) velocity: u8, - /// Absolute time when note started (seconds) - start_time: f64, + start_time: Beats, } -/// State of an active MIDI recording session +/// State of an active MIDI recording session. pub struct MidiRecordingState { - /// Track being recorded to pub track_id: TrackId, - /// MIDI clip ID pub clip_id: MidiClipId, - /// Timeline start position in seconds - pub start_time: f64, - /// Currently active notes (noteOn without matching noteOff) - /// Maps note number to ActiveMidiNote + pub start_time: Beats, active_notes: HashMap, - /// Completed notes ready to be added to clip - /// Format: (time_offset, note, velocity, duration) - pub completed_notes: Vec<(f64, u8, u8, f64)>, + /// Completed notes: (time_offset, note, velocity, duration) — all times in beats + pub completed_notes: Vec<(Beats, u8, u8, Beats)>, } impl MidiRecordingState { - /// Create a new MIDI recording state - pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: f64) -> Self { + pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: Beats) -> Self { Self { track_id, clip_id, @@ -211,87 +202,62 @@ impl MidiRecordingState { } } - /// Handle a MIDI note on event - pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: f64) { - self.active_notes.insert(note, ActiveMidiNote { - note, - velocity, - start_time: absolute_time, - }); + pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) { + self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time }); } - /// Handle a MIDI note off event - pub fn note_off(&mut self, note: u8, absolute_time: f64) { - // Find the matching noteOn + pub fn note_off(&mut self, note: u8, absolute_time: Beats) { if let Some(active_note) = self.active_notes.remove(¬e) { - // If the note was fully released before the recording start (e.g. during count-in - // pre-roll), discard it — only notes still held at the clip start are kept. if absolute_time <= self.start_time { return; } - - // Clamp note start to clip start: notes held across the recording boundary - // are treated as starting at the clip position. let note_start = active_note.start_time.max(self.start_time); - let time_offset = note_start - self.start_time; - let duration = absolute_time - note_start; - - eprintln!("[MIDI_RECORDING_STATE] Completing note {}: note_start={:.3}s, note_end={:.3}s, recording_start={:.3}s, time_offset={:.3}s, duration={:.3}s", - note, note_start, absolute_time, self.start_time, time_offset, duration); - self.completed_notes.push(( - time_offset, + note_start - self.start_time, active_note.note, active_note.velocity, - duration, + absolute_time - note_start, )); } - // If no matching noteOn found, ignore the noteOff } - /// Get all completed notes - pub fn get_notes(&self) -> &[(f64, u8, u8, f64)] { + pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] { &self.completed_notes } - /// Get the number of completed notes pub fn note_count(&self) -> usize { self.completed_notes.len() } /// Get all completed notes plus currently-held notes with a provisional duration. - /// Used for live preview during recording so held notes appear immediately. - pub fn get_notes_with_active(&self, current_time: f64) -> Vec<(f64, u8, u8, f64)> { + pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { let mut notes = self.completed_notes.clone(); for active in self.active_notes.values() { let note_start = active.start_time.max(self.start_time); - let time_offset = note_start - self.start_time; - let provisional_dur = (current_time - note_start).max(0.0); - notes.push((time_offset, active.note, active.velocity, provisional_dur)); + notes.push(( + note_start - self.start_time, + active.note, + active.velocity, + (current_time - note_start).max(Beats::ZERO), + )); } notes } - /// Get the note numbers of all currently held (active) notes pub fn active_note_numbers(&self) -> Vec { self.active_notes.keys().copied().collect() } - /// Close out all active notes at the given time - /// This should be called when stopping recording to end any held notes - pub fn close_active_notes(&mut self, end_time: f64) { + pub fn close_active_notes(&mut self, end_time: Beats) { let active_notes: Vec<_> = self.active_notes.drain().collect(); for (_note_num, active_note) in active_notes { let note_start = active_note.start_time.max(self.start_time); - let time_offset = note_start - self.start_time; - let duration = end_time - note_start; - self.completed_notes.push(( - time_offset, + note_start - self.start_time, active_note.note, active_note.velocity, - duration, + end_time - note_start, )); } } diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index 3a3c476..c066cc1 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -6,6 +6,8 @@ use super::node_graph::AudioGraph; use super::node_graph::nodes::{AudioInputNode, AudioOutputNode}; use super::node_graph::preset::GraphPreset; use super::pool::AudioClipPool; +use crate::tempo_map::TempoMap; +use crate::time::{Beats, Seconds}; use serde::{Serialize, Deserialize}; use std::collections::{HashMap, HashSet}; @@ -23,10 +25,12 @@ pub type Track = AudioTrack; /// Rendering context that carries timing information through the track hierarchy /// /// This allows metatracks to transform time for their children (time stretch, offset, etc.) -#[derive(Debug, Clone, Copy)] -pub struct RenderContext { +#[derive(Clone, Copy)] +pub struct RenderContext<'a> { /// Current playhead position in seconds (in transformed time) - pub playhead_seconds: f64, + pub playhead_seconds: Seconds, + /// Tempo map for beat ↔ second conversion + pub tempo_map: &'a TempoMap, /// Audio sample rate pub sample_rate: u32, /// Number of channels @@ -41,16 +45,17 @@ pub struct RenderContext { pub live_only: bool, } -impl RenderContext { - /// Create a new render context +impl<'a> RenderContext<'a> { pub fn new( - playhead_seconds: f64, + playhead_seconds: Seconds, + tempo_map: &'a TempoMap, sample_rate: u32, channels: u32, buffer_size: usize, ) -> Self { Self { playhead_seconds, + tempo_map, sample_rate, channels, buffer_size, @@ -59,15 +64,21 @@ impl RenderContext { } } - /// Get the duration of the buffer in seconds - pub fn buffer_duration(&self) -> f64 { - self.buffer_size as f64 / (self.sample_rate as f64 * self.channels as f64) + pub fn buffer_duration(&self) -> Seconds { + Seconds(self.buffer_size as f64 / (self.sample_rate as f64 * self.channels as f64)) } - /// Get the end time of the buffer - pub fn buffer_end(&self) -> f64 { + pub fn buffer_end(&self) -> Seconds { self.playhead_seconds + self.buffer_duration() } + + pub fn playhead_beats(&self) -> Beats { + self.tempo_map.seconds_to_beats(self.playhead_seconds) + } + + pub fn buffer_end_beats(&self) -> Beats { + self.tempo_map.seconds_to_beats(self.buffer_end()) + } } /// Node in the track hierarchy - can be an audio track, MIDI track, or a metatrack @@ -170,14 +181,14 @@ pub struct Metatrack { pub time_stretch: f32, /// Pitch shift in semitones (for future implementation) pub pitch_shift: f32, - /// Time offset in seconds (shift content forward/backward in time) - pub offset: f64, - /// Trim start: offset into the metatrack's internal content (seconds) + /// Time offset (shift content forward/backward in time) + pub offset: Seconds, + /// Trim start: offset into the metatrack's internal content /// Children will see time starting from this point - pub trim_start: f64, - /// Trim end: offset into the metatrack's internal content (seconds) + pub trim_start: Seconds, + /// Trim end: offset into the metatrack's internal content /// None means no end trim (play until content ends) - pub trim_end: Option, + pub trim_end: Option, /// Automation lanes for this metatrack pub automation_lanes: HashMap, next_automation_id: AutomationLaneId, @@ -231,8 +242,8 @@ impl Metatrack { solo: false, time_stretch: 1.0, pitch_shift: 0.0, - offset: 0.0, - trim_start: 0.0, + offset: Seconds::ZERO, + trim_start: Seconds::ZERO, trim_end: None, automation_lanes: HashMap::new(), next_automation_id: 0, @@ -493,7 +504,7 @@ impl Metatrack { } /// Evaluate automation at a specific time and return effective parameters - pub fn evaluate_automation_at_time(&self, time: f64) -> (f32, f32, f64) { + pub fn evaluate_automation_at_time(&self, time: Beats) -> (f32, f32, Seconds) { let mut volume = self.volume; let mut time_stretch = self.time_stretch; let mut offset = self.offset; @@ -517,7 +528,7 @@ impl Metatrack { } ParameterId::TimeOffset => { if let Some(automated_value) = lane.evaluate(time) { - offset = automated_value as f64; + offset = Seconds(automated_value as f64); } } _ => {} @@ -561,7 +572,7 @@ impl Metatrack { /// Check whether this metatrack should produce audio at the given parent time. /// Returns false if the playhead is outside the trim window. - pub fn is_active_at_time(&self, parent_playhead: f64) -> bool { + pub fn is_active_at_time(&self, parent_playhead: Seconds) -> bool { let local_time = (parent_playhead - self.offset) * self.time_stretch as f64; if local_time < self.trim_start { return false; @@ -580,7 +591,7 @@ impl Metatrack { /// Time stretch affects how fast content plays: 0.5 = half speed, 2.0 = double speed /// Offset shifts content forward/backward in time /// Trim start offsets into the internal content - pub fn transform_context(&self, ctx: RenderContext) -> RenderContext { + pub fn transform_context<'a>(&self, ctx: RenderContext<'a>) -> RenderContext<'a> { let mut transformed = ctx; // Apply transformations in order: @@ -596,7 +607,7 @@ impl Metatrack { let stretched = adjusted_playhead * self.time_stretch as f64; // 3. Add trim_start so children see time starting from the trim point - // If trim_start=2.0, children start seeing time 2.0 when parent reaches offset + // If trim_start=2.0s, children start seeing time 2.0s when parent reaches offset transformed.playhead_seconds = stretched + self.trim_start; // Accumulate time stretch for nested metatracks @@ -772,13 +783,13 @@ impl MidiTrack { // Send note-off for all 128 possible MIDI notes to silence the instrument let mut note_offs = Vec::new(); for note in 0..128 { - note_offs.push(MidiEvent::note_off(0.0, 0, note, 0)); + note_offs.push(MidiEvent::note_off(Beats::ZERO, 0, note, 0)); } // Create a silent buffer to process the note-offs let buffer_size = 512 * 2; // stereo let mut silent_buffer = vec![0.0f32; buffer_size]; - self.instrument_graph.process(&mut silent_buffer, ¬e_offs, 0.0); + self.instrument_graph.process(&mut silent_buffer, ¬e_offs, Beats::ZERO); } /// Queue a live MIDI event (from virtual keyboard or MIDI controller) @@ -805,17 +816,17 @@ impl MidiTrack { let mut midi_events = Vec::new(); if !ctx.live_only { - let buffer_duration_seconds = output.len() as f64 / (ctx.sample_rate as f64 * ctx.channels as f64); - let buffer_end_seconds = ctx.playhead_seconds + buffer_duration_seconds; + let playhead_beats = ctx.playhead_beats(); + let buffer_end_beats = ctx.buffer_end_beats(); - // Collect MIDI events from all clip instances that overlap with current time range + // Collect MIDI events from all clip instances that overlap with current beat range let mut currently_active = HashSet::new(); for instance in &self.clip_instances { - if instance.overlaps_range(ctx.playhead_seconds, buffer_end_seconds) { + if instance.overlaps_range(playhead_beats, buffer_end_beats) { currently_active.insert(instance.id); } if let Some(clip) = midi_pool.get_clip(instance.clip_id) { - let events = instance.get_events_in_range(clip, ctx.playhead_seconds, buffer_end_seconds); + let events = instance.get_events_in_range(clip, playhead_beats, buffer_end_beats); midi_events.extend(events); } } @@ -824,7 +835,7 @@ impl MidiTrack { for prev_id in &self.prev_active_instances { if !currently_active.contains(prev_id) { for note in 0..128u8 { - midi_events.push(MidiEvent::note_off(ctx.playhead_seconds, 0, note, 0)); + midi_events.push(MidiEvent::note_off(playhead_beats, 0, note, 0)); } break; } @@ -836,10 +847,10 @@ impl MidiTrack { midi_events.extend(self.live_midi_queue.drain(..)); // Generate audio using instrument graph - self.instrument_graph.process(output, &midi_events, ctx.playhead_seconds); + self.instrument_graph.process(output, &midi_events, ctx.playhead_beats()); // Evaluate and apply automation (skip automation in live_only mode — no playhead to evaluate at) - let effective_volume = if ctx.live_only { self.volume } else { self.evaluate_automation_at_time(ctx.playhead_seconds) }; + let effective_volume = if ctx.live_only { self.volume } else { self.evaluate_automation_at_time(ctx.playhead_beats()) }; // Apply track volume for sample in output.iter_mut() { @@ -848,7 +859,7 @@ impl MidiTrack { } /// Evaluate automation at a specific time and return the effective volume - fn evaluate_automation_at_time(&self, time: f64) -> f32 { + fn evaluate_automation_at_time(&self, time: Beats) -> f32 { let mut volume = self.volume; // Check for volume automation @@ -1081,12 +1092,9 @@ impl AudioTrack { &mut self, output: &mut [f32], pool: &AudioClipPool, - playhead_seconds: f64, - sample_rate: u32, - channels: u32, + ctx: RenderContext<'_>, ) -> usize { - let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64); - let buffer_end_seconds = playhead_seconds + buffer_duration_seconds; + let buffer_end = ctx.buffer_end(); // Split borrow: take clip_render_buffer out to avoid borrow conflict with &self methods let mut clip_buffer = std::mem::take(&mut self.clip_render_buffer); @@ -1096,16 +1104,8 @@ impl AudioTrack { // Render all active clip instances into the buffer for clip in &self.clips { - // Check if clip overlaps with current buffer time range - if clip.external_start < buffer_end_seconds && clip.external_end() > playhead_seconds { - rendered += self.render_clip( - clip, - &mut clip_buffer, - pool, - playhead_seconds, - sample_rate, - channels, - ); + if clip.external_start_secs(ctx.tempo_map) < buffer_end && clip.external_end_secs(ctx.tempo_map) > ctx.playhead_seconds { + rendered += self.render_clip(clip, &mut clip_buffer, pool, ctx); } } @@ -1123,13 +1123,13 @@ impl AudioTrack { } // Process through the effects graph (this will write to output buffer) - self.effects_graph.process(output, &[], playhead_seconds); + self.effects_graph.process(output, &[], ctx.playhead_beats()); // Put the buffer back for reuse next callback self.clip_render_buffer = clip_buffer; // Evaluate and apply automation - let effective_volume = self.evaluate_automation_at_time(playhead_seconds); + let effective_volume = self.evaluate_automation_at_time(ctx.playhead_beats()); // Apply track volume for sample in output.iter_mut() { @@ -1140,7 +1140,7 @@ impl AudioTrack { } /// Evaluate automation at a specific time and return the effective volume - fn evaluate_automation_at_time(&self, time: f64) -> f32 { + fn evaluate_automation_at_time(&self, time: Beats) -> f32 { let mut volume = self.volume; // Check for volume automation @@ -1169,49 +1169,40 @@ impl AudioTrack { clip: &AudioClipInstance, output: &mut [f32], pool: &AudioClipPool, - playhead_seconds: f64, - sample_rate: u32, - channels: u32, + ctx: RenderContext<'_>, ) -> usize { - let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64); - let buffer_end_seconds = playhead_seconds + buffer_duration_seconds; + let playhead = ctx.playhead_seconds; + let buffer_end = ctx.buffer_end(); + let tempo_map = ctx.tempo_map; + let sample_rate = ctx.sample_rate; + let channels = ctx.channels; // Determine the time range we need to render (intersection of buffer and clip external bounds) - let render_start_seconds = playhead_seconds.max(clip.external_start); - let render_end_seconds = buffer_end_seconds.min(clip.external_end()); + let render_start = playhead.max(clip.external_start_secs(tempo_map)); + let render_end = buffer_end.min(clip.external_end_secs(tempo_map)); - // If no overlap, return early - if render_start_seconds >= render_end_seconds { + if render_start >= render_end { return 0; } let internal_duration = clip.internal_duration(); - if internal_duration <= 0.0 { + if internal_duration <= Seconds::ZERO { return 0; } - // Calculate combined gain let combined_gain = clip.gain; - let mut total_rendered = 0; - - // Process the render range sample by sample (or in chunks for efficiency) - // For looping clips, we need to handle wrap-around at the loop boundary let samples_per_second = sample_rate as f64 * channels as f64; - // For now, render in a simpler way - iterate through the timeline range - // and use get_content_position for each sample position - let output_start_offset = ((render_start_seconds - playhead_seconds) * samples_per_second + 0.5) as usize; - let output_end_offset = ((render_end_seconds - playhead_seconds) * samples_per_second + 0.5) as usize; + let output_start_offset = ((render_start - playhead).0 * samples_per_second + 0.5) as usize; + let output_end_offset = ((render_end - playhead).0 * samples_per_second + 0.5) as usize; if output_end_offset > output.len() || output_start_offset > output.len() { return 0; } - // If not looping, we can render in one chunk (more efficient) - if !clip.is_looping() { - // Simple case: no looping - let content_start = clip.get_content_position(render_start_seconds).unwrap_or(clip.internal_start); + if !clip.is_looping(tempo_map) { + let content_start = clip.get_content_position(render_start, tempo_map).unwrap_or(clip.internal_start); let output_len = output.len(); let output_slice = &mut output[output_start_offset..output_end_offset.min(output_len)]; @@ -1225,23 +1216,20 @@ impl AudioTrack { clip.read_ahead.as_deref(), ); } else { - // Looping case: need to handle wrap-around at loop boundaries - // Render in segments, one per loop iteration - let mut timeline_pos = render_start_seconds; + // Looping case: handle wrap-around at loop boundaries + let mut timeline_pos = render_start; let mut output_offset = output_start_offset; - while timeline_pos < render_end_seconds && output_offset < output.len() { - // Calculate position within the loop - let relative_pos = timeline_pos - clip.external_start; - let loop_offset = relative_pos % internal_duration; - let content_pos = clip.internal_start + loop_offset; + while timeline_pos < render_end && output_offset < output.len() { + let relative_pos = timeline_pos - clip.external_start_secs(tempo_map); + let loop_offset = relative_pos.0 % internal_duration.0; + let content_pos = clip.internal_start + Seconds(loop_offset); - // Calculate how much we can render before hitting the loop boundary - let time_to_loop_end = internal_duration - loop_offset; - let time_to_render_end = render_end_seconds - timeline_pos; + let time_to_loop_end = Seconds(internal_duration.0 - loop_offset); + let time_to_render_end = render_end - timeline_pos; let chunk_duration = time_to_loop_end.min(time_to_render_end); - let chunk_samples = (chunk_duration * samples_per_second) as usize; + let chunk_samples = (chunk_duration.0 * samples_per_second) as usize; let chunk_samples = chunk_samples.min(output.len() - output_offset); if chunk_samples == 0 { @@ -1262,7 +1250,7 @@ impl AudioTrack { total_rendered += rendered; output_offset += chunk_samples; - timeline_pos += chunk_duration; + timeline_pos = timeline_pos + chunk_duration; } } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 8b9aba0..1ca4020 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -6,6 +6,7 @@ use crate::audio::midi::MidiEvent; use crate::audio::buffer_pool::BufferPoolStats; use crate::audio::node_graph::nodes::LoopMode; use crate::io::WaveformPeak; +use crate::time::{Beats, Seconds}; /// Commands sent from UI/control thread to audio thread #[derive(Debug, Clone)] @@ -113,7 +114,7 @@ pub enum Command { // Recording commands /// Start recording on a track (track_id, start_time) - StartRecording(TrackId, f64), + StartRecording(TrackId, Beats), /// Stop the current recording StopRecording, /// Pause the current recording @@ -123,7 +124,7 @@ pub enum Command { // MIDI Recording commands /// Start MIDI recording on a track (track_id, clip_id, start_time) - StartMidiRecording(TrackId, MidiClipId, f64), + StartMidiRecording(TrackId, MidiClipId, Beats), /// Stop the current MIDI recording StopMidiRecording, @@ -144,11 +145,6 @@ pub enum Command { SetMetronomeEnabled(bool), /// Set project tempo and time signature (bpm, (numerator, denominator)) SetTempo(f32, (u32, u32)), - /// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale - /// automation keyframe times to preserve beat positions. - /// (from_bpm, to_bpm, fps, midi_durations: Vec<(clip_id, new_duration_seconds)>) - ApplyBpmChange(f64, f64, f64, Vec<(MidiClipId, f64)>), - // Node graph commands /// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y) GraphAddNode(TrackId, String, f32, f32), @@ -290,7 +286,7 @@ pub enum AudioEvent { /// Recording started (track_id, clip_id, sample_rate, channels) RecordingStarted(TrackId, ClipId, u32, u32), /// Recording progress update (clip_id, current_duration) - RecordingProgress(ClipId, f64), + RecordingProgress(ClipId, Seconds), /// Recording stopped (clip_id, pool_index, waveform) RecordingStopped(ClipId, usize, Vec), /// Recording error (error_message) @@ -298,8 +294,8 @@ pub enum AudioEvent { /// MIDI recording stopped (track_id, clip_id, note_count) MidiRecordingStopped(TrackId, MidiClipId, usize), /// MIDI recording progress (track_id, clip_id, duration, notes) - /// Notes format: (start_time, note, velocity, duration) - MidiRecordingProgress(TrackId, MidiClipId, f64, Vec<(f64, u8, u8, f64)>), + /// Notes format: (start_time, note, velocity, duration) — all times in beats + MidiRecordingProgress(TrackId, MidiClipId, Beats, Vec<(Beats, u8, u8, Beats)>), /// Project has been reset ProjectReset, /// MIDI note started playing (note, velocity) diff --git a/daw-backend/src/io/midi_file.rs b/daw-backend/src/io/midi_file.rs index 1dd2305..955da81 100644 --- a/daw-backend/src/io/midi_file.rs +++ b/daw-backend/src/io/midi_file.rs @@ -1,164 +1,69 @@ use crate::audio::midi::{MidiClip, MidiClipId, MidiEvent}; +use crate::time::Beats; use std::fs; use std::path::Path; -/// Load a MIDI file and convert it to a MidiClip +/// Load a MIDI file and convert it to a MidiClip. +/// +/// Event timestamps are stored as beat positions: `tick / ticks_per_beat`. +/// Tempo events in the MIDI file only affect wall-clock playback speed — not the +/// beat grid — so they are ignored here. pub fn load_midi_file>( path: P, clip_id: MidiClipId, _sample_rate: u32, ) -> Result { - // Read the MIDI file let data = fs::read(path.as_ref()).map_err(|e| format!("Failed to read MIDI file: {}", e))?; - - // Parse with midly let smf = midly::Smf::parse(&data).map_err(|e| format!("Failed to parse MIDI file: {}", e))?; - // Convert timing to ticks per second let ticks_per_beat = match smf.header.timing { midly::Timing::Metrical(tpb) => tpb.as_int() as f64, midly::Timing::Timecode(fps, subframe) => { - // For timecode, calculate equivalent ticks per second + // Timecode-based MIDI: treat subframes as ticks per beat (fps.as_f32() * subframe as f32) as f64 } }; - // First pass: collect all events with their tick positions and tempo changes - #[derive(Debug)] - enum RawEvent { - Midi { - tick: u64, - channel: u8, - message: midly::MidiMessage, - }, - Tempo { - tick: u64, - microseconds_per_beat: f64, - }, - } + let mut events = Vec::new(); + let mut max_tick = 0u64; - let mut raw_events = Vec::new(); - let mut max_time_ticks = 0u64; - - // Collect all events from all tracks with their absolute tick positions for track in &smf.tracks { let mut current_tick = 0u64; for event in track { current_tick += event.delta.as_int() as u64; - max_time_ticks = max_time_ticks.max(current_tick); + max_tick = max_tick.max(current_tick); + + let timestamp = Beats(current_tick as f64 / ticks_per_beat); match event.kind { midly::TrackEventKind::Midi { channel, message } => { - raw_events.push(RawEvent::Midi { - tick: current_tick, - channel: channel.as_int(), - message, - }); - } - midly::TrackEventKind::Meta(midly::MetaMessage::Tempo(tempo)) => { - raw_events.push(RawEvent::Tempo { - tick: current_tick, - microseconds_per_beat: tempo.as_int() as f64, - }); - } - _ => { - // Ignore other meta events - } - } - } - } - - // Sort all events by tick position - raw_events.sort_by_key(|e| match e { - RawEvent::Midi { tick, .. } => *tick, - RawEvent::Tempo { tick, .. } => *tick, - }); - - // Second pass: convert ticks to timestamps with proper tempo tracking - let mut events = Vec::new(); - let mut microseconds_per_beat = 500000.0; // Default: 120 BPM - let mut last_tick = 0u64; - let mut accumulated_time = 0.0; // Time in seconds - - for raw_event in raw_events { - match raw_event { - RawEvent::Tempo { - tick, - microseconds_per_beat: new_tempo, - } => { - // Update accumulated time up to this tempo change - let delta_ticks = tick - last_tick; - let delta_time = (delta_ticks as f64 / ticks_per_beat) - * (microseconds_per_beat / 1_000_000.0); - accumulated_time += delta_time; - last_tick = tick; - - // Update tempo for future events - microseconds_per_beat = new_tempo; - } - RawEvent::Midi { - tick, - channel, - message, - } => { - // Calculate time for this event - let delta_ticks = tick - last_tick; - let delta_time = (delta_ticks as f64 / ticks_per_beat) - * (microseconds_per_beat / 1_000_000.0); - accumulated_time += delta_time; - last_tick = tick; - - // Store timestamp in seconds (sample-rate independent) - let timestamp = accumulated_time; - - match message { - midly::MidiMessage::NoteOn { key, vel } => { - let velocity = vel.as_int(); - if velocity > 0 { - events.push(MidiEvent::note_on( - timestamp, - channel, - key.as_int(), - velocity, - )); - } else { - events.push(MidiEvent::note_off(timestamp, channel, key.as_int(), 64)); + let ch = channel.as_int(); + match message { + midly::MidiMessage::NoteOn { key, vel } => { + let velocity = vel.as_int(); + if velocity > 0 { + events.push(MidiEvent::note_on(timestamp, ch, key.as_int(), velocity)); + } else { + events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), 64)); + } } - } - midly::MidiMessage::NoteOff { key, vel } => { - events.push(MidiEvent::note_off( - timestamp, - channel, - key.as_int(), - vel.as_int(), - )); - } - midly::MidiMessage::Controller { controller, value } => { - let status = 0xB0 | channel; - events.push(MidiEvent::new( - timestamp, - status, - controller.as_int(), - value.as_int(), - )); - } - _ => { - // Ignore other MIDI messages + midly::MidiMessage::NoteOff { key, vel } => { + events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), vel.as_int())); + } + midly::MidiMessage::Controller { controller, value } => { + let status = 0xB0 | ch; + events.push(MidiEvent::new(timestamp, status, controller.as_int(), value.as_int())); + } + _ => {} } } + _ => {} // Tempo and other meta events don't affect beat positions } } } - // Calculate final clip duration - let final_delta_ticks = max_time_ticks - last_tick; - let final_delta_time = - (final_delta_ticks as f64 / ticks_per_beat) * (microseconds_per_beat / 1_000_000.0); - let duration_seconds = accumulated_time + final_delta_time; - - // Create the MIDI clip (content only, positioning happens when creating instance) - let clip = MidiClip::new(clip_id, events, duration_seconds, "Imported MIDI".to_string()); - + let duration = Beats(max_tick as f64 / ticks_per_beat); + let clip = MidiClip::new(clip_id, events, duration, "Imported MIDI".to_string()); Ok(clip) } diff --git a/daw-backend/src/lib.rs b/daw-backend/src/lib.rs index edfed17..733eae0 100644 --- a/daw-backend/src/lib.rs +++ b/daw-backend/src/lib.rs @@ -6,6 +6,8 @@ pub mod audio; pub mod command; +pub mod time; +pub mod tempo_map; pub mod dsp; pub mod effects; pub mod io; @@ -18,6 +20,8 @@ pub use audio::{ TrackNode, }; 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 command::{AudioEvent, Command, OscilloscopeData}; pub use command::types::AutomationKeyframeData; pub use io::{load_midi_file, AudioFile, WaveformChunk, WaveformChunkKey, WaveformPeak, WavWriter}; @@ -90,7 +94,7 @@ impl AudioSystem { // Create input ringbuffer for recording (large buffer for audio samples) // Buffer size: 10 seconds of audio at 48kHz stereo = 48000 * 2 * 10 = 960000 samples let input_buffer_size = (sample_rate * channels * 10) as usize; - let (mut input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size); + let (input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size); // Create mirror ringbuffer for streaming recorded audio to UI (live waveform) let (mirror_tx, mirror_rx) = rtrb::RingBuffer::new(input_buffer_size); diff --git a/daw-backend/src/tempo_map.rs b/daw-backend/src/tempo_map.rs new file mode 100644 index 0000000..a6a5577 --- /dev/null +++ b/daw-backend/src/tempo_map.rs @@ -0,0 +1,262 @@ +//! TempoMap — beats ↔ seconds conversion with variable tempo support. +//! +//! Positions are stored in **beats** throughout the project; `TempoMap` converts +//! between beats and seconds at render / scheduling time. +//! +//! # 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`. +//! +//! # Sequential-access optimisation +//! An `AtomicUsize` caches the index of the last segment visited by +//! `beats_to_seconds`. When calls are in ascending order (the common case when +//! walking events in order) the scan starts from the cached index instead of +//! the beginning, giving amortised O(1) behaviour. + +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`. +#[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. + 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, +} + +/// A piecewise-constant 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. + pub entries: Vec, + /// Sequential-access cache: index of the last segment used by `beats_to_seconds`. + #[serde(skip, default)] + last_index: AtomicUsize, +} + +impl Clone for TempoMap { + fn clone(&self) -> Self { + Self { + entries: self.entries.clone(), + last_index: AtomicUsize::new(self.last_index.load(Ordering::Relaxed)), + } + } +} + +impl Default for TempoMap { + fn default() -> Self { + Self::constant(120.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 }], + last_index: AtomicUsize::new(0), + } + } + + /// Rebuild the `seconds` field on every entry from scratch. + /// **Must be called** after any mutation (add/remove/reorder entry) and + /// after deserialization. + pub fn rebuild_seconds(&mut self) { + let mut cumulative = 0.0_f64; + for i in 0..self.entries.len() { + 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; + } + } + self.last_index.store(0, Ordering::Relaxed); + } + + /// Return the BPM active at `beat`. + pub fn bpm_at(&self, beat: Beats) -> f64 { + let idx = self.entries.partition_point(|e| e.beat <= beat.0).saturating_sub(1); + self.entries[idx.max(0)].bpm + } + + /// Convert beats to seconds using the tempo map. + /// + /// 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) + } + + /// 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) + } + + /// Global BPM — the BPM of the first entry (at beat 0). + pub fn global_bpm(&self) -> f64 { + self.entries[0].bpm + } + + /// Set the global BPM (first entry). Rebuilds seconds. + pub fn set_global_bpm(&mut self, bpm: f64) { + self.entries[0].bpm = bpm; + self.rebuild_seconds(); + } + + /// 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. + pub fn transform(&self, beat: f64) -> f64 { + if beat <= 0.0 { + return 0.0; + } + let n = self.entries.len(); + let cached = self.last_index.load(Ordering::Relaxed).min(n.saturating_sub(1)); + let start = if beat >= self.entries[cached].beat { cached } else { 0 }; + let mut idx = start; + while idx + 1 < n && self.entries[idx + 1].beat <= beat { + idx += 1; + } + self.last_index.store(idx, Ordering::Relaxed); + let entry = &self.entries[idx]; + entry.seconds + (beat - entry.beat) * 60.0 / entry.bpm + } + + /// Inverse of [`transform`]: convert parent time units back to local beats. + pub fn inverse_transform(&self, parent_time: f64) -> f64 { + if parent_time <= 0.0 { + return 0.0; + } + let n = self.entries.len(); + 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 + } + + /// Build a `TempoMap` from a list of `(beat, bpm)` keyframes. + /// 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() { + return Self::constant(120.0); + } + let mut entries: Vec = keyframes + .iter() + .map(|&(beat, bpm)| TempoEntry { beat, bpm, seconds: 0.0 }) + .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 }); + } + let mut map = Self { entries, last_index: AtomicUsize::new(0) }; + map.rebuild_seconds(); + map + } +} + +/// 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() { + t = tm.transform(t); + } + t +} + +/// Inverse of [`beats_to_seconds_stack`]: absolute seconds → local beats. +pub fn seconds_to_beats_stack(seconds: f64, stack: &[&TempoMap]) -> f64 { + let mut t = seconds; + for tm in stack.iter() { + t = tm.inverse_transform(t); + } + t +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constant_bpm_round_trip() { + let m = TempoMap::constant(120.0); + assert!((m.beats_to_seconds(Beats(2.0)).0 - 1.0).abs() < 1e-9); + assert!((m.seconds_to_beats(Seconds(1.0)).0 - 2.0).abs() < 1e-9); + } + + #[test] + fn variable_tempo() { + 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); + 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 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}"); + } + + #[test] + fn sequential_cache() { + let m = TempoMap::constant(120.0); + for i in 0..10 { + let secs = m.beats_to_seconds(Beats(i as f64)); + assert!((secs.0 - i as f64 * 0.5).abs() < 1e-9); + } + } +} diff --git a/daw-backend/src/time.rs b/daw-backend/src/time.rs new file mode 100644 index 0000000..b2e2913 --- /dev/null +++ b/daw-backend/src/time.rs @@ -0,0 +1,125 @@ +/// Strongly-typed time units to prevent accidental beats/seconds confusion. +/// +/// Convert between the two using `TempoMap::beats_to_seconds` / `TempoMap::seconds_to_beats`. +/// All internal scheduling and clip positions use `Beats`; only audio rendering +/// (sample offsets, file seeks) uses `Seconds`. +use serde::{Deserialize, Serialize}; +use std::ops::{Add, AddAssign, Div, Mul, Neg, Rem, Sub, SubAssign}; + +/// A time position or duration expressed in **beats** (quarter-note beats). +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Beats(pub f64); + +/// A time position or duration expressed in **seconds**. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Seconds(pub f64); + +impl Beats { + pub const ZERO: Self = Self(0.0); + + pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) } + pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) } + pub fn abs(self) -> Self { Self(self.0.abs()) } + pub fn ceil(self) -> Self { Self(self.0.ceil()) } + pub fn floor(self) -> Self { Self(self.0.floor()) } + pub fn beats_to_f64(self) -> f64 { self.0 } +} + +impl Seconds { + pub const ZERO: Self = Self(0.0); + + pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) } + pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) } + pub fn abs(self) -> Self { Self(self.0.abs()) } + pub fn seconds_to_f64(self) -> f64 { self.0 } +} + +// --- Beats arithmetic --- + +impl Add for Beats { + type Output = Self; + fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) } +} +impl Sub for Beats { + type Output = Self; + fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) } +} +impl Mul for Beats { + type Output = Self; + fn mul(self, rhs: f64) -> Self { Self(self.0 * rhs) } +} +impl Div for Beats { + type Output = Self; + fn div(self, rhs: f64) -> Self { Self(self.0 / rhs) } +} +/// Beats / Beats = dimensionless ratio (f64) +impl Div for Beats { + type Output = f64; + fn div(self, rhs: Beats) -> f64 { self.0 / rhs.0 } +} +impl Rem for Beats { + type Output = Self; + fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) } +} +impl Neg for Beats { + type Output = Self; + fn neg(self) -> Self { Self(-self.0) } +} +impl AddAssign for Beats { + fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; } +} +impl SubAssign for Beats { + fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; } +} + +// --- Seconds arithmetic --- + +impl Add for Seconds { + type Output = Self; + fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) } +} +impl Sub for Seconds { + type Output = Self; + fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) } +} +impl Mul for Seconds { + type Output = Self; + fn mul(self, rhs: f64) -> Self { Self(self.0 * rhs) } +} +impl Div for Seconds { + type Output = Self; + fn div(self, rhs: f64) -> Self { Self(self.0 / rhs) } +} +/// Seconds / Seconds = dimensionless ratio (f64) +impl Div for Seconds { + type Output = f64; + fn div(self, rhs: Seconds) -> f64 { self.0 / rhs.0 } +} +impl Rem for Seconds { + type Output = Self; + fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) } +} +impl Neg for Seconds { + type Output = Self; + fn neg(self) -> Self { Self(-self.0) } +} +impl AddAssign for Seconds { + fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; } +} +impl SubAssign for Seconds { + fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; } +} + +impl std::fmt::Display for Beats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::fmt::Display for Seconds { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/daw-backend/src/tui/mod.rs b/daw-backend/src/tui/mod.rs index 20c64a7..3dc57bc 100644 --- a/daw-backend/src/tui/mod.rs +++ b/daw-backend/src/tui/mod.rs @@ -859,7 +859,7 @@ fn execute_command( for event in &midi_clip.events { let status = event.status & 0xF0; - let time_seconds = event.timestamp as f64 / sample_rate; + let time_seconds = event.timestamp.beats_to_f64() / sample_rate; match status { 0x90 if event.data2 > 0 => { @@ -878,7 +878,7 @@ fn execute_command( } // Add to local UI state with note data - app.add_clip(track_id, clip_id, start_time, duration, file_path.to_string(), notes); + app.add_clip(track_id, clip_id, start_time, duration.beats_to_f64(), file_path.to_string(), notes); app.next_clip_id += 1; // Send to audio engine with the start_time (clip content is separate from timeline position) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index fd92016..b952cdb 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -79,8 +79,6 @@ impl Action for AddClipInstanceAction { if let Some(valid_start) = adjusted_start { // Update instance to use the valid position self.clip_instance.timeline_start = valid_start; - let (bpm, fps) = (document.bpm, document.framerate); - self.clip_instance.sync_from_seconds(bpm, fps); } else { // No valid position found - reject the operation return Err("Cannot add clip: no valid position found on layer (layer is full)".to_string()); @@ -210,10 +208,10 @@ impl Action for AddClipInstanceAction { let instance = daw_backend::MidiClipInstance::new( 0, // Instance ID will be assigned by backend *midi_clip_id, - internal_start, - internal_end, - external_start, - external_duration, + daw_backend::Beats(internal_start), + daw_backend::Beats(internal_end), + daw_backend::Beats(external_start), + daw_backend::Beats(external_duration), ); // Send query to add instance and get instance ID diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs b/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs index 549fa00..f47023b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/change_bpm.rs @@ -1,248 +1,49 @@ -//! Change BPM action +//! Change BPM / Tempo action //! -//! Atomically changes the document BPM and rescales all clip instance positions and -//! MIDI event timestamps so that beat positions are preserved (Measures mode behaviour). +//! Atomically swaps the document `TempoMap`, sending the new map to the engine. +//! All clip and note positions are stored in **beats** so no position rescaling +//! is required — only the TempoMap entry changes. use crate::action::{Action, BackendContext}; -use crate::clip::ClipInstance; use crate::document::Document; -use crate::layer::AnyLayer; -use std::collections::HashMap; -use uuid::Uuid; +use crate::tempo_map::TempoMap; -/// Snapshot of all timing fields on a `ClipInstance` +/// Action that changes the global BPM by replacing the tempo map. #[derive(Clone)] -struct TimingFields { - timeline_start: f64, - timeline_start_beats: f64, - timeline_start_frames: f64, - trim_start: f64, - trim_start_beats: f64, - trim_start_frames: f64, - trim_end: Option, - trim_end_beats: Option, - trim_end_frames: Option, - timeline_duration: Option, - timeline_duration_beats: Option, - timeline_duration_frames: Option, -} - -impl TimingFields { - fn from_instance(ci: &ClipInstance) -> Self { - Self { - timeline_start: ci.timeline_start, - timeline_start_beats: ci.timeline_start_beats, - timeline_start_frames: ci.timeline_start_frames, - trim_start: ci.trim_start, - trim_start_beats: ci.trim_start_beats, - trim_start_frames: ci.trim_start_frames, - trim_end: ci.trim_end, - trim_end_beats: ci.trim_end_beats, - trim_end_frames: ci.trim_end_frames, - timeline_duration: ci.timeline_duration, - timeline_duration_beats: ci.timeline_duration_beats, - timeline_duration_frames: ci.timeline_duration_frames, - } - } - - fn apply_to(&self, ci: &mut ClipInstance) { - ci.timeline_start = self.timeline_start; - ci.timeline_start_beats = self.timeline_start_beats; - ci.timeline_start_frames = self.timeline_start_frames; - ci.trim_start = self.trim_start; - ci.trim_start_beats = self.trim_start_beats; - ci.trim_start_frames = self.trim_start_frames; - ci.trim_end = self.trim_end; - ci.trim_end_beats = self.trim_end_beats; - ci.trim_end_frames = self.trim_end_frames; - ci.timeline_duration = self.timeline_duration; - ci.timeline_duration_beats = self.timeline_duration_beats; - ci.timeline_duration_frames = self.timeline_duration_frames; - } -} - -#[derive(Clone)] -struct ClipTimingSnapshot { - layer_id: Uuid, - instance_id: Uuid, - old_fields: TimingFields, - new_fields: TimingFields, -} - -#[derive(Clone)] -struct MidiClipSnapshot { - layer_id: Uuid, - midi_clip_id: u32, - clip_id: Uuid, - old_clip_duration: f64, - new_clip_duration: f64, - old_events: Vec, - new_events: Vec, -} - -/// Action that atomically changes BPM and rescales all clip/note positions to preserve beats pub struct ChangeBpmAction { - old_bpm: f64, - new_bpm: f64, + old_map: TempoMap, + new_map: TempoMap, time_sig: (u32, u32), - clip_snapshots: Vec, - midi_snapshots: Vec, } impl ChangeBpmAction { - /// Build the action, computing new positions for all clip instances and MIDI events. - /// - /// `midi_event_cache` maps backend MIDI clip ID → current event list. - pub fn new( - old_bpm: f64, - new_bpm: f64, - document: &Document, - midi_event_cache: &HashMap>, - ) -> Self { - let fps = document.framerate; - let time_sig = ( - document.time_signature.numerator, - document.time_signature.denominator, - ); - - let mut clip_snapshots: Vec = Vec::new(); - let mut midi_snapshots: Vec = Vec::new(); - - // Collect MIDI clip IDs we've already snapshotted (avoid duplicates) - let mut seen_midi_clips: std::collections::HashSet = std::collections::HashSet::new(); - - for layer in document.all_layers() { - let layer_id = layer.id(); - - let clip_instances: &[ClipInstance] = match layer { - AnyLayer::Vector(vl) => &vl.clip_instances, - AnyLayer::Audio(al) => &al.clip_instances, - AnyLayer::Video(vl) => &vl.clip_instances, - AnyLayer::Effect(el) => &el.clip_instances, - AnyLayer::Group(_) | AnyLayer::Raster(_) => continue, - }; - - for ci in clip_instances { - let old_fields = TimingFields::from_instance(ci); - - // Compute new fields: beats are canonical, recompute seconds + frames. - // Guard: if timeline_start_beats was never populated (clips added without - // sync_from_seconds), derive beats from seconds before applying. - let mut new_ci = ci.clone(); - if new_ci.timeline_start_beats == 0.0 && new_ci.timeline_start.abs() > 1e-9 { - new_ci.sync_from_seconds(old_bpm, fps); - } - new_ci.apply_beats(new_bpm, fps); - let new_fields = TimingFields::from_instance(&new_ci); - - clip_snapshots.push(ClipTimingSnapshot { - layer_id, - instance_id: ci.id, - old_fields, - new_fields, - }); - - // If this is a MIDI clip on an audio layer, collect MIDI events + rescale duration. - // Always snapshot the clip (even if empty) so clip.duration is rescaled. - if let AnyLayer::Audio(_) = layer { - if let Some(audio_clip) = document.get_audio_clip(&ci.clip_id) { - use crate::clip::AudioClipType; - if let AudioClipType::Midi { midi_clip_id } = &audio_clip.clip_type { - let midi_id = *midi_clip_id; - if !seen_midi_clips.contains(&midi_id) { - seen_midi_clips.insert(midi_id); - - let old_clip_duration = audio_clip.duration; - let new_clip_duration = old_clip_duration * old_bpm / new_bpm; - - // Use cached events if present; empty vec for clips with no events yet. - let old_events = midi_event_cache.get(&midi_id).cloned().unwrap_or_default(); - let new_events: Vec<_> = old_events.iter().map(|ev| { - let mut e = ev.clone(); - // Ensure beats are populated before using them as canonical. - // Events created before triple-rep (e.g. from recording) - // have timestamp_beats == 0.0 — sync from seconds first. - if e.timestamp_beats == 0.0 && e.timestamp.abs() > 1e-9 { - e.sync_from_seconds(old_bpm, fps); - } - e.apply_beats(new_bpm, fps); - e - }).collect(); - - midi_snapshots.push(MidiClipSnapshot { - layer_id, - midi_clip_id: midi_id, - clip_id: ci.clip_id, - old_clip_duration, - new_clip_duration, - old_events, - new_events, - }); - } - } - } - } - } - } - - Self { - old_bpm, - new_bpm, - time_sig, - clip_snapshots, - midi_snapshots, - } + /// Build the action from the current document state and a desired new BPM. + pub fn new(new_bpm: f64, document: &Document) -> Self { + 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 } } - /// Return the new MIDI event lists for each affected clip (for immediate cache update). - pub fn new_midi_events(&self) -> impl Iterator)> { - self.midi_snapshots.iter().map(|s| (s.midi_clip_id, &s.new_events)) + /// 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 } } - fn apply_clips(document: &mut Document, snapshots: &[ClipTimingSnapshot], use_new: bool) { - for snap in snapshots { - let fields = if use_new { &snap.new_fields } else { &snap.old_fields }; - - let layer = match document.get_layer_mut(&snap.layer_id) { - Some(l) => l, - None => continue, - }; - - let clip_instances = match layer { - AnyLayer::Vector(vl) => &mut vl.clip_instances, - AnyLayer::Audio(al) => &mut al.clip_instances, - AnyLayer::Video(vl) => &mut vl.clip_instances, - AnyLayer::Effect(el) => &mut el.clip_instances, - AnyLayer::Group(_) | AnyLayer::Raster(_) => continue, - }; - - if let Some(ci) = clip_instances.iter_mut().find(|ci| ci.id == snap.instance_id) { - fields.apply_to(ci); - } - } - } - - fn apply_midi_durations(document: &mut Document, snapshots: &[MidiClipSnapshot], use_new: bool) { - for snap in snapshots { - if let Some(clip) = document.get_audio_clip_mut(&snap.clip_id) { - clip.duration = if use_new { snap.new_clip_duration } else { snap.old_clip_duration }; - } - } - } + pub fn new_bpm(&self) -> f64 { self.new_map.global_bpm() } + pub fn old_bpm(&self) -> f64 { self.old_map.global_bpm() } } impl Action for ChangeBpmAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { - document.bpm = self.new_bpm; - Self::apply_clips(document, &self.clip_snapshots, true); - Self::apply_midi_durations(document, &self.midi_snapshots, true); + *document.tempo_map_mut() = self.new_map.clone(); Ok(()) } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { - document.bpm = self.old_bpm; - Self::apply_clips(document, &self.clip_snapshots, false); - Self::apply_midi_durations(document, &self.midi_snapshots, false); + *document.tempo_map_mut() = self.old_map.clone(); Ok(()) } @@ -253,111 +54,26 @@ impl Action for ChangeBpmAction { fn execute_backend( &mut self, backend: &mut BackendContext, - document: &Document, + _document: &Document, ) -> Result<(), String> { let controller = match backend.audio_controller.as_mut() { Some(c) => c, None => return Ok(()), }; - - // Update tempo - controller.set_tempo(self.new_bpm as f32, self.time_sig); - - // Update MIDI clip events and positions - for snap in &self.midi_snapshots { - let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { - Some(&id) => id, - None => continue, - }; - controller.update_midi_clip_events(track_id, snap.midi_clip_id, snap.new_events.clone()); - } - - // Move clip instances in the backend - for snap in &self.clip_snapshots { - let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { - Some(&id) => id, - None => continue, - }; - let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id); - match backend_id { - Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => { - controller.move_clip(track_id, *audio_id, snap.new_fields.timeline_start); - } - Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => { - controller.move_clip(track_id, *midi_id, snap.new_fields.timeline_start); - } - None => {} // Vector/video clips — no backend move needed - } - } - - // Sync beat/frame representations, rescale MIDI clip durations, and rescale - // automation keyframe times in the backend - let fps = document.framerate; - let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter() - .map(|s| (s.midi_clip_id, s.new_clip_duration)) - .collect(); - controller.apply_bpm_change(self.old_bpm, self.new_bpm, fps, midi_durations); - + controller.set_tempo(self.new_map.global_bpm() as f32, self.time_sig); Ok(()) } fn rollback_backend( &mut self, backend: &mut BackendContext, - document: &Document, + _document: &Document, ) -> Result<(), String> { let controller = match backend.audio_controller.as_mut() { Some(c) => c, None => return Ok(()), }; - - controller.set_tempo(self.old_bpm as f32, self.time_sig); - - for snap in &self.midi_snapshots { - let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { - Some(&id) => id, - None => continue, - }; - controller.update_midi_clip_events(track_id, snap.midi_clip_id, snap.old_events.clone()); - } - - for snap in &self.clip_snapshots { - let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { - Some(&id) => id, - None => continue, - }; - let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id); - match backend_id { - Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => { - controller.move_clip(track_id, *audio_id, snap.old_fields.timeline_start); - } - Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => { - controller.move_clip(track_id, *midi_id, snap.old_fields.timeline_start); - } - None => {} - } - } - - // Sync beat/frame representations, restore MIDI clip durations, and restore - // automation keyframe times in the backend - let fps = document.framerate; - let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter() - .map(|s| (s.midi_clip_id, s.old_clip_duration)) - .collect(); - controller.apply_bpm_change(self.new_bpm, self.old_bpm, fps, midi_durations); - + controller.set_tempo(self.old_map.global_bpm() as f32, self.time_sig); Ok(()) } - - fn all_midi_events_after_execute(&self) -> Vec<(u32, Vec)> { - self.midi_snapshots.iter() - .map(|s| (s.midi_clip_id, s.new_events.clone())) - .collect() - } - - fn all_midi_events_after_rollback(&self) -> Vec<(u32, Vec)> { - self.midi_snapshots.iter() - .map(|s| (s.midi_clip_id, s.old_events.clone())) - .collect() - } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/change_fps.rs b/lightningbeam-ui/lightningbeam-core/src/actions/change_fps.rs index 8c6ff29..a1d5540 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/change_fps.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/change_fps.rs @@ -1,156 +1,33 @@ //! Change FPS action //! -//! Atomically changes the document framerate and rescales all clip instance positions -//! so that frame positions are preserved (Frames mode behaviour). +//! Atomically changes the document framerate. +//! All clip positions are stored in **beats** so no position rescaling is +//! required — only the framerate field changes. use crate::action::{Action, BackendContext}; -use crate::clip::ClipInstance; use crate::document::Document; -use crate::layer::AnyLayer; -use uuid::Uuid; -/// Snapshot of all timing fields on a `ClipInstance` -#[derive(Clone)] -struct TimingFields { - timeline_start: f64, - timeline_start_beats: f64, - timeline_start_frames: f64, - trim_start: f64, - trim_start_beats: f64, - trim_start_frames: f64, - trim_end: Option, - trim_end_beats: Option, - trim_end_frames: Option, - timeline_duration: Option, - timeline_duration_beats: Option, - timeline_duration_frames: Option, -} - -impl TimingFields { - fn from_instance(ci: &ClipInstance) -> Self { - Self { - timeline_start: ci.timeline_start, - timeline_start_beats: ci.timeline_start_beats, - timeline_start_frames: ci.timeline_start_frames, - trim_start: ci.trim_start, - trim_start_beats: ci.trim_start_beats, - trim_start_frames: ci.trim_start_frames, - trim_end: ci.trim_end, - trim_end_beats: ci.trim_end_beats, - trim_end_frames: ci.trim_end_frames, - timeline_duration: ci.timeline_duration, - timeline_duration_beats: ci.timeline_duration_beats, - timeline_duration_frames: ci.timeline_duration_frames, - } - } - - fn apply_to(&self, ci: &mut ClipInstance) { - ci.timeline_start = self.timeline_start; - ci.timeline_start_beats = self.timeline_start_beats; - ci.timeline_start_frames = self.timeline_start_frames; - ci.trim_start = self.trim_start; - ci.trim_start_beats = self.trim_start_beats; - ci.trim_start_frames = self.trim_start_frames; - ci.trim_end = self.trim_end; - ci.trim_end_beats = self.trim_end_beats; - ci.trim_end_frames = self.trim_end_frames; - ci.timeline_duration = self.timeline_duration; - ci.timeline_duration_beats = self.timeline_duration_beats; - ci.timeline_duration_frames = self.timeline_duration_frames; - } -} - -#[derive(Clone)] -struct ClipTimingSnapshot { - layer_id: Uuid, - instance_id: Uuid, - old_fields: TimingFields, - new_fields: TimingFields, -} - -/// Action that atomically changes framerate and rescales all clip positions to preserve frames +/// Action that changes the document framerate. pub struct ChangeFpsAction { old_fps: f64, new_fps: f64, - clip_snapshots: Vec, } impl ChangeFpsAction { - /// Build the action, computing new positions for all clip instances. - pub fn new(old_fps: f64, new_fps: f64, document: &Document) -> Self { - let bpm = document.bpm; - - let mut clip_snapshots: Vec = Vec::new(); - - for layer in document.all_layers() { - let layer_id = layer.id(); - - let clip_instances: &[ClipInstance] = match layer { - AnyLayer::Vector(vl) => &vl.clip_instances, - AnyLayer::Audio(al) => &al.clip_instances, - AnyLayer::Video(vl) => &vl.clip_instances, - AnyLayer::Effect(el) => &el.clip_instances, - AnyLayer::Group(_) | AnyLayer::Raster(_) => continue, - }; - - for ci in clip_instances { - let old_fields = TimingFields::from_instance(ci); - - // Compute new fields: frames are canonical, recompute seconds + beats - let mut new_ci = ci.clone(); - new_ci.apply_frames(new_fps, bpm); - let new_fields = TimingFields::from_instance(&new_ci); - - clip_snapshots.push(ClipTimingSnapshot { - layer_id, - instance_id: ci.id, - old_fields, - new_fields, - }); - } - } - - Self { - old_fps, - new_fps, - clip_snapshots, - } - } - - fn apply_clips(document: &mut Document, snapshots: &[ClipTimingSnapshot], use_new: bool) { - for snap in snapshots { - let fields = if use_new { &snap.new_fields } else { &snap.old_fields }; - - let layer = match document.get_layer_mut(&snap.layer_id) { - Some(l) => l, - None => continue, - }; - - let clip_instances = match layer { - AnyLayer::Vector(vl) => &mut vl.clip_instances, - AnyLayer::Audio(al) => &mut al.clip_instances, - AnyLayer::Video(vl) => &mut vl.clip_instances, - AnyLayer::Effect(el) => &mut el.clip_instances, - AnyLayer::Group(_) | AnyLayer::Raster(_) => continue, - }; - - if let Some(ci) = clip_instances.iter_mut().find(|ci| ci.id == snap.instance_id) { - fields.apply_to(ci); - } - } + /// Build the action from old and new framerates. + pub fn new(old_fps: f64, new_fps: f64, _document: &Document) -> Self { + Self { old_fps, new_fps } } } impl Action for ChangeFpsAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { document.framerate = self.new_fps; - Self::apply_clips(document, &self.clip_snapshots, true); Ok(()) } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { document.framerate = self.old_fps; - Self::apply_clips(document, &self.clip_snapshots, false); Ok(()) } @@ -160,68 +37,18 @@ impl Action for ChangeFpsAction { fn execute_backend( &mut self, - backend: &mut BackendContext, + _backend: &mut BackendContext, _document: &Document, ) -> Result<(), String> { - // FPS change does not affect audio timing — only move clips that changed position - let controller = match backend.audio_controller.as_mut() { - Some(c) => c, - None => return Ok(()), - }; - - for snap in &self.clip_snapshots { - if (snap.new_fields.timeline_start - snap.old_fields.timeline_start).abs() < 1e-9 { - continue; // No movement, skip - } - let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { - Some(&id) => id, - None => continue, - }; - let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id); - match backend_id { - Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => { - controller.move_clip(track_id, *audio_id, snap.new_fields.timeline_start); - } - Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => { - controller.move_clip(track_id, *midi_id, snap.new_fields.timeline_start); - } - None => {} - } - } - + // FPS does not affect audio scheduling — nothing to do in the backend. Ok(()) } fn rollback_backend( &mut self, - backend: &mut BackendContext, + _backend: &mut BackendContext, _document: &Document, ) -> Result<(), String> { - let controller = match backend.audio_controller.as_mut() { - Some(c) => c, - None => return Ok(()), - }; - - for snap in &self.clip_snapshots { - if (snap.new_fields.timeline_start - snap.old_fields.timeline_start).abs() < 1e-9 { - continue; - } - let track_id = match backend.layer_to_track_map.get(&snap.layer_id) { - Some(&id) => id, - None => continue, - }; - let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id); - match backend_id { - Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => { - controller.move_clip(track_id, *audio_id, snap.old_fields.timeline_start); - } - Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => { - controller.move_clip(track_id, *midi_id, snap.old_fields.timeline_start); - } - None => {} - } - } - Ok(()) } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index 3a97088..fba7ee7 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -119,9 +119,6 @@ impl Action for MoveClipInstancesAction { // Store adjusted moves for rollback self.layer_moves = adjusted_moves.clone(); - let bpm = document.bpm; - let fps = document.framerate; - // Apply all adjusted moves for (layer_id, moves) in &adjusted_moves { let layer = document.get_layer_mut(layer_id) @@ -142,7 +139,6 @@ impl Action for MoveClipInstancesAction { if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) { clip_instance.timeline_start = *new; - clip_instance.sync_from_seconds(bpm, fps); } } } @@ -151,8 +147,6 @@ impl Action for MoveClipInstancesAction { } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { - let bpm = document.bpm; - let fps = document.framerate; for (layer_id, moves) in &self.layer_moves { let layer = document.get_layer_mut(layer_id) .ok_or_else(|| format!("Layer {} not found", layer_id))?; @@ -172,7 +166,6 @@ impl Action for MoveClipInstancesAction { if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) { clip_instance.timeline_start = *old; - clip_instance.sync_from_seconds(bpm, fps); } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs index d282ba0..afadffb 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -177,10 +177,10 @@ impl Action for RemoveClipInstancesAction { let midi_instance = daw_backend::MidiClipInstance::new( 0, *midi_clip_id, - internal_start, - internal_end, - external_start, - external_duration, + daw_backend::Beats(internal_start), + daw_backend::Beats(internal_end), + daw_backend::Beats(external_start), + daw_backend::Beats(external_duration), ); let query = Query::AddMidiClipInstanceSync(track_id, midi_instance); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index 6da1327..f5de9a4 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -126,8 +126,8 @@ impl Action for SplitClipInstanceAction { .get_clip_duration(&instance.clip_id) .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; - // Calculate the effective duration and timeline end - let effective_duration = instance.effective_duration(clip_duration); + // Calculate the effective duration and timeline end (both in beats) + let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let timeline_end = instance.timeline_start + effective_duration; // Validate: split_time must be strictly within the clip's timeline span @@ -179,7 +179,6 @@ impl Action for SplitClipInstanceAction { } self.new_instance_id = Some(right_instance.id); - right_instance.sync_from_seconds(document.bpm, document.framerate); // Now modify the original (left) instance and add the new (right) instance let layer_mut = document @@ -239,21 +238,6 @@ impl Action for SplitClipInstanceAction { } } - // Sync derived fields on the left (original) instance - let (bpm, fps) = (document.bpm, document.framerate); - if let Some(layer) = document.get_layer_mut(&self.layer_id) { - let cis: &mut Vec = match layer { - AnyLayer::Vector(vl) => &mut vl.clip_instances, - AnyLayer::Audio(al) => &mut al.clip_instances, - AnyLayer::Video(vl) => &mut vl.clip_instances, - AnyLayer::Effect(el) => &mut el.clip_instances, - _ => return { self.executed = true; Ok(()) }, - }; - if let Some(inst) = cis.iter_mut().find(|ci| ci.id == self.instance_id) { - inst.sync_from_seconds(bpm, fps); - } - } - self.executed = true; Ok(()) } @@ -406,10 +390,10 @@ impl Action for SplitClipInstanceAction { let instance = daw_backend::MidiClipInstance::new( 0, *midi_clip_id, - internal_start, - internal_end, - external_start, - external_duration, + daw_backend::Beats(internal_start), + daw_backend::Beats(internal_end), + daw_backend::Beats(external_start), + daw_backend::Beats(external_duration), ); let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index 78ea70d..ee349b8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -101,7 +101,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], - }; + }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { let member_old_trim = instance.trim_start; @@ -138,7 +138,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], - }; + }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { let member_old_trim_end = instance.trim_end; @@ -182,7 +182,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], - }; + }; let instance = clip_instances.iter() .find(|ci| &ci.id == instance_id) @@ -260,9 +260,6 @@ impl Action for TrimClipInstancesAction { // Store clamped trims for rollback self.layer_trims = clamped_trims.clone(); - let bpm = document.bpm; - let fps = document.framerate; - // Apply all clamped trims for (layer_id, trims) in &clamped_trims { let layer = match document.get_layer_mut(layer_id) { @@ -297,7 +294,6 @@ impl Action for TrimClipInstancesAction { clip_instance.trim_end = new.trim_value; } } - clip_instance.sync_from_seconds(bpm, fps); } } } @@ -305,8 +301,6 @@ impl Action for TrimClipInstancesAction { } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { - let bpm = document.bpm; - let fps = document.framerate; for (layer_id, trims) in &self.layer_trims { let layer = match document.get_layer_mut(layer_id) { Some(l) => l, @@ -340,7 +334,6 @@ impl Action for TrimClipInstancesAction { clip_instance.trim_end = old.trim_value; } } - clip_instance.sync_from_seconds(bpm, fps); } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/beat_time.rs b/lightningbeam-ui/lightningbeam-core/src/beat_time.rs index 52b7cdd..adce198 100644 --- a/lightningbeam-ui/lightningbeam-core/src/beat_time.rs +++ b/lightningbeam-ui/lightningbeam-core/src/beat_time.rs @@ -1,6 +1,7 @@ //! Beat/measure ↔ seconds conversion utilities use crate::document::TimeSignature; +use crate::tempo_map::TempoMap; /// Position expressed as measure, beat, tick #[derive(Debug, Clone, Copy)] @@ -11,9 +12,8 @@ pub struct MeasurePosition { } /// Convert a time in seconds to a measure position -pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> MeasurePosition { - let beats_per_second = bpm / 60.0; - let total_beats = (time * beats_per_second).max(0.0); +pub fn time_to_measure(time: f64, tempo_map: &TempoMap, time_sig: &TimeSignature) -> MeasurePosition { + let total_beats = tempo_map.inverse_transform(time).max(0.0); let beats_per_measure = time_sig.numerator as f64; let measure = (total_beats / beats_per_measure).floor() as u32 + 1; @@ -24,21 +24,20 @@ pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> Measure } /// Convert a measure position to seconds -pub fn measure_to_time(pos: MeasurePosition, bpm: f64, time_sig: &TimeSignature) -> f64 { +pub fn measure_to_time(pos: MeasurePosition, tempo_map: &TempoMap, time_sig: &TimeSignature) -> f64 { let beats_per_measure = time_sig.numerator as f64; let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure + (pos.beat as f64 - 1.0) + (pos.tick as f64 / 1000.0); - let beats_per_second = bpm / 60.0; - total_beats / beats_per_second + tempo_map.transform(total_beats) } -/// Get the duration of one beat in seconds -pub fn beat_duration(bpm: f64) -> f64 { - 60.0 / bpm +/// Get the duration of one beat in seconds at the given beat position +pub fn beat_duration(beat: f64, tempo_map: &TempoMap) -> f64 { + 60.0 / tempo_map.bpm_at(daw_backend::Beats(beat)) } -/// Get the duration of one measure in seconds -pub fn measure_duration(bpm: f64, time_sig: &TimeSignature) -> f64 { - beat_duration(bpm) * time_sig.numerator as f64 +/// Get the duration of one measure in seconds at the given beat position +pub fn measure_duration(beat: f64, tempo_map: &TempoMap, time_sig: &TimeSignature) -> f64 { + beat_duration(beat, tempo_map) * time_sig.numerator as f64 } diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 2b4b81a..68d6fb0 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -98,15 +98,20 @@ impl VectorClip { /// /// The `clip_duration_fn` resolves referenced clip durations for non-vector layers. /// Falls back to the stored `duration` field if no content exists. - pub fn content_duration(&self, framerate: f64) -> f64 { - self.content_duration_with(framerate, |_| None) + pub fn content_duration(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 { + self.content_duration_with(framerate, tempo_map, |_| None) } /// Like `content_duration`, but with a closure that resolves clip durations /// for audio/video/effect clip instances inside this movie clip. - pub fn content_duration_with(&self, framerate: f64, clip_duration_fn: impl Fn(&Uuid) -> Option) -> f64 { + /// + /// `clip_duration_fn` returns clip content duration **in seconds**. + /// Result is in **seconds**. + pub fn content_duration_with(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap, clip_duration_fn: impl Fn(&Uuid) -> Option) -> f64 { let frame_duration = 1.0 / framerate; - let mut last_time: Option = None; + // Work in beats, convert to seconds at the end. + let mut last_beats: Option = None; + let mut last_secs: Option = None; for layer_node in self.layers.iter() { // Check clip instances on ALL layer types (vector, audio, video, effect) @@ -119,33 +124,38 @@ impl VectorClip { AnyLayer::Raster(_) => &[], }; for ci in clip_instances { - let end = if let Some(td) = ci.timeline_duration { - ci.timeline_start + td + // Compute end position of this clip instance in beats + let end_beats = if let Some(td_beats) = ci.timeline_duration { + ci.timeline_start + td_beats } else if let Some(te) = ci.trim_end { - ci.timeline_start + (te - ci.trim_start).max(0.0) - } else if let Some(clip_dur) = clip_duration_fn(&ci.clip_id) { - ci.timeline_start + (clip_dur - ci.trim_start).max(0.0) + let secs = (te - ci.trim_start).max(0.0); + ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start + } else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) { + let secs = (clip_dur_secs - ci.trim_start).max(0.0); + ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start } else { continue; }; - last_time = Some(match last_time { - Some(t) => t.max(end), - None => end, - }); + last_beats = Some(last_beats.map_or(end_beats, |t: f64| t.max(end_beats))); } - // Also check vector layer keyframes + // Vector layer keyframes are in seconds if let AnyLayer::Vector(vector_layer) = &layer_node.data { if let Some(last_kf) = vector_layer.keyframes.last() { - last_time = Some(match last_time { - Some(t) => t.max(last_kf.time), - None => last_kf.time, - }); + last_secs = Some(last_secs.map_or(last_kf.time, |t: f64| t.max(last_kf.time))); } } } - match last_time { + let from_clips = last_beats.map(|b| tempo_map.transform(b)); + let combined = match (from_clips, last_secs) { + (Some(a), Some(b)) => Some(a.max(b)), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }; + + match combined { Some(t) => t + frame_duration, None => self.duration, } @@ -186,9 +196,10 @@ impl VectorClip { // Handle nested clip instances recursively for clip_instance in &vector_layer.clip_instances { - // Convert parent clip time to nested clip local time - // Apply timeline offset and playback speed, then add trim offset - let nested_clip_time = ((clip_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start; + // Convert parent clip time (seconds) to nested clip local time (seconds). + // timeline_start is in beats; convert to seconds using document BPM. + let start_secs = document.tempo_map().transform(clip_instance.timeline_start); + let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; // Look up the nested clip definition let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) { @@ -485,12 +496,12 @@ impl AudioClip { pub fn new_midi( name: impl Into, midi_clip_id: u32, - duration: f64, + duration: daw_backend::Beats, ) -> Self { Self { id: Uuid::new_v4(), name: name.into(), - duration, + duration: duration.beats_to_f64(), clip_type: AudioClipType::Midi { midi_clip_id }, folder_id: None, } @@ -589,6 +600,12 @@ impl AnyClip { /// - Timeline placement (when this instance appears on the parent layer's timeline) /// - Trimming (trim_start, trim_end within the clip's internal content) /// - Playback speed (time remapping) +/// +/// ## Coordinate systems +/// - `timeline_start` / `timeline_duration` are in **beats** (quarter-note beats). +/// Use [`crate::tempo_map::TempoMap::beats_to_seconds`] to convert to seconds. +/// - `trim_start` / `trim_end` are in **seconds** (audio/video file seek offsets; +/// not affected by BPM changes). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ClipInstance { /// Unique identifier for this instance @@ -606,52 +623,25 @@ pub struct ClipInstance { /// Optional name for this instance pub name: Option, - /// When this instance starts on the timeline (in seconds, relative to parent layer) - /// This is the external positioning - where the instance appears on the timeline - /// Default: 0.0 (start at beginning of layer) + /// When this instance starts on the timeline, in **beats**. + /// Default: 0.0 pub timeline_start: f64, - /// timeline_start in beats (quarter-note beats); derived from timeline_start - #[serde(default)] - pub timeline_start_beats: f64, - /// timeline_start in frames; derived from timeline_start - #[serde(default)] - pub timeline_start_frames: f64, - /// How long this instance appears on the timeline (in seconds) - /// If timeline_duration > (trim_end - trim_start), the trimmed content will loop + /// How long this instance appears on the timeline, in **beats**. + /// If set and longer than the trimmed content, the content will loop. /// Default: None (use trimmed clip duration, no looping) pub timeline_duration: Option, - /// timeline_duration in beats; derived from timeline_duration - #[serde(default)] - pub timeline_duration_beats: Option, - /// timeline_duration in frames; derived from timeline_duration - #[serde(default)] - pub timeline_duration_frames: Option, - /// Trim start: offset into the clip's internal content (in seconds) - /// Allows trimming the beginning of the clip - /// - For audio: offset into the audio file - /// - For video: offset into the video file - /// - For vector: offset into the animation timeline - /// Default: 0.0 (start at beginning of clip) + /// Trim start: offset into the clip's internal content, in **seconds**. + /// - For audio: byte-offset into the audio file + /// - For video: seek position in the video file + /// - For vector: time offset into the animation + /// Default: 0.0 pub trim_start: f64, - /// trim_start in beats; derived from trim_start - #[serde(default)] - pub trim_start_beats: f64, - /// trim_start in frames; derived from trim_start - #[serde(default)] - pub trim_start_frames: f64, - /// Trim end: offset into the clip's internal content (in seconds) - /// Allows trimming the end of the clip + /// Trim end: offset into the clip's internal content, in **seconds**. /// Default: None (use full clip duration) pub trim_end: Option, - /// trim_end in beats; derived from trim_end - #[serde(default)] - pub trim_end_beats: Option, - /// trim_end in frames; derived from trim_end - #[serde(default)] - pub trim_end_frames: Option, /// Playback speed multiplier /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed @@ -663,7 +653,7 @@ pub struct ClipInstance { /// Default: 1.0 pub gain: f32, - /// How far (in seconds) the looped content extends before timeline_start. + /// How far (in beats) the looped content extends before timeline_start. /// When set, loop iterations are drawn/played before the content start. /// Default: None (no pre-loop) #[serde(default, skip_serializing_if = "Option::is_none")] @@ -720,17 +710,9 @@ impl ClipInstance { opacity: 1.0, name: None, timeline_start: 0.0, - timeline_start_beats: 0.0, - timeline_start_frames: 0.0, timeline_duration: None, - timeline_duration_beats: None, - timeline_duration_frames: None, trim_start: 0.0, - trim_start_beats: 0.0, - trim_start_frames: 0.0, trim_end: None, - trim_end_beats: None, - trim_end_frames: None, playback_speed: 1.0, gain: 1.0, loop_before: None, @@ -746,71 +728,15 @@ impl ClipInstance { opacity: 1.0, name: None, timeline_start: 0.0, - timeline_start_beats: 0.0, - timeline_start_frames: 0.0, timeline_duration: None, - timeline_duration_beats: None, - timeline_duration_frames: None, trim_start: 0.0, - trim_start_beats: 0.0, - trim_start_frames: 0.0, trim_end: None, - trim_end_beats: None, - trim_end_frames: None, playback_speed: 1.0, gain: 1.0, loop_before: None, } } - /// Sync beats and frames from the seconds fields (call after any seconds-based write). - pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { - self.timeline_start_beats = self.timeline_start * bpm / 60.0; - self.timeline_start_frames = self.timeline_start * fps; - self.trim_start_beats = self.trim_start * bpm / 60.0; - self.trim_start_frames = self.trim_start * fps; - self.trim_end_beats = self.trim_end.map(|v| v * bpm / 60.0); - self.trim_end_frames = self.trim_end.map(|v| v * fps); - self.timeline_duration_beats = self.timeline_duration.map(|v| v * bpm / 60.0); - self.timeline_duration_frames = self.timeline_duration.map(|v| v * fps); - } - - /// Recompute seconds and frames from beats (call when BPM changes in Measures mode). - pub fn apply_beats(&mut self, bpm: f64, fps: f64) { - self.timeline_start = self.timeline_start_beats * 60.0 / bpm; - self.timeline_start_frames = self.timeline_start * fps; - self.trim_start = self.trim_start_beats * 60.0 / bpm; - self.trim_start_frames = self.trim_start * fps; - if let Some(b) = self.trim_end_beats { - let s = b * 60.0 / bpm; - self.trim_end = Some(s); - self.trim_end_frames = Some(s * fps); - } - if let Some(b) = self.timeline_duration_beats { - let s = b * 60.0 / bpm; - self.timeline_duration = Some(s); - self.timeline_duration_frames = Some(s * fps); - } - } - - /// Recompute seconds and beats from frames (call when FPS changes in Frames mode). - pub fn apply_frames(&mut self, fps: f64, bpm: f64) { - self.timeline_start = self.timeline_start_frames / fps; - self.timeline_start_beats = self.timeline_start * bpm / 60.0; - self.trim_start = self.trim_start_frames / fps; - self.trim_start_beats = self.trim_start * bpm / 60.0; - if let Some(f) = self.trim_end_frames { - let s = f / fps; - self.trim_end = Some(s); - self.trim_end_beats = Some(s * bpm / 60.0); - } - if let Some(f) = self.timeline_duration_frames { - let s = f / fps; - self.timeline_duration = Some(s); - self.timeline_duration_beats = Some(s * bpm / 60.0); - } - } - /// Set the transform pub fn with_transform(mut self, transform: Transform) -> Self { self.transform = transform; @@ -861,83 +787,83 @@ impl ClipInstance { self } - /// Set explicit timeline duration by setting trim_end - /// - /// For effect instances, this effectively sets the duration since - /// effects have infinite internal duration (trim_start defaults to 0). - pub fn with_timeline_duration(mut self, duration: f64) -> Self { - self.trim_end = Some(self.trim_start + duration); + /// Set explicit timeline duration (in beats) by directly setting `timeline_duration`. + pub fn with_timeline_duration(mut self, duration_beats: f64) -> Self { + self.timeline_duration = Some(duration_beats); self } - /// Get the effective duration of this instance (accounting for trimming and looping) - /// If timeline_duration is set, returns that (enabling content looping) - /// Otherwise returns the trimmed content duration - pub fn effective_duration(&self, clip_duration: f64) -> f64 { - // If timeline_duration is explicitly set, use that (for looping) - if let Some(timeline_dur) = self.timeline_duration { - return timeline_dur; - } - - // Otherwise, return the trimmed content duration - let end = self.trim_end.unwrap_or(clip_duration); + /// Content window size in seconds: `trim_end - trim_start`. + /// Used for internal looping calculations. + pub fn content_window_secs(&self, clip_duration_secs: f64) -> f64 { + let end = self.trim_end.unwrap_or(clip_duration_secs); (end - self.trim_start).max(0.0) } - /// Get the effective start position on the timeline, accounting for loop_before. - /// This is the left edge of the clip's visual extent. + /// How long this instance appears on the timeline, in **beats**. + /// + /// If `timeline_duration` is set, returns that (enabling content looping). + /// Otherwise converts the content window from seconds to beats using the tempo map. + pub fn effective_duration_beats(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 { + if let Some(td) = self.timeline_duration { + return td; + } + let window_secs = self.content_window_secs(clip_duration_secs); + let start_secs = tempo_map.transform(self.timeline_start); + tempo_map.inverse_transform(start_secs + window_secs) - self.timeline_start + } + + /// Left edge of the clip's visual extent on the timeline, in **beats**. pub fn effective_start(&self) -> f64 { self.timeline_start - self.loop_before.unwrap_or(0.0) } - /// Get the total visual duration including both loop_before and effective_duration. - pub fn total_duration(&self, clip_duration: f64) -> f64 { - self.loop_before.unwrap_or(0.0) + self.effective_duration(clip_duration) + /// Total visual duration (loop_before + effective_duration), in **beats**. + pub fn total_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 { + self.loop_before.unwrap_or(0.0) + self.effective_duration_beats(clip_duration_secs, tempo_map) } - /// Remap timeline time to clip content time + /// Map a playback time (in **seconds**) to clip-local content time (in **seconds**). /// - /// Takes a global timeline time and returns the corresponding time within this - /// clip's content, accounting for: - /// - Instance position (timeline_start) - /// - Playback speed - /// - Trimming (trim_start, trim_end) - /// - Looping (if timeline_duration > content window) - /// - /// Returns None if the clip instance is not active at the given timeline time. - pub fn remap_time(&self, timeline_time: f64, clip_duration: f64) -> Option { - // Check if clip instance is active at this time - let instance_end = self.timeline_start + self.effective_duration(clip_duration); - if timeline_time < self.timeline_start || timeline_time >= instance_end { + /// Returns `None` if the clip instance is not active at `time_secs`. + pub fn remap_time_secs(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option { + let start_secs = tempo_map.transform(self.timeline_start); + let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map); + let end_secs = tempo_map.transform(self.timeline_start + dur_beats); + + if time_secs < start_secs || time_secs >= end_secs { return None; } - // Calculate relative time within the instance (0.0 = start of instance) - let relative_time = timeline_time - self.timeline_start; + let relative_secs = time_secs - start_secs; + let content_time = relative_secs * self.playback_speed; + let content_window = self.content_window_secs(clip_duration_secs); - // Account for playback speed - let content_time = relative_time * self.playback_speed; - - // Get the content window size (the portion of clip we're sampling) - let trim_end = self.trim_end.unwrap_or(clip_duration); - let content_window = (trim_end - self.trim_start).max(0.0); - - // If content_window is zero, can't sample anything if content_window == 0.0 { return Some(self.trim_start); } - // Apply looping if content exceeds the window let looped_time = if content_time > content_window { content_time % content_window } else { content_time }; - // Add trim_start offset to get final clip time Some(self.trim_start + looped_time) } + /// Alias for `remap_time_secs`. + #[inline] + pub fn remap_time(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option { + self.remap_time_secs(time_secs, clip_duration_secs, tempo_map) + } + + /// Alias for `effective_duration_beats`. + #[inline] + pub fn effective_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 { + self.effective_duration_beats(clip_duration_secs, tempo_map) + } + /// Convert to affine transform pub fn to_affine(&self) -> vello::kurbo::Affine { self.transform.to_affine() diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index c33ea98..d8c9e3b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -6,10 +6,11 @@ use crate::asset_folder::AssetFolderTree; use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip}; use crate::effect::EffectDefinition; -use crate::layer::AnyLayer; +use crate::layer::{AnyLayer, GroupLayer}; use crate::script::ScriptDefinition; use crate::layout::LayoutNode; use crate::shape::ShapeColor; +use crate::tempo_map::TempoMap; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use uuid::Uuid; @@ -131,6 +132,8 @@ impl Default for TimeSignature { } } +// Keep for backward serde compat (old files may have a `bpm` field); no longer used. +#[allow(dead_code)] fn default_bpm() -> f64 { 120.0 } /// How time is displayed in the timeline @@ -173,14 +176,16 @@ pub struct Document { /// Framerate (frames per second) pub framerate: f64, - /// Tempo in beats per minute - #[serde(default = "default_bpm")] - pub bpm: f64, - /// Time signature #[serde(default)] pub time_signature: TimeSignature, + /// Master track (master bus + tempo automation lane). + /// Stored separately from the root layer tree; shown in timeline when + /// `show_master_track` is enabled in the editor state. + #[serde(default)] + pub master_layer: GroupLayer, + /// Duration in seconds pub duration: f64, @@ -266,8 +271,12 @@ impl Default for Document { width: 1920.0, height: 1080.0, framerate: 60.0, - bpm: 120.0, time_signature: TimeSignature::default(), + master_layer: { + let mut ml = GroupLayer::new_master(120.0); + ml.layer.id = uuid::Uuid::new_v4(); + ml + }, duration: 10.0, root: GraphicsObject::default(), vector_clips: HashMap::new(), @@ -311,6 +320,28 @@ impl Document { } } + /// Reference to the document's tempo map (stored on the master layer). + pub fn tempo_map(&self) -> &TempoMap { + self.master_layer.tempo_map.as_ref() + .expect("master_layer always has a tempo_map") + } + + /// Mutable reference to the document's tempo map. + pub fn tempo_map_mut(&mut self) -> &mut TempoMap { + self.master_layer.tempo_map.as_mut() + .expect("master_layer always has a tempo_map") + } + + /// Convenience accessor for the global BPM (first entry of the tempo map). + pub fn bpm(&self) -> f64 { + self.tempo_map().global_bpm() + } + + /// Set the global BPM. Rebuilds the tempo map's seconds cache. + pub fn set_bpm(&mut self, bpm: f64) { + self.tempo_map_mut().set_global_bpm(bpm); + } + /// Rebuild the layer→clip reverse lookup map from all vector clips. /// Call after deserialization or bulk clip modifications. pub fn rebuild_layer_to_clip_map(&mut self) { @@ -619,56 +650,6 @@ impl Document { layers } - /// Migrate old documents: compute beats/frames from seconds for any ClipInstance whose - /// derived fields are still zero (i.e., documents saved before triple-representation). - /// Call once after loading a document. - pub fn sync_all_clip_positions(&mut self) { - let bpm = self.bpm; - let fps = self.framerate; - - fn sync_list(list: &mut [crate::layer::AnyLayer], bpm: f64, fps: f64) { - for layer in list.iter_mut() { - match layer { - crate::layer::AnyLayer::Vector(vl) => { - for ci in &mut vl.clip_instances { - if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); } - } - } - crate::layer::AnyLayer::Audio(al) => { - for ci in &mut al.clip_instances { - if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); } - } - } - crate::layer::AnyLayer::Video(vl) => { - for ci in &mut vl.clip_instances { - if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); } - } - } - crate::layer::AnyLayer::Effect(el) => { - for ci in &mut el.clip_instances { - if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); } - } - } - crate::layer::AnyLayer::Group(g) => { - sync_list(&mut g.children, bpm, fps); - } - crate::layer::AnyLayer::Raster(_) => {} - } - } - } - - sync_list(&mut self.root.children, bpm, fps); - for clip in self.vector_clips.values_mut() { - for node in &mut clip.layers.roots { - if let crate::layer::AnyLayer::Vector(vl) = &mut node.data { - for ci in &mut vl.clip_instances { - if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); } - } - } - } - } - } - // === CLIP LIBRARY METHODS === /// Add a vector clip to the library @@ -866,11 +847,12 @@ impl Document { if clip.is_group { Some(clip.duration) } else { - Some(clip.content_duration_with(self.framerate, |id| { + let tempo_map = self.tempo_map(); + Some(clip.content_duration_with(self.framerate, tempo_map, |id| { // Resolve nested clip durations (audio, video, other vector clips) if let Some(vc) = self.vector_clips.get(id) { // Avoid deep recursion — use stored duration for nested vector clips - Some(vc.content_duration(self.framerate)) + Some(vc.content_duration(self.framerate, tempo_map)) } else if let Some(ac) = self.audio_clips.get(id) { Some(ac.duration) } else if let Some(vc) = self.video_clips.get(id) { @@ -961,7 +943,7 @@ impl Document { }; let instance_start = instance.effective_start(); - let instance_end = instance.timeline_start + instance.effective_duration(clip_duration); + let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map()); // Check overlap: start_a < end_b AND start_b < end_a if start_time < instance_end && instance_start < end_time { @@ -1019,7 +1001,7 @@ impl Document { if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) { let inst_start = instance.effective_start(); - let inst_end = instance.timeline_start + instance.effective_duration(clip_dur); + let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map()); occupied_ranges.push((inst_start, inst_end, instance.id)); } } @@ -1114,7 +1096,7 @@ impl Document { } if let Some(dur) = self.get_clip_duration(&inst.clip_id) { let start = inst.effective_start(); - let end = inst.timeline_start + inst.effective_duration(dur); + let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map()); non_group.push((start, end)); } } @@ -1184,7 +1166,7 @@ impl Document { // Calculate other clip's extent (accounting for loop_before) if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { - let other_end = other.timeline_start + other.effective_duration(clip_duration); + let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); // If this clip is to the left and closer than current nearest if other_end <= current_timeline_start && other_end > nearest_end { @@ -1280,7 +1262,7 @@ impl Document { } if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { - let other_end = other.timeline_start + other.effective_duration(clip_duration); + let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); if other_end <= current_effective_start && other_end > nearest_end { nearest_end = other_end; diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs index 0ce50d2..fb919e8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs @@ -142,16 +142,18 @@ impl EffectLayer { self.clip_instances.iter_mut().find(|e| &e.id == id) } - /// Get all clip instances (effects) that are active at a given time + /// Get all clip instances (effects) that are active at a given time. /// - /// Uses `EFFECT_DURATION` to calculate effective duration for each instance. - pub fn active_clip_instances_at(&self, time: f64) -> Vec<&ClipInstance> { + /// `time_secs` is the playback time in seconds; `bpm` is used to convert + /// `timeline_start` (beats) to seconds for comparison. + pub fn active_clip_instances_at(&self, time_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Vec<&ClipInstance> { use crate::effect::EFFECT_DURATION; + let time_beats = tempo_map.inverse_transform(time_secs); self.clip_instances .iter() .filter(|e| { - let end = e.timeline_start + e.effective_duration(EFFECT_DURATION); - time >= e.timeline_start && time < end + let end = e.timeline_start + e.effective_duration(EFFECT_DURATION, tempo_map); + time_beats >= e.timeline_start && time_beats < end }) .collect() } @@ -239,13 +241,13 @@ mod tests { layer.add_clip_instance(effect2); // At time 2: only effect1 active - assert_eq!(layer.active_clip_instances_at(2.0).len(), 1); + assert_eq!(layer.active_clip_instances_at(2.0, 60.0).len(), 1); // At time 4: both effects active - assert_eq!(layer.active_clip_instances_at(4.0).len(), 2); + assert_eq!(layer.active_clip_instances_at(4.0, 60.0).len(), 2); // At time 7: only effect2 active - assert_eq!(layer.active_clip_instances_at(7.0).len(), 1); + assert_eq!(layer.active_clip_instances_at(7.0, 60.0).len(), 1); } #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/export.rs b/lightningbeam-ui/lightningbeam-core/src/export.rs index 9c95045..4bef658 100644 --- a/lightningbeam-ui/lightningbeam-core/src/export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/export.rs @@ -76,6 +76,9 @@ pub struct AudioExportSettings { /// End time in seconds pub end_time: f64, + + /// Project BPM (for beat-position scheduling during export) + pub bpm: f64, } impl Default for AudioExportSettings { @@ -88,6 +91,7 @@ impl Default for AudioExportSettings { bitrate_kbps: 320, start_time: 0.0, end_time: 60.0, + bpm: 120.0, } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs index 9ef8506..fe5fb74 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_io.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -491,6 +491,8 @@ pub fn load_beam(path: &Path) -> Result { // 5. Extract document and audio backend state let step5_start = std::time::Instant::now(); let mut document = beam_project.ui_state; + // Rebuild derived seconds cache on all TempoMap entries after deserialization. + document.tempo_map_mut().rebuild_seconds(); let mut audio_project = beam_project.audio_backend.project; let audio_pool_entries = beam_project.audio_backend.audio_pool_entries; let layer_to_track_map = beam_project.audio_backend.layer_to_track_map; diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index 66cb18c..8e647c6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -256,15 +256,20 @@ pub fn hit_test_clip_instances( parent_transform: Affine, timeline_time: f64, ) -> Option { + let tempo_map = document.tempo_map(); for clip_instance in clip_instances.iter().rev() { // Check time bounds: skip clip instances not active at this time + // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0); - let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration); - if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end { + let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); + let timeline_beats = tempo_map.inverse_transform(timeline_time); + if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { continue; } - let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start; + // clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds) + let start_secs = tempo_map.transform(clip_instance.timeline_start); + let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { vector_clip.calculate_content_bounds(document, clip_time) @@ -294,16 +299,20 @@ pub fn hit_test_clip_instances_in_rect( timeline_time: f64, ) -> Vec { let mut hits = Vec::new(); + let tempo_map = document.tempo_map(); for clip_instance in clip_instances { // Check time bounds: skip clip instances not active at this time + // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0); - let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration); - if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end { + let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); + let timeline_beats = tempo_map.inverse_transform(timeline_time); + if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { continue; } - let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start; + let start_secs = tempo_map.transform(clip_instance.timeline_start); + let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { vector_clip.calculate_content_bounds(document, clip_time) diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 53686ae..dd900c0 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -3,6 +3,7 @@ //! Layers organize objects and shapes, and contain animation data. use crate::animation::AnimationData; +use crate::tempo_map::TempoMap; use crate::clip::ClipInstance; use crate::vector_graph::VectorGraph; use crate::effect_layer::EffectLayer; @@ -720,6 +721,12 @@ pub struct GroupLayer { /// Whether the group is expanded in the timeline #[serde(default = "default_true")] pub expanded: bool, + + /// Optional tempo map for this group. When `Some`, the group's children use + /// this tempo map for beat-to-parent-time conversion. The master group always + /// has `Some`; regular groups default to `None` (inherit parent tempo). + #[serde(default)] + pub tempo_map: Option, } fn default_true() -> bool { @@ -746,6 +753,12 @@ impl LayerTrait for GroupLayer { fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; } } +impl Default for GroupLayer { + fn default() -> Self { + Self::new("Group") + } +} + impl GroupLayer { /// Create a new group layer pub fn new(name: impl Into) -> Self { @@ -753,6 +766,21 @@ impl GroupLayer { layer: Layer::new(LayerType::Group, name), children: Vec::new(), expanded: true, + tempo_map: None, + } + } + + /// Create a master group layer with a constant tempo. + pub fn new_master(bpm: f64) -> Self { + Self { + layer: Layer::with_id( + uuid::Uuid::nil(), // replaced by Document::new + LayerType::Group, + "Master", + ), + children: Vec::new(), + expanded: true, + tempo_map: Some(TempoMap::constant(bpm)), } } @@ -773,7 +801,7 @@ impl GroupLayer { AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Group(_) => &[], // no nested groups AnyLayer::Raster(_) => &[], // raster layers have no clip instances - }; + }; for ci in instances { result.push((child_id, ci)); } diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 0bb31e6..40ce30f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -2,6 +2,7 @@ // Shared data structures and types pub mod beat_time; +pub mod tempo_map; pub mod gpu; pub mod layout; pub mod pane; diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 4df00af..674495a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -391,9 +391,10 @@ pub fn render_layer_isolated( let mut video_mgr = video_manager.lock().unwrap(); let mut instances = Vec::new(); + let tempo_map = document.tempo_map(); for clip_instance in &video_layer.clip_instances { let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; - let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else { continue }; + let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else { continue }; let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time) else { continue }; // Evaluate animated transform properties. @@ -458,8 +459,9 @@ pub fn render_layer_isolated( AnyLayer::Effect(effect_layer) => { // Effect layers are processed during compositing, not rendered to scene // Return early with a dedicated effect layer type + let tempo_map = document.tempo_map(); let active_effects: Vec = effect_layer - .active_clip_instances_at(time) + .active_clip_instances_at(time, tempo_map) .into_iter() .cloned() .collect(); @@ -728,16 +730,19 @@ fn render_clip_instance( }; // Remap timeline time to clip's internal time + let tempo_map = document.tempo_map(); let clip_time = if vector_clip.is_group { - // Groups are static — visible from timeline_start to the next keyframe boundary - let end = group_end_time.unwrap_or(clip_instance.timeline_start); - if time < clip_instance.timeline_start || time >= end { + // Groups are static — visible from timeline_start to the next keyframe boundary. + // timeline_start is in beats; group_end_time is in seconds (render time). + let start_secs = tempo_map.transform(clip_instance.timeline_start); + let end = group_end_time.unwrap_or(start_secs); + if time < start_secs || time >= end { return; } 0.0 } else { let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration); - let Some(t) = clip_instance.remap_time(time, clip_dur) else { + let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else { return; // Clip instance not active at this time }; t @@ -889,7 +894,8 @@ fn render_video_layer( }; // Remap timeline time to clip's internal time - let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else { + let tempo_map = document.tempo_map(); + let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else { continue; // Clip instance not active at this time }; @@ -1553,13 +1559,15 @@ fn render_clip_instance_cpu( ) { let Some(vector_clip) = document.vector_clips.get(&clip_instance.clip_id) else { return }; + let tempo_map = document.tempo_map(); let clip_time = if vector_clip.is_group { - let end = group_end_time.unwrap_or(clip_instance.timeline_start); - if time < clip_instance.timeline_start || time >= end { return; } + let start_secs = tempo_map.transform(clip_instance.timeline_start); + let end = group_end_time.unwrap_or(start_secs); + if time < start_secs || time >= end { return; } 0.0 } else { let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration); - let Some(t) = clip_instance.remap_time(time, clip_dur) else { return }; + let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else { return }; t }; diff --git a/lightningbeam-ui/lightningbeam-core/src/tempo_map.rs b/lightningbeam-ui/lightningbeam-core/src/tempo_map.rs new file mode 100644 index 0000000..5d1cc40 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/tempo_map.rs @@ -0,0 +1 @@ +pub use daw_backend::tempo_map::{TempoEntry, TempoMap}; diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs index 928d7f8..405fb14 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs @@ -69,8 +69,9 @@ fn export_audio_daw_backend>( channels: settings.channels, bit_depth: settings.bit_depth, mp3_bitrate: 320, // Not used for WAV/FLAC - start_time: settings.start_time, - end_time: settings.end_time, + start_time: daw_backend::Seconds(settings.start_time), + end_time: daw_backend::Seconds(settings.end_time), + tempo_map: daw_backend::TempoMap::constant(settings.bpm), }; // Use the existing DAW backend export function @@ -105,8 +106,9 @@ fn export_audio_ffmpeg_mp3>( channels: settings.channels, bit_depth: 16, // Unused mp3_bitrate: settings.bitrate_kbps, - start_time: settings.start_time, - end_time: settings.end_time, + start_time: daw_backend::Seconds(settings.start_time), + end_time: daw_backend::Seconds(settings.end_time), + tempo_map: daw_backend::TempoMap::constant(settings.bpm), }; // Step 1: Render audio to memory @@ -294,8 +296,9 @@ fn export_audio_ffmpeg_aac>( channels: settings.channels, bit_depth: 16, // Unused mp3_bitrate: settings.bitrate_kbps, - start_time: settings.start_time, - end_time: settings.end_time, + start_time: daw_backend::Seconds(settings.start_time), + end_time: daw_backend::Seconds(settings.end_time), + tempo_map: daw_backend::TempoMap::constant(settings.bpm), }; // Step 1: Render audio to memory diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index d50f400..957d415 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -696,8 +696,9 @@ impl ExportOrchestrator { channels: settings.channels, bit_depth: settings.bit_depth, mp3_bitrate: settings.bitrate_kbps, - start_time: settings.start_time, - end_time: settings.end_time, + start_time: daw_backend::Seconds(settings.start_time), + end_time: daw_backend::Seconds(settings.end_time), + tempo_map: daw_backend::TempoMap::constant(settings.bpm), }; // Use DAW backend export for all formats diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index 783b836..ec8b7fc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -882,7 +882,7 @@ fn composite_document_to_hdr( let effect_inst = lightningbeam_core::effect::EffectInstance::new( effect_def, effect_instance.timeline_start, - effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION), + effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, document.tempo_map()), ); let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 8f90521..e641187 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1844,7 +1844,7 @@ impl EditorApp { let mut result = Vec::new(); for instance in clip_instances { if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) { - let effective_duration = instance.effective_duration(clip_duration); + let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let timeline_end = instance.timeline_start + effective_duration; const EPSILON: f64 = 0.001; @@ -2698,7 +2698,7 @@ impl EditorApp { let mut duplicate = original.clone(); duplicate.id = uuid::Uuid::new_v4(); let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(1.0); - let effective_duration = original.effective_duration(clip_duration); + let effective_duration = original.effective_duration(clip_duration, document.tempo_map()); duplicate.timeline_start = original.timeline_start + effective_duration; if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) { duplicate.clip_id = *new_clip_def_id; @@ -3771,7 +3771,7 @@ impl EditorApp { // Sync BPM/time signature to metronome let doc = self.action_executor.document(); controller.set_tempo( - doc.bpm as f32, + doc.bpm() as f32, (doc.time_signature.numerator, doc.time_signature.denominator), ); } @@ -3834,9 +3834,6 @@ impl EditorApp { // Rebuild MIDI event cache for all MIDI clips (needed for timeline/piano roll rendering) let step8_start = std::time::Instant::now(); - // Migrate old documents: compute beats/frames derived fields - self.action_executor.document_mut().sync_all_clip_positions(); - self.midi_event_cache.clear(); let midi_clip_ids: Vec = self.action_executor.document() .audio_clips.values() @@ -3859,19 +3856,6 @@ impl EditorApp { } eprintln!("📊 [APPLY] Step 8: Rebuilt MIDI event cache for {} clips in {:.2}ms", midi_fetched, step8_start.elapsed().as_secs_f64() * 1000.0); - // Sync beats/frames derived fields on MIDI events (migration for old documents) - { - let bpm = self.action_executor.document().bpm; - let fps = self.action_executor.document().framerate; - for events in self.midi_event_cache.values_mut() { - for ev in events.iter_mut() { - if ev.timestamp_beats == 0.0 && ev.timestamp.abs() > 1e-9 { - ev.sync_from_seconds(bpm, fps); - } - } - } - } - // Reset playback state self.playback_time = 0.0; self.is_playing = false; @@ -4002,15 +3986,11 @@ impl EditorApp { /// Rebuild a MIDI event cache entry from backend note format. /// Called after undo/redo to keep the cache consistent with the backend. fn rebuild_midi_cache_entry(&mut self, clip_id: u32, notes: &[(f64, u8, u8, f64)]) { - let bpm = self.action_executor.document().bpm; - let fps = self.action_executor.document().framerate; let mut events: Vec = Vec::with_capacity(notes.len() * 2); for &(start_time, note, velocity, duration) in notes { - let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity); - on.sync_from_seconds(bpm, fps); + let on = daw_backend::audio::midi::MidiEvent::note_on(daw_backend::Beats(start_time), 0, note, velocity); events.push(on); - let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + duration, 0, note, 0); - off.sync_from_seconds(bpm, fps); + let off = daw_backend::audio::midi::MidiEvent::note_off(daw_backend::Beats(start_time + duration), 0, note, 0); events.push(off); } events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); @@ -4048,14 +4028,14 @@ impl EditorApp { let frontend_clip_id = self.action_executor.document_mut().add_audio_clip(clip); println!("Imported MIDI '{}' ({:.1}s, {} total events) - Frontend ID: {}, Backend ID: {}", - name, duration, event_count, frontend_clip_id, backend_clip_id); + name, duration.beats_to_f64(), event_count, frontend_clip_id, backend_clip_id); Some(ImportedAssetInfo { clip_id: frontend_clip_id, clip_type: panes::DragClipType::AudioMidi, name, dimensions: None, - duration, + duration: duration.beats_to_f64(), linked_audio_clip_id: None, }) } else { @@ -5032,7 +5012,7 @@ impl eframe::App for EditorApp { if let Some(doc_clip_id) = doc_clip_id { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { if clip.is_recording() { - clip.duration = duration; + clip.duration = duration.seconds_to_f64(); } } } @@ -5202,24 +5182,18 @@ impl eframe::App for EditorApp { } // Update the clip's duration so the timeline bar grows if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { - clip.duration = duration; + clip.duration = duration.beats_to_f64(); } } } - // Update midi_event_cache with notes captured so far - // (inlined to avoid conflicting &mut self borrow) + // Update midi_event_cache with notes captured so far. + // Notes from get_notes_with_active() are already in beats. { - let bpm = self.action_executor.document().bpm; - let fps = self.action_executor.document().framerate; let mut events: Vec = Vec::with_capacity(notes.len() * 2); - for &(start_time, note, velocity, dur) in ¬es { - let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity); - on.sync_from_seconds(bpm, fps); - events.push(on); - let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + dur, 0, note, 0); - off.sync_from_seconds(bpm, fps); - events.push(off); + for &(start_beats, note, velocity, dur_beats) in ¬es { + events.push(daw_backend::audio::midi::MidiEvent::note_on(start_beats, 0, note, velocity)); + events.push(daw_backend::audio::midi::MidiEvent::note_off(start_beats + dur_beats, 0, note, 0)); } events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); self.midi_event_cache.insert(clip_id, events); @@ -5236,13 +5210,7 @@ impl eframe::App for EditorApp { match controller.query_midi_clip(track_id, clip_id) { Ok(midi_clip_data) => { drop(controller); - let bpm = self.action_executor.document().bpm; - let fps = self.action_executor.document().framerate; - let mut final_events = midi_clip_data.events.clone(); - for ev in &mut final_events { - ev.sync_from_seconds(bpm, fps); - } - self.midi_event_cache.insert(clip_id, final_events); + self.midi_event_cache.insert(clip_id, midi_clip_data.events.clone()); // Update document clip with final duration and name let doc_clip_id = self.action_executor.document() diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index d3d22e7..3e3cd41 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -391,10 +391,10 @@ fn generate_midi_thumbnail( // Draw note events for event in events { - if !event.is_note_on() || event.timestamp > preview_duration { + if !event.is_note_on() || event.timestamp.beats_to_f64() > preview_duration { continue; } - let (timestamp, note_number) = (event.timestamp, event.data1); + let (timestamp, note_number) = (event.timestamp.beats_to_f64(), event.data1); let x = ((timestamp / preview_duration) * size as f64) as usize; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 9ca1bfd..3b16748 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -1147,7 +1147,7 @@ impl InfopanelPane { let clip_dur = document.get_clip_duration(&ci.clip_id) .unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start); - let total_dur = ci.total_duration(clip_dur); + let total_dur = ci.total_duration(clip_dur, document.tempo_map()); ui.horizontal(|ui| { ui.label("Duration:"); ui.label(format!("{:.2}s", total_dur)); @@ -1215,10 +1215,10 @@ impl InfopanelPane { let mut pending: std::collections::HashMap = std::collections::HashMap::new(); for event in events { if event.is_note_on() { - pending.insert(event.data1, (event.timestamp, event.data2)); + pending.insert(event.data1, (event.timestamp.beats_to_f64(), event.data2)); } else if event.is_note_off() { if let Some((start, v)) = pending.remove(&event.data1) { - notes.push((start, event.data1, v, event.timestamp - start)); + notes.push((start, event.data1, v, event.timestamp.beats_to_f64() - start)); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 695f15f..8589c51 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -255,10 +255,10 @@ impl PianoRollPane { for event in events { let channel = event.status & 0x0F; if event.is_note_on() { - active.insert(event.data1, (event.timestamp, event.data2, channel)); + active.insert(event.data1, (event.timestamp.beats_to_f64(), event.data2, channel)); } else if event.is_note_off() { if let Some((start, vel, ch)) = active.remove(&event.data1) { - let duration = (event.timestamp - start).max(MIN_NOTE_DURATION); + let duration = (event.timestamp.beats_to_f64() - start).max(MIN_NOTE_DURATION); notes.push(ResolvedNote { note: event.data1, channel: ch, @@ -292,13 +292,13 @@ impl PianoRollPane { // ── Ruler interval calculation ─────────────────────────────────────── - fn ruler_interval(&self, bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 { + fn ruler_interval(&self, tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 { let min_pixel_gap = 80.0; let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64; // Use beat-aligned intervals - let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm); - let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig); + let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map); + let measure_dur = lightningbeam_core::beat_time::measure_duration(0.0, tempo_map, time_sig); let beat_intervals = [ beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0, measure_dur, measure_dur * 2.0, measure_dur * 4.0, @@ -315,8 +315,8 @@ impl PianoRollPane { } // end impl PianoRollPane (snap helpers follow as free functions) -fn snap_to_value(t: f64, snap: SnapValue, bpm: f64) -> f64 { - let beat = 60.0 / bpm; +fn snap_to_value(t: f64, snap: SnapValue, tempo_map: &daw_backend::TempoMap) -> f64 { + let beat = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map); match snap { SnapValue::None => t, SnapValue::Whole => round_to_grid(t, beat * 4.0), @@ -347,7 +347,7 @@ fn snap_swing(t: f64, cell: f64, ratio: f64) -> f64 { *cands.iter().min_by(|&&a, &&b| (a - t).abs().partial_cmp(&(b - t).abs()).unwrap()).unwrap() } -fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue { +fn detect_snap(notes: &[&ResolvedNote], tempo_map: &daw_backend::TempoMap) -> SnapValue { const EPS: f64 = 0.002; if notes.is_empty() { return SnapValue::None; } let order = [ @@ -359,7 +359,7 @@ fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue { SnapValue::SixteenthTriplet, SnapValue::ThirtySecondTriplet, ]; for &sv in &order { - if notes.iter().all(|n| (snap_to_value(n.start_time, sv, bpm) - n.start_time).abs() < EPS) { + if notes.iter().all(|n| (snap_to_value(n.start_time, sv, tempo_map) - n.start_time).abs() < EPS) { return sv; } } @@ -425,7 +425,7 @@ impl PianoRollPane { let clip_doc_id = clip_doc_id; // doc-side AudioClip uuid let duration = mc.external_duration; let instance_uuid = Uuid::nil(); // no doc-side instance uuid yet - clip_data.push((mc.clip_id, mc.external_start, mc.internal_start, duration, instance_uuid)); + clip_data.push((mc.clip_id, mc.external_start.beats_to_f64(), mc.internal_start.beats_to_f64(), duration.beats_to_f64(), instance_uuid)); let _ = clip_doc_id; // used above for the if-let pattern } } @@ -435,7 +435,7 @@ impl PianoRollPane { for instance in &audio_layer.clip_instances { if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { - let duration = instance.effective_duration(clip.duration); + let duration = instance.effective_duration(clip.duration, document.tempo_map()); clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id)); } } @@ -456,8 +456,8 @@ impl PianoRollPane { self.snap_user_changed = false; if self.snap_value != SnapValue::None && !self.selected_note_indices.is_empty() { if let Some(clip_id) = self.selected_clip_id { - let bpm = shared.action_executor.document().bpm; - self.quantize_selected_notes(clip_id, bpm, shared); + let tempo_map = shared.action_executor.document().tempo_map().clone(); + self.quantize_selected_notes(clip_id, &tempo_map, shared); } } } @@ -484,11 +484,11 @@ impl PianoRollPane { // Render grid (clipped to grid area) let grid_painter = ui.painter_at(grid_rect); - let (grid_bpm, grid_time_sig) = { + let (grid_tempo_map, grid_time_sig) = { let doc = shared.action_executor.document(); - (doc.bpm, doc.time_signature.clone()) + (doc.tempo_map().clone(), doc.time_signature.clone()) }; - self.render_grid(&grid_painter, grid_rect, grid_bpm, &grid_time_sig); + self.render_grid(&grid_painter, grid_rect, &grid_tempo_map, &grid_time_sig); // Render clip boundaries and notes for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data { @@ -621,7 +621,7 @@ impl PianoRollPane { } fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect, - bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) { + tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) { // Horizontal lines (note separators) for note in MIN_NOTE..=MAX_NOTE { let y = self.note_to_y(note, grid_rect); @@ -648,9 +648,9 @@ impl PianoRollPane { } // Vertical lines (beat-aligned time grid) - let interval = self.ruler_interval(bpm, time_sig); - let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm); - let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig); + let interval = self.ruler_interval(tempo_map, time_sig); + let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map); + let measure_dur = lightningbeam_core::beat_time::measure_duration(0.0, tempo_map, time_sig); let start = (self.viewport_start_time / interval).floor() as i64; let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64; @@ -675,7 +675,7 @@ impl PianoRollPane { // Labels at measure boundaries if is_measure && x > grid_rect.min.x + 20.0 { - let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig); + let pos = lightningbeam_core::beat_time::time_to_measure(time, tempo_map, time_sig); painter.text( pos2(x + 2.0, grid_rect.min.y + 2.0), Align2::LEFT_TOP, @@ -685,7 +685,7 @@ impl PianoRollPane { ); } else if is_beat && !is_measure && x > grid_rect.min.x + 20.0 && beat_dur as f32 * self.pixels_per_second > 40.0 { - let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig); + let pos = lightningbeam_core::beat_time::time_to_measure(time, tempo_map, time_sig); painter.text( pos2(x + 2.0, grid_rect.min.y + 2.0), Align2::LEFT_TOP, @@ -708,8 +708,8 @@ impl PianoRollPane { ) -> f32 { let mut peak = 0.0f32; for ev in events { - if ev.timestamp > note_end + 0.01 { break; } - if ev.timestamp >= note_start - 0.01 + if ev.timestamp.beats_to_f64() > note_end + 0.01 { break; } + if ev.timestamp.beats_to_f64() >= note_start - 0.01 && (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == channel { @@ -770,7 +770,7 @@ impl PianoRollPane { }; let timestamp = note_start + t * note_duration; let (lsb, msb) = encode_bend(normalized); - events.push(MidiEvent::new(timestamp, 0xE0 | channel, lsb, msb)); + events.push(MidiEvent::new(daw_backend::Beats(timestamp), 0xE0 | channel, lsb, msb)); } events } @@ -794,11 +794,11 @@ impl PianoRollPane { let ch = ev.status & 0x0F; let msg = ev.status & 0xF0; if msg == 0x90 && ev.data2 > 0 { - active.insert((ev.data1, ch), ev.timestamp); + active.insert((ev.data1, ch), ev.timestamp.beats_to_f64()); } else if msg == 0x80 || (msg == 0x90 && ev.data2 == 0) { if let Some(start) = active.remove(&(ev.data1, ch)) { // Overlaps target range and is NOT the note we're assigning - if start < note_end && ev.timestamp > note_start + if start < note_end && ev.timestamp.beats_to_f64() > note_start && !(ev.data1 == note_pitch && ch == current_channel) { used[ch as usize] = true; @@ -828,9 +828,9 @@ impl PianoRollPane { fn find_cc1_for_note(events: &[daw_backend::audio::midi::MidiEvent], note_start: f64, note_end: f64, channel: u8) -> u8 { let mut cc1 = 0u8; for ev in events { - if ev.timestamp > note_end { break; } + if ev.timestamp.beats_to_f64() > note_end { break; } if (ev.status & 0xF0) == 0xB0 && (ev.status & 0x0F) == channel && ev.data1 == 1 { - if ev.timestamp <= note_start { + if ev.timestamp.beats_to_f64() <= note_start { cc1 = ev.data2; } } @@ -950,7 +950,7 @@ impl PianoRollPane { let ts = note.start_time + t as f64 * note.duration; let mut existing_norm = 0.0f32; for ev in events { - if ev.timestamp > ts { break; } + if ev.timestamp.beats_to_f64() > ts { break; } if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == drag_ch { let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16); existing_norm = (raw - 8192) as f32 / 8192.0; @@ -991,7 +991,7 @@ impl PianoRollPane { // Find last pitch bend event at or before ts let mut bend_norm = 0.0f32; for ev in events { - if ev.timestamp > ts { break; } + if ev.timestamp.beats_to_f64() > ts { break; } if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == note.channel { let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16); bend_norm = (raw - 8192) as f32 / 8192.0; @@ -1037,9 +1037,9 @@ impl PianoRollPane { } fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect, - bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) { + tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) { // Collect visible time grid positions - let interval = self.ruler_interval(bpm, time_sig); + let interval = self.ruler_interval(tempo_map, time_sig); let start = (self.viewport_start_time / interval).floor() as i64; let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64; let end = (end_time / interval).ceil() as i64; @@ -1374,10 +1374,10 @@ impl PianoRollPane { if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) { let clip_start = selected_clip.1; let trim_start = selected_clip.2; - let bpm = shared.action_executor.document().bpm; + let tempo_map = shared.action_executor.document().tempo_map(); let clip_local_time = snap_to_value( (time - clip_start).max(0.0) + trim_start, - self.snap_value, bpm, + self.snap_value, tempo_map, ); self.creating_note = Some(TempNote { note, @@ -1395,8 +1395,8 @@ impl PianoRollPane { self.selection_rect = Some((pos, pos)); self.drag_mode = Some(DragMode::SelectRect); - let bpm = shared.action_executor.document().bpm; - let seek_time = snap_to_value(time.max(0.0), self.snap_value, bpm); + let tempo_map = shared.action_executor.document().tempo_map(); + let seek_time = snap_to_value(time.max(0.0), self.snap_value, tempo_map); *shared.playback_time = seek_time; if let Some(ctrl) = shared.audio_controller.as_ref() { if let Ok(mut c) = ctrl.lock() { c.seek(seek_time); } @@ -1538,7 +1538,7 @@ impl PianoRollPane { let ts = note_start + t * note_duration; let mut bend = 0.0f32; for ev in &new_events { - if ev.timestamp > ts { break; } + if ev.timestamp.beats_to_f64() > ts { break; } if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel { let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16); bend = (raw - 8192) as f32 / 8192.0; @@ -1550,7 +1550,8 @@ impl PianoRollPane { // Remove old bend events in range before writing combined new_events.retain(|ev| { let is_bend = (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel; - let in_range = ev.timestamp >= note_start - 0.001 && ev.timestamp <= note_start + note_duration + 0.01; + let ts = ev.timestamp.beats_to_f64(); + let in_range = ts >= note_start - 0.001 && ts <= note_start + note_duration + 0.01; !(is_bend && in_range) }); @@ -1568,20 +1569,15 @@ impl PianoRollPane { let combined = (existing_norm[i] + zone_norm).clamp(-1.0, 1.0); let (lsb, msb) = encode_bend(combined); let ts = note_start + i as f64 / num_steps as f64 * note_duration; - new_events.push(daw_backend::audio::midi::MidiEvent::new(ts, 0xE0 | target_channel, lsb, msb)); + new_events.push(daw_backend::audio::midi::MidiEvent::new(daw_backend::Beats(ts), 0xE0 | target_channel, lsb, msb)); } // For End zone: reset just after note ends so it doesn't bleed into next note if zone == PitchBendZone::End { let (lsb, msb) = encode_bend(0.0); - new_events.push(daw_backend::audio::midi::MidiEvent::new(note_start + note_duration + 0.005, 0xE0 | target_channel, lsb, msb)); + new_events.push(daw_backend::audio::midi::MidiEvent::new(daw_backend::Beats(note_start + note_duration + 0.005), 0xE0 | target_channel, lsb, msb)); } new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal)); - { - let doc = shared.action_executor.document(); - let bpm = doc.bpm; let fps = doc.framerate; - for ev in &mut new_events { ev.sync_from_seconds(bpm, fps); } - } self.push_events_action("Set pitch bend", clip_id, old_events, new_events.clone(), shared); shared.midi_event_cache.insert(clip_id, new_events); } @@ -1737,8 +1733,8 @@ impl PianoRollPane { fn update_cache_from_resolved(clip_id: u32, resolved: &[ResolvedNote], shared: &mut SharedPaneState) { let mut events: Vec = Vec::with_capacity(resolved.len() * 2); for n in resolved { - events.push(daw_backend::audio::midi::MidiEvent::note_on(n.start_time, 0, n.note, n.velocity)); - events.push(daw_backend::audio::midi::MidiEvent::note_off(n.start_time + n.duration, 0, n.note, 0)); + events.push(daw_backend::audio::midi::MidiEvent::note_on(daw_backend::Beats(n.start_time), 0, n.note, n.velocity)); + events.push(daw_backend::audio::midi::MidiEvent::note_off(daw_backend::Beats(n.start_time + n.duration), 0, n.note, 0)); } events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); shared.midi_event_cache.insert(clip_id, events); @@ -1787,12 +1783,12 @@ impl PianoRollPane { let resolved = Self::resolve_notes(events); let old_notes = Self::notes_to_backend_format(&resolved); - let bpm = shared.action_executor.document().bpm; + let tempo_map = shared.action_executor.document().tempo_map(); let mut new_resolved = resolved.clone(); for &idx in &self.selected_note_indices { if idx < new_resolved.len() { let raw_time = (new_resolved[idx].start_time + dt).max(0.0); - new_resolved[idx].start_time = snap_to_value(raw_time, self.snap_value, bpm); + new_resolved[idx].start_time = snap_to_value(raw_time, self.snap_value, tempo_map); new_resolved[idx].note = (new_resolved[idx].note as i32 + dn).clamp(0, 127) as u8; } } @@ -1968,7 +1964,7 @@ impl PianoRollPane { shared.pending_actions.push(Box::new(action)); } - fn quantize_selected_notes(&mut self, clip_id: u32, bpm: f64, shared: &mut SharedPaneState) { + fn quantize_selected_notes(&mut self, clip_id: u32, tempo_map: &daw_backend::TempoMap, shared: &mut SharedPaneState) { let events = match shared.midi_event_cache.get(&clip_id) { Some(e) => e, None => return }; let resolved = Self::resolve_notes(events); let old_notes = Self::notes_to_backend_format(&resolved); @@ -1976,7 +1972,7 @@ impl PianoRollPane { for &idx in &self.selected_note_indices { if idx < new_resolved.len() { new_resolved[idx].start_time = - snap_to_value(new_resolved[idx].start_time, self.snap_value, bpm).max(0.0); + snap_to_value(new_resolved[idx].start_time, self.snap_value, tempo_map).max(0.0); } } let new_notes = Self::notes_to_backend_format(&new_resolved); @@ -2075,11 +2071,11 @@ impl PianoRollPane { // Dot grid background (visible where the spectrogram doesn't draw) let grid_painter = ui.painter_at(view_rect); { - let (dot_bpm, dot_ts) = { + let (dot_tempo_map, dot_ts) = { let doc = shared.action_executor.document(); - (doc.bpm, doc.time_signature.clone()) + (doc.tempo_map().clone(), doc.time_signature.clone()) }; - self.render_dot_grid(&grid_painter, view_rect, dot_bpm, &dot_ts); + self.render_dot_grid(&grid_painter, view_rect, &dot_tempo_map, &dot_ts); } // Find audio pool index for the active layer's clips @@ -2323,7 +2319,7 @@ impl PaneRenderer for PianoRollPane { for ev in cached.iter_mut() { if ev.is_note_on() && ev.data1 == sn.note && (ev.status & 0x0F) == sn.channel - && (ev.timestamp - sn.start_time).abs() < 1e-6 + && (ev.timestamp.beats_to_f64() - sn.start_time).abs() < 1e-6 { ev.data2 = new_vel; } @@ -2360,21 +2356,16 @@ impl PaneRenderer for PianoRollPane { let is_cc1 = (ev.status & 0xF0) == 0xB0 && (ev.status & 0x0F) == sn.channel && ev.data1 == 1; - let at_start = (ev.timestamp - sn.start_time).abs() < 0.001; + let at_start = (ev.timestamp.beats_to_f64() - sn.start_time).abs() < 0.001; !(is_cc1 && at_start) }); if new_cc1 > 0 { new_events.push(daw_backend::audio::midi::MidiEvent::new( - sn.start_time, 0xB0 | sn.channel, 1, new_cc1, + daw_backend::Beats(sn.start_time), 0xB0 | sn.channel, 1, new_cc1, )); } } new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal)); - { - let doc = shared.action_executor.document(); - let bpm = doc.bpm; let fps = doc.framerate; - for ev in &mut new_events { ev.sync_from_seconds(bpm, fps); } - } self.push_events_action("Set modulation", clip_id, old_events, new_events.clone(), shared); shared.midi_event_cache.insert(clip_id, new_events); } @@ -2416,7 +2407,7 @@ impl PaneRenderer for PianoRollPane { // Snap-to dropdown — only in Measures mode let doc = shared.action_executor.document(); let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures; - let bpm = doc.bpm; + let tempo_map = doc.tempo_map(); drop(doc); if is_measures { @@ -2429,7 +2420,7 @@ impl PaneRenderer for PianoRollPane { let sel: Vec<&ResolvedNote> = self.selected_note_indices.iter() .filter_map(|&i| resolved.get(i)) .collect(); - self.snap_value = detect_snap(&sel, bpm); + self.snap_value = detect_snap(&sel, tempo_map); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 1531bd2..e1f11b9 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1438,7 +1438,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let effect_inst = lightningbeam_core::effect::EffectInstance::new( effect_def, effect_instance.timeline_start, - effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION), + effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, self.ctx.document.tempo_map()), ); // Acquire temp buffer for effect output (HDR format) @@ -1793,7 +1793,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { // Skip clip instances not active at current time let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0); - let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur); + let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur, self.ctx.document.tempo_map()); if self.ctx.playback_time < clip_instance.timeline_start || self.ctx.playback_time >= instance_end { continue; } @@ -2214,7 +2214,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Find clip instance visible at playback time let visible_clip = video_layer.clip_instances.iter().find(|inst| { let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(0.0); - let effective_duration = inst.effective_duration(clip_duration); + let effective_duration = inst.effective_duration(clip_duration, self.ctx.document.tempo_map()); playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration }); @@ -10287,7 +10287,7 @@ impl StagePane { if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) { video_layer.clip_instances.iter().find(|inst| { let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(0.0); - let effective_duration = inst.effective_duration(clip_duration); + let effective_duration = inst.effective_duration(clip_duration, document.tempo_map()); playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration }).map(|inst| inst.id) } else { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 0aab067..93ae067 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -74,10 +74,11 @@ fn compute_clip_stacking( return vec![(0, 1); clip_instances.len()]; } + let tempo_map = document.tempo_map(); let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| { let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0); let start = ci.effective_start(); - let end = start + ci.total_duration(clip_dur); + let end = start + ci.total_duration(clip_dur, tempo_map); (start, end) }).collect(); @@ -510,20 +511,14 @@ fn build_audio_clip_cache( .unwrap_or_else(|| audio_backend_uuid(ac.id)); let mut ci = ClipInstance::new(clip_id); ci.id = instance_id; - ci.timeline_start = ac.external_start; - ci.timeline_start_beats = ac.external_start_beats; - ci.timeline_start_frames = ac.external_start_frames; - ci.trim_start = ac.internal_start; - ci.trim_start_beats = ac.internal_start_beats; - ci.trim_start_frames = ac.internal_start_frames; - ci.trim_end = Some(ac.internal_end); - ci.trim_end_beats = Some(ac.internal_end_beats); - ci.trim_end_frames = Some(ac.internal_end_frames); - let internal_dur = ac.internal_end - ac.internal_start; - if (ac.external_duration - internal_dur).abs() > 1e-9 { - ci.timeline_duration = Some(ac.external_duration); - ci.timeline_duration_beats = Some(ac.external_duration_beats); - ci.timeline_duration_frames = Some(ac.external_duration_frames); + ci.timeline_start = ac.external_start.beats_to_f64(); + ci.trim_start = ac.internal_start.seconds_to_f64(); + ci.trim_end = Some(ac.internal_end.seconds_to_f64()); + let internal_dur_secs = (ac.internal_end - ac.internal_start).seconds_to_f64(); + let tempo_map = document.tempo_map(); + let external_dur_secs = tempo_map.transform(ac.external_duration.beats_to_f64()); + if (external_dur_secs - internal_dur_secs).abs() > 1e-9 { + ci.timeline_duration = Some(ac.external_duration.beats_to_f64()); } ci.gain = ac.gain; instances.push(ci); @@ -540,21 +535,12 @@ fn build_audio_clip_cache( .unwrap_or_else(|| midi_backend_uuid(mc.id)); let mut ci = ClipInstance::new(clip_id); ci.id = instance_id; - ci.timeline_start = mc.external_start; - ci.timeline_start_beats = mc.external_start_beats; - ci.timeline_start_frames = mc.external_start_frames; - ci.trim_start = mc.internal_start; - ci.trim_start_beats = mc.internal_start_beats; - ci.trim_start_frames = mc.internal_start_frames; - ci.trim_end = Some(mc.internal_end); - ci.trim_end_beats = Some(mc.internal_end_beats); - ci.trim_end_frames = Some(mc.internal_end_frames); - let internal_dur = mc.internal_end - mc.internal_start; - if (mc.external_duration - internal_dur).abs() > 1e-9 { - ci.timeline_duration = Some(mc.external_duration); - ci.timeline_duration_beats = Some(mc.external_duration_beats); - ci.timeline_duration_frames = Some(mc.external_duration_frames); - } + ci.timeline_start = mc.external_start.beats_to_f64(); + ci.trim_start = mc.internal_start.beats_to_f64(); + ci.trim_end = Some(mc.internal_end.beats_to_f64()); + // Always set timeline_duration for MIDI clips: duration is in beats, so we + // must bypass the content_window_secs * bpm/60 formula (which expects seconds). + ci.timeline_duration = Some(mc.external_duration.beats_to_f64()); instances.push(ci); } } @@ -955,12 +941,19 @@ impl TimelinePane { && *shared.metronome_enabled && self.time_display_format == TimelineMode::Measures { - let (bpm, beats_per_measure) = { + let (tempo_map, beats_per_measure) = { let doc = shared.action_executor.document(); - (doc.bpm, doc.time_signature.numerator as f64) + (doc.tempo_map().clone(), doc.time_signature.numerator as f64) + }; + // Convert start_time to beats, subtract one measure, convert back to seconds + let start_beat = tempo_map.inverse_transform(start_time); + let count_in_start_beat = start_beat - beats_per_measure; + let seek_to = if count_in_start_beat >= 0.0 { + tempo_map.transform(count_in_start_beat) + } else { + // Before beat 0: extrapolate using initial BPM + tempo_map.transform(0.0) - count_in_start_beat.abs() * (60.0 / tempo_map.global_bpm()) }; - let count_in_duration = beats_per_measure * (60.0 / bpm); - let seek_to = start_time - count_in_duration; // may be negative if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); @@ -1025,14 +1018,11 @@ impl TimelinePane { // Create document clip + clip instance immediately let doc_clip = AudioClip::new_midi("Recording...", - *shared.recording_clips.get(&layer_id).unwrap_or(&0), 0.0); + *shared.recording_clips.get(&layer_id).unwrap_or(&0), daw_backend::Beats::ZERO); let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip); - let bpm = shared.action_executor.document().bpm; - let fps = shared.action_executor.document().framerate; let mut clip_instance = ClipInstance::new(doc_clip_id) .with_timeline_start(start_time); - clip_instance.sync_from_seconds(bpm, fps); if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { @@ -1203,8 +1193,9 @@ impl TimelinePane { for (ci_idx, clip_instance) in clip_instances.iter().enumerate() { let clip_duration = effective_clip_duration(document, layer, clip_instance)?; + let tempo_map = document.tempo_map(); let instance_start = clip_instance.effective_start(); - let instance_duration = clip_instance.total_duration(clip_duration); + let instance_duration = clip_instance.total_duration(clip_duration, tempo_map); let instance_end = instance_start + instance_duration; let start_x = self.time_to_x(instance_start); @@ -1284,12 +1275,13 @@ impl TimelinePane { let child_clips = group.all_child_clip_instances(); let mut spans: Vec<(f64, f64, Vec)> = Vec::new(); // (start, end, clip_ids) + let tempo_map = document.tempo_map(); for (_child_layer_id, ci) in &child_clips { let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { ci.trim_end.unwrap_or(1.0) - ci.trim_start }); let start = ci.effective_start(); - let end = start + ci.total_duration(clip_dur); + let end = start + ci.total_duration(clip_dur, tempo_map); spans.push((start, end, vec![ci.id])); } @@ -1367,57 +1359,23 @@ impl TimelinePane { ((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32 } - /// Effective display start for a clip instance. + /// Effective display start for a clip instance, in seconds. /// - /// In Measures mode, uses `timeline_start_beats` as the canonical position so clips stay - /// anchored to their beat position during live BPM drag preview. Falls back to seconds - /// in other modes or when beat data is unavailable. - fn instance_display_start(&self, ci: &lightningbeam_core::clip::ClipInstance, bpm: f64) -> f64 { - if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures - && (ci.timeline_start_beats.abs() > 1e-12 || ci.timeline_start == 0.0) - { - ci.timeline_start_beats * 60.0 / bpm - ci.loop_before.unwrap_or(0.0) - } else { - ci.effective_start() - } + /// `timeline_start` is in beats; converts to seconds using the current (preview) BPM so + /// clips stay anchored to their beat position during live BPM drag. + fn instance_display_start(&self, ci: &lightningbeam_core::clip::ClipInstance, tempo_map: &daw_backend::TempoMap) -> f64 { + tempo_map.transform(ci.effective_start()) } - /// In Measures mode, uses beats fields for the clip's on-timeline duration so the width - /// stays correct during live BPM drag preview. Falls back to seconds in other modes. - fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, bpm: f64) -> f64 { - use lightningbeam_core::document::TimelineMode; - if self.time_display_format == TimelineMode::Measures { - // Looping/extended clip: explicit timeline_duration_beats - if let Some(dur_beats) = ci.timeline_duration_beats { - if dur_beats.abs() > 1e-12 { - return dur_beats * 60.0 / bpm; - } - } - // Non-looping: derive from trim range in beats - let ts_beats = ci.trim_start_beats; - if let Some(te_beats) = ci.trim_end_beats { - if te_beats.abs() > 1e-12 || ts_beats.abs() > 1e-12 { - return (te_beats - ts_beats).max(0.0) * 60.0 / bpm; - } - } - } - ci.total_duration(clip_dur_secs) + /// Effective on-timeline duration for a clip instance, in seconds. + /// + /// `total_duration` is in beats; converts to seconds using the current (preview) BPM. + fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, tempo_map: &daw_backend::TempoMap) -> f64 { + tempo_map.transform(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) - tempo_map.transform(ci.effective_start()) } - /// In Measures mode, returns the clip content start (trim_start) and duration in - /// beat-derived display seconds, for use when rendering note overlays. - fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, bpm: f64) -> (f64, f64) { - use lightningbeam_core::document::TimelineMode; - if self.time_display_format == TimelineMode::Measures { - let ts_beats = ci.trim_start_beats; - if let Some(te_beats) = ci.trim_end_beats { - if te_beats.abs() > 1e-12 || ts_beats.abs() > 1e-12 { - let start = ts_beats * 60.0 / bpm; - let dur = (te_beats - ts_beats).max(0.0) * 60.0 / bpm; - return (start, dur); - } - } - } + /// Returns the clip content start (trim_start) and duration in display seconds. + fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, _bpm: f64) -> (f64, f64) { let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); (ci.trim_start, (trim_end - ci.trim_start).max(0.0)) } @@ -1433,7 +1391,7 @@ impl TimelinePane { /// - Seconds mode: no snapping fn quantize_grid_size( &self, - bpm: f64, + tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64, ) -> Option { @@ -1441,8 +1399,8 @@ impl TimelinePane { TimelineMode::Frames => Some(1.0 / framerate), TimelineMode::Measures => { use lightningbeam_core::beat_time::{beat_duration, measure_duration}; - let beat = beat_duration(bpm); - let measure = measure_duration(bpm, time_sig); + let beat = beat_duration(0.0, tempo_map); + let measure = measure_duration(0.0, tempo_map, time_sig); let pps = self.pixels_per_second as f64; // Very zoomed in: 16th note > 40px → no snap if pps * beat / 4.0 > 40.0 { return None; } @@ -1467,11 +1425,11 @@ impl TimelinePane { fn snap_to_grid( &self, t: f64, - bpm: f64, + tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64, ) -> f64 { - match self.quantize_grid_size(bpm, time_sig, framerate) { + match self.quantize_grid_size(tempo_map, time_sig, framerate) { Some(grid) => (t / grid).round() * grid, None => t, } @@ -1481,11 +1439,11 @@ impl TimelinePane { /// Snaps the anchor clip's resulting position to the grid; all selected clips use the same offset. fn snapped_move_offset( &self, - bpm: f64, + tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64, ) -> f64 { - match self.quantize_grid_size(bpm, time_sig, framerate) { + match self.quantize_grid_size(tempo_map, time_sig, framerate) { Some(grid) => { let snapped = ((self.drag_anchor_start + self.drag_offset) / grid).round() * grid; snapped - self.drag_anchor_start @@ -1524,7 +1482,7 @@ impl TimelinePane { /// Render the time ruler at the top fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme, - bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64) { + tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64) { let painter = ui.painter(); // Background @@ -1570,8 +1528,8 @@ impl TimelinePane { } } TimelineMode::Measures => { - let beats_per_second = bpm / 60.0; - let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm); + 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; @@ -1724,15 +1682,10 @@ impl TimelinePane { let note_style = theme.style(".timeline-midi-note", ctx); let note_color = note_style.background_color().unwrap_or(egui::Color32::BLACK); - // In Measures mode during BPM drag, derive display time from beats so notes - // stay anchored to their beat positions. + // `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)); let event_display_time = |ev: &daw_backend::audio::midi::MidiEvent| -> f64 { - if let Some(bpm) = display_bpm { - if ev.timestamp_beats.abs() > 1e-12 || ev.timestamp == 0.0 { - return ev.timestamp_beats * 60.0 / bpm; - } - } - ev.timestamp + tempo_map.transform(ev.timestamp.beats_to_f64()) }; // Build a map of active notes (note_number -> note_on_timestamp) @@ -2782,7 +2735,7 @@ impl TimelinePane { } } TimelineMode::Measures => { - let beats_per_second = document.bpm / 60.0; + let beats_per_second = document.tempo_map().bpm_at(daw_backend::Beats(0.0)) / 60.0; 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; @@ -2860,10 +2813,10 @@ impl TimelinePane { ci.trim_end.unwrap_or(1.0) - ci.trim_start }); let mut start = ci.effective_start(); - let dur = ci.total_duration(clip_dur); + let dur = ci.total_duration(clip_dur, document.tempo_map()); // Apply drag offset for selected clips during move if is_move_drag && selection.contains_clip_instance(&ci.id) { - start = (start + self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate)).max(0.0); + start = (start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0); } ranges.push((start, start + dur)); } @@ -2933,9 +2886,9 @@ impl TimelinePane { .unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start); let mut ci_start = ci.effective_start(); if is_move_drag && selection.contains_clip_instance(&ci.id) { - ci_start = (ci_start + self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate)).max(0.0); + ci_start = (ci_start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0); } - let ci_duration = ci.total_duration(clip_dur); + let ci_duration = ci.total_duration(clip_dur, document.tempo_map()); let ci_end = ci_start + ci_duration; let sx = self.time_to_x(ci_start); @@ -3052,9 +3005,9 @@ impl TimelinePane { let clip_dur = audio_clip.duration; let mut ci_start = ci.effective_start(); if is_move_drag && selection.contains_clip_instance(&ci.id) { - ci_start = (ci_start + self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate)).max(0.0); + ci_start = (ci_start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0); } - let ci_duration = ci.total_duration(clip_dur); + let ci_duration = ci.total_duration(clip_dur, document.tempo_map()); let ci_screen_start = rect.min.x + self.time_to_x(ci_start); let ci_screen_end = ci_screen_start + (ci_duration * self.pixels_per_second as f64) as f32; @@ -3150,11 +3103,11 @@ impl TimelinePane { .filter(|ci| selection.contains_clip_instance(&ci.id)) .filter_map(|ci| { let dur = document.get_clip_duration(&ci.clip_id)?; - Some((ci.id, ci.effective_start(), ci.total_duration(dur))) + Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map()))) }) .collect(); if !group.is_empty() { - Some(document.clamp_group_move_offset(&layer.id(), &group, self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate))) + Some(document.clamp_group_move_offset(&layer.id(), &group, self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate))) } else { None } @@ -3167,7 +3120,7 @@ impl TimelinePane { let preview_ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| { let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0); let mut start = ci.effective_start(); - let mut duration = ci.total_duration(clip_dur); + let mut duration = ci.total_duration(clip_dur, document.tempo_map()); let is_selected = selection.contains_clip_instance(&ci.id); let is_linked = if self.clip_drag_state.is_some() { @@ -3187,7 +3140,7 @@ impl TimelinePane { } } ClipDragType::TrimLeft => { - let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, document.bpm, &document.time_signature, document.framerate).max(0.0).min(clip_dur); + let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate).max(0.0).min(clip_dur); let offset = new_trim - ci.trim_start; start = (ci.timeline_start + offset).max(0.0); duration = (clip_dur - new_trim).max(0.0); @@ -3197,7 +3150,7 @@ impl TimelinePane { } ClipDragType::TrimRight => { let old_trim_end = ci.trim_end.unwrap_or(clip_dur); - let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.bpm, &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur); + let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur); duration = (new_trim_end - ci.trim_start).max(0.0); } ClipDragType::LoopExtendRight => { @@ -3205,7 +3158,7 @@ impl TimelinePane { let content_window = (trim_end - ci.trim_start).max(0.0); let current_right = ci.timeline_duration.unwrap_or(content_window); let right_edge = ci.timeline_start + current_right + self.drag_offset; - let snapped_edge = self.snap_to_grid(right_edge, document.bpm, &document.time_signature, document.framerate); + let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate); let new_right = (snapped_edge - ci.timeline_start).max(content_window); let loop_before = ci.loop_before.unwrap_or(0.0); duration = loop_before + new_right; @@ -3217,7 +3170,7 @@ impl TimelinePane { let desired = (current_loop_before - self.drag_offset).max(0.0); let snapped = (desired / content_window).round() * content_window; start = ci.timeline_start - snapped; - duration = snapped + ci.effective_duration(clip_dur); + duration = snapped + ci.effective_duration(clip_dur, document.tempo_map()); } } } @@ -3236,13 +3189,13 @@ impl TimelinePane { if let Some(clip_duration) = clip_duration { // Calculate effective duration accounting for trimming. // In Measures mode, uses beats fields so width tracks BPM during live drag. - let mut instance_duration = self.instance_display_duration(clip_instance, clip_duration, document.bpm); + let mut instance_duration = self.instance_display_duration(clip_instance, clip_duration, document.tempo_map()); // Instance positioned on the layer's timeline using timeline_start. // In Measures mode, uses timeline_start_beats so clips stay at their beat // position during live BPM drag preview. let _layer_data = layer.layer(); - let mut instance_start = self.instance_display_start(clip_instance, document.bpm); + let mut instance_start = self.instance_display_start(clip_instance, document.tempo_map()); // Apply drag offset preview for selected clips with snapping let is_selected = selection.contains_clip_instance(&clip_instance.id); @@ -3267,7 +3220,7 @@ impl TimelinePane { // Track preview trim values for note/waveform rendering. // In Measures mode, derive from beats so they track BPM during live drag. - let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm); + let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm()); let mut preview_trim_start = base_trim_start; let mut preview_clip_duration = base_clip_duration; @@ -3282,7 +3235,7 @@ impl TimelinePane { } ClipDragType::TrimLeft => { // Trim left: calculate new trim_start with snap to adjacent clips - let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.bpm, &document.time_signature, document.framerate) + let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate) .max(0.0) .min(clip_duration); @@ -3322,7 +3275,7 @@ impl TimelinePane { ClipDragType::TrimRight => { // Trim right: extend or reduce duration with snap to adjacent clips let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.bpm, &document.time_signature, document.framerate) + let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate) .max(clip_instance.trim_start) .min(clip_duration); @@ -3356,7 +3309,7 @@ impl TimelinePane { let content_window = (trim_end - clip_instance.trim_start).max(0.0); let current_right = clip_instance.timeline_duration.unwrap_or(content_window); let right_edge = clip_instance.timeline_start + current_right + self.drag_offset; - let snapped_edge = self.snap_to_grid(right_edge, document.bpm, &document.time_signature, document.framerate); + let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate); let desired_right = (snapped_edge - clip_instance.timeline_start).max(content_window); let new_right = if desired_right > current_right { @@ -3404,7 +3357,7 @@ impl TimelinePane { }; // Recompute instance_start and instance_duration - let right_duration = clip_instance.effective_duration(clip_duration); + let right_duration = clip_instance.effective_duration(clip_duration, document.tempo_map()); instance_start = clip_instance.timeline_start - new_loop_before; instance_duration = new_loop_before + right_duration; content_origin = clip_instance.timeline_start; @@ -3525,7 +3478,7 @@ impl TimelinePane { if iter_duration <= 0.0 { continue; } let note_bpm = if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures { - Some(document.bpm) + Some(document.bpm()) } else { None }; @@ -3547,7 +3500,7 @@ impl TimelinePane { } } else { let note_bpm = if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures { - Some(document.bpm) + Some(document.bpm()) } else { None }; @@ -4066,7 +4019,7 @@ impl TimelinePane { let clip_duration = effective_clip_duration(document, layer, clip_instance); if let Some(clip_duration) = clip_duration { - let instance_duration = clip_instance.total_duration(clip_duration); + let instance_duration = clip_instance.total_duration(clip_duration, document.tempo_map()); let instance_start = clip_instance.effective_start(); let instance_end = instance_start + instance_duration; @@ -4419,7 +4372,7 @@ impl TimelinePane { HashMap::new(); // Compute snapped offset once for all selected clips (preserves relative spacing) - let move_offset = self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate); + let move_offset = self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate); // Iterate through all layers (including group children) to find selected clip instances for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) { @@ -4481,8 +4434,7 @@ impl TimelinePane { // New trim_start is snapped then clamped to valid range let desired_trim_start = self.snap_to_grid( - old_trim_start + self.drag_offset, - document.bpm, &document.time_signature, document.framerate, + old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, ).max(0.0).min(clip_duration); // Apply overlap prevention when extending left @@ -4525,11 +4477,10 @@ impl TimelinePane { // Calculate new trim_end based on current duration let current_duration = - clip_instance.effective_duration(clip_duration); + clip_instance.effective_duration(clip_duration, document.tempo_map()); let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration); let desired_trim_end = self.snap_to_grid( - old_trim_end_val + self.drag_offset, - document.bpm, &document.time_signature, document.framerate, + old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, ).max(clip_instance.trim_start).min(clip_duration); // Apply overlap prevention when extending right @@ -4607,7 +4558,7 @@ impl TimelinePane { let content_window = (trim_end - clip_instance.trim_start).max(0.0); let current_right = clip_instance.timeline_duration.unwrap_or(content_window); let right_edge = clip_instance.timeline_start + current_right + self.drag_offset; - let snapped_edge = self.snap_to_grid(right_edge, document.bpm, &document.time_signature, document.framerate); + let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate); let desired_right = snapped_edge - clip_instance.timeline_start; let new_right = if desired_right > current_right { @@ -4785,7 +4736,7 @@ impl TimelinePane { if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) { if let Some(pos) = response.interact_pointer_pos() { let x = (pos.x - content_rect.min.x).max(0.0); - let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.bpm, &document.time_signature, document.framerate); + let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate); *playback_time = new_time; self.is_scrubbing = true; // Seek immediately so it works while playing @@ -4799,7 +4750,7 @@ impl TimelinePane { else if self.is_scrubbing && response.dragged() && !self.is_panning { if let Some(pos) = response.interact_pointer_pos() { let x = (pos.x - content_rect.min.x).max(0.0); - let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.bpm, &document.time_signature, document.framerate); + let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate); *playback_time = new_time; if let Some(controller_arc) = audio_controller { let mut controller = controller_arc.lock().unwrap(); @@ -5092,9 +5043,9 @@ impl PaneRenderer for TimelinePane { // Time display (format-dependent) { - let (bpm, time_sig_num, time_sig_den, framerate) = { + let (tempo_map, bpm, time_sig_num, time_sig_den, framerate) = { let doc = shared.action_executor.document(); - (doc.bpm, doc.time_signature.numerator, doc.time_signature.denominator, doc.framerate) + (doc.tempo_map().clone(), doc.bpm(), doc.time_signature.numerator, doc.time_signature.denominator, doc.framerate) }; match self.time_display_format { @@ -5104,7 +5055,7 @@ impl PaneRenderer for TimelinePane { TimelineMode::Measures => { let time_sig = lightningbeam_core::document::TimeSignature { numerator: time_sig_num, denominator: time_sig_den }; let pos = lightningbeam_core::beat_time::time_to_measure( - *shared.playback_time, bpm, &time_sig, + *shared.playback_time, &tempo_map, &time_sig, ); ui.colored_label(text_color, format!( "BAR: {}.{} | BPM: {:.0} | {}/{}", @@ -5204,7 +5155,7 @@ impl PaneRenderer for TimelinePane { self.bpm_drag_start = Some(bpm); } // Live preview: update document directly so grid reflows immediately - shared.action_executor.document_mut().bpm = bpm_val; + 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)); @@ -5214,35 +5165,17 @@ impl PaneRenderer for TimelinePane { // 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; + 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 — keep it. - // ChangeBpmAction::new uses the passed old/new params, not document.bpm, - // so we don't need to revert (which would cause a one-frame flash). + // 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( - start_bpm, new_bpm, shared.action_executor.document(), - shared.midi_event_cache, ); - // Immediately update midi_event_cache for rendering - for (clip_id, events) in action.new_midi_events() { - shared.midi_event_cache.insert(clip_id, events.clone()); - } - // Optimistically rescale automation keyframe times in the cache. - // The backend rescales asynchronously via ApplyBpmChange; this keeps - // the cache consistent without waiting for the engine to round-trip. - let scale = start_bpm / new_bpm; - for lanes in self.automation_cache.values_mut() { - for lane in lanes.iter_mut() { - for kf in lane.keyframes.iter_mut() { - kf.time *= scale; - } - } - } shared.pending_actions.push(Box::new(action)); } } @@ -5271,7 +5204,7 @@ impl PaneRenderer for TimelinePane { doc.time_signature.denominator = *den; if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); - controller.set_tempo(doc.bpm as f32, (*num, *den)); + controller.set_tempo(doc.bpm() as f32, (*num, *den)); } } } @@ -5331,7 +5264,7 @@ impl PaneRenderer for TimelinePane { let clip_duration = effective_clip_duration(document, layer, clip_instance); if let Some(clip_duration) = clip_duration { - let instance_duration = clip_instance.effective_duration(clip_duration); + let instance_duration = clip_instance.effective_duration(clip_duration, document.tempo_map()); let instance_end = clip_instance.timeline_start + instance_duration; max_endpoint = max_endpoint.max(instance_end); } @@ -5390,7 +5323,7 @@ impl PaneRenderer for TimelinePane { // Render time ruler (clip to ruler rect) ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); - self.render_ruler(ui, ruler_rect, shared.theme, document.bpm, &document.time_signature, document.framerate); + self.render_ruler(ui, ruler_rect, shared.theme, document.tempo_map(), &document.time_signature, document.framerate); // Render layer rows with clipping ui.set_clip_rect(content_rect.intersect(original_clip_rect)); @@ -5486,8 +5419,8 @@ impl PaneRenderer for TimelinePane { // Invalidate automation cache when BPM changes (but not during a live drag — // during drag the backend hasn't rescaled yet so re-querying would give stale data). let bpm_committed = self.bpm_drag_start.is_none(); - if bpm_committed && (document.bpm - self.automation_cache_bpm).abs() > 1e-9 { - self.automation_cache_bpm = document.bpm; + if bpm_committed && (document.bpm() - self.automation_cache_bpm).abs() > 1e-9 { + self.automation_cache_bpm = document.bpm(); self.automation_cache.clear(); } @@ -5631,7 +5564,7 @@ impl PaneRenderer for TimelinePane { for inst in instances { if !shared.selection.contains_clip_instance(&inst.id) { continue; } if let Some(dur) = document.get_clip_duration(&inst.clip_id) { - let eff = inst.effective_duration(dur); + let eff = inst.effective_duration(dur, document.tempo_map()); let start = inst.timeline_start; let end = start + eff; let min_dist = min_split_px as f64 / self.pixels_per_second as f64; @@ -5657,7 +5590,7 @@ impl PaneRenderer for TimelinePane { .filter(|ci| shared.selection.contains_clip_instance(&ci.id)) .all(|ci| { if let Some(dur) = document.get_clip_duration(&ci.clip_id) { - let eff = ci.effective_duration(dur); + let eff = ci.effective_duration(dur, document.tempo_map()); let max_extend = document.find_max_trim_extend_right( &layer_id, &ci.id, ci.timeline_start, eff, ); @@ -5696,7 +5629,7 @@ impl PaneRenderer for TimelinePane { enabled = instances.iter().all(|ci| { let paste_start = (ci.timeline_start + offset).max(0.0); if let Some(dur) = document.get_clip_duration(&ci.clip_id) { - let eff = ci.effective_duration(dur); + let eff = ci.effective_duration(dur, document.tempo_map()); document .find_nearest_valid_position( &layer_id,