diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 74288be..1f6cba8 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -572,7 +572,7 @@ impl Engine { if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize { // Clamp to 0 during count-in pre-roll (negative playhead = before project start) - let position_seconds = self.playhead.max(0) as f64 / self.sample_rate as f64; + let position_seconds = Seconds(self.playhead.max(0) as f64 / self.sample_rate as f64); let _ = self .event_tx .push(AudioEvent::PlaybackPosition(position_seconds)); @@ -922,7 +922,7 @@ impl Engine { self.project.stop_all_notes(); } Command::Seek(seconds) => { - self.playhead = (seconds * self.sample_rate as f64) as i64; + self.playhead = (seconds.seconds_to_f64() * self.sample_rate as f64) as i64; // Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed) let clamped = self.playhead.max(0) as u64; self.playhead_atomic.store(clamped, Ordering::Relaxed); @@ -1014,12 +1014,12 @@ impl Engine { 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 = Beats(new_external_duration); + clip.external_duration = new_external_duration; } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { - instance.external_duration = Beats(new_external_duration); + instance.external_duration = new_external_duration; } } _ => {} @@ -1046,7 +1046,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 = Seconds(offset); + metatrack.offset = offset; } } Command::SetPitchShift(track_id, semitones) => { @@ -1056,12 +1056,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 = Seconds(trim_start.max(0.0)); + metatrack.trim_start = Seconds(trim_start.seconds_to_f64().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| Seconds(t.max(0.0))); + metatrack.trim_end = trim_end.map(|t| Seconds(t.seconds_to_f64().max(0.0))); } } Command::CreateAudioTrack(name, parent_id) => { @@ -1115,9 +1115,13 @@ impl Engine { // Send chunks via MPSC channel (will be forwarded by audio thread) if !chunks.is_empty() { println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index); - let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + let event_chunks: Vec<(u32, (Seconds, Seconds), Vec)> = chunks .into_iter() - .map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) + .map(|chunk| { + // A chunk's time_range is a wall-clock span into the audio file. + let (start, end) = chunk.time_range; + (chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks) + }) .collect(); match chunk_tx.send(AudioEvent::WaveformChunksReady { @@ -1181,12 +1185,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, Beats(duration), format!("MIDI Clip {}", clip_id)); + let clip = MidiClip::empty(clip_id, 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 instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time)); + let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time); if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { track.clip_instances.push(instance); @@ -1201,11 +1205,11 @@ impl Engine { // 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 in beats (canonical) - let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); + let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); clip.add_event(note_on); // Add note off event - let note_off_time = Beats(time_offset + duration); + let note_off_time = time_offset + duration; let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.add_event(note_off); } else { @@ -1214,9 +1218,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(Beats(time_offset), 0, note, velocity); + let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); clip.add_event(note_on); - let note_off_time = Beats(time_offset + duration); + let note_off_time = time_offset + duration; let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.add_event(note_off); } @@ -1226,7 +1230,7 @@ impl Engine { } Command::AddLoadedMidiClip(track_id, clip, start_time) => { // Add a pre-loaded MIDI clip to the track with the given start time - if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { + if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) { // instance positions are already in beats; nothing to sync } self.refresh_clip_snapshot(); @@ -1240,11 +1244,11 @@ impl Engine { // Add new events from the notes array // Timestamps are in beats (canonical) for (start_time, note, velocity, duration) in notes { - let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity); + let note_on = MidiEvent::note_on(start_time, 0, note, velocity); clip.events.push(note_on); // Add note off event - let note_off_time = Beats(start_time + duration); + let note_off_time = start_time + duration; let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.events.push(note_off); } @@ -1306,7 +1310,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(Beats(time), value, curve); + let point = crate::audio::AutomationPoint::new(time, value, curve); match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { @@ -1332,17 +1336,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(Beats(time), Beats(tolerance)); + lane.remove_point_at_time(time, tolerance); } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(lane) = track.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(Beats(time), Beats(tolerance)); + lane.remove_point_at_time(time, tolerance); } } Some(crate::audio::track::TrackNode::Group(group)) => { if let Some(lane) = group.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(Beats(time), Beats(tolerance)); + lane.remove_point_at_time(time, tolerance); } } None => {} @@ -2431,7 +2435,7 @@ impl Engine { // Downcast to AutomationInputNode using as_any_mut if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { let keyframe = AutomationKeyframe { - time: Beats(time), + time, value, interpolation, ease_out, @@ -2459,7 +2463,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(Beats(time), Beats(0.001)); // 1ms tolerance + auto_node.remove_keyframe_at_time(time, Beats(0.001)); // 1ms tolerance } else { eprintln!("Node {} is not an AutomationInputNode", node_id); } @@ -2528,9 +2532,12 @@ impl Engine { // Send chunks via MPSC channel (will be forwarded by audio thread) if !chunks.is_empty() { - let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + let event_chunks: Vec<(u32, (Seconds, Seconds), Vec)> = chunks .into_iter() - .map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) + .map(|chunk| { + let (start, end) = chunk.time_range; + (chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks) + }) .collect(); let _ = chunk_tx.send(AudioEvent::WaveformChunksReady { @@ -2694,7 +2701,7 @@ impl Engine { path: path_str, channels: metadata.channels, sample_rate: metadata.sample_rate, - duration: metadata.duration, + duration: Seconds(metadata.duration), format: metadata.format, }); @@ -2802,7 +2809,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.0, + duration: clip.duration, events: clip.events.clone(), })) } else { @@ -2834,7 +2841,7 @@ impl Engine { InterpolationType::Hold => "hold", }.to_string(); AutomationKeyframeData { - time: kf.time.0, + time: kf.time, value: kf.value, interpolation: interpolation_str, ease_out: kf.ease_out, @@ -3003,7 +3010,11 @@ impl Engine { } Query::GetPoolFileInfo(pool_index) => { match self.audio_pool.get_file_info(pool_index) { - Some(info) => QueryResponse::PoolFileInfo(Ok(info)), + // The pool measures a file's length in wall-clock seconds; name it as such at + // the boundary rather than handing a bare f64 to the UI. + Some((duration, sample_rate, channels)) => { + QueryResponse::PoolFileInfo(Ok((Seconds(duration), sample_rate, channels))) + } None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))), } } @@ -3050,7 +3061,7 @@ impl Engine { } Query::AddMidiClipSync(track_id, clip, start_time) => { // 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)) { + let result = match self.project.add_midi_clip_at(track_id, clip, start_time) { Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), }; @@ -3644,7 +3655,7 @@ impl EngineController { /// Seek to a specific position in seconds pub fn seek(&mut self, seconds: Seconds) { - let _ = self.command_tx.push(Command::Seek(seconds.seconds_to_f64())); + let _ = self.command_tx.push(Command::Seek(seconds)); } /// Set track volume (0.0 = silence, 1.0 = unity gain) @@ -3691,7 +3702,7 @@ impl EngineController { /// Extend or shrink a clip's external duration (enables looping if > internal duration) pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: Beats) { - let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration.beats_to_f64())); + let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration)); } /// Send a generic command to the audio thread @@ -3705,9 +3716,9 @@ impl EngineController { } /// Get current playhead position in seconds - pub fn get_playhead_seconds(&self) -> f64 { + pub fn get_playhead_seconds(&self) -> Seconds { let frames = self.playhead.load(Ordering::Relaxed); - frames as f64 / self.sample_rate as f64 + Seconds(frames as f64 / self.sample_rate as f64) } /// Get the shared clip snapshot. The UI can read this each frame to display @@ -3740,7 +3751,7 @@ impl EngineController { /// Set metatrack time offset in seconds /// Positive = shift content later, negative = shift earlier pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) { - let _ = self.command_tx.push(Command::SetOffset(track_id, offset.seconds_to_f64())); + let _ = self.command_tx.push(Command::SetOffset(track_id, offset)); } /// Set metatrack pitch shift in semitones (for future use) @@ -3750,12 +3761,12 @@ impl EngineController { /// Set metatrack trim start in seconds pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: Seconds) { - let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start.seconds_to_f64())); + let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start)); } /// Set metatrack trim end in seconds (None = no end trim) pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option) { - let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end.map(|s| s.seconds_to_f64()))); + let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end)); } /// Create a new audio track @@ -3909,23 +3920,22 @@ impl EngineController { pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: Beats, duration: Beats) -> MidiClipId { // Peek at the next clip ID that will be used let clip_id = self.next_midi_clip_id.load(Ordering::Relaxed); - let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time.beats_to_f64(), duration.beats_to_f64())); + let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time, duration)); clip_id } /// Add a MIDI note to a clip pub fn add_midi_note(&mut self, track_id: TrackId, clip_id: MidiClipId, time_offset: Beats, note: u8, velocity: u8, duration: Beats) { - let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset.beats_to_f64(), note, velocity, duration.beats_to_f64())); + let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset, note, velocity, duration)); } /// Add a pre-loaded MIDI clip to a track at the given timeline position (beats) pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) { - let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time.beats_to_f64())); + let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time)); } /// Update all notes in a MIDI clip. Note tuples are (start [beats], note, velocity, duration [beats]). pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(Beats, u8, u8, Beats)>) { - let notes = notes.into_iter().map(|(t, n, v, d)| (t.beats_to_f64(), n, v, d.beats_to_f64())).collect(); let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes)); } @@ -3966,7 +3976,7 @@ impl EngineController { curve: crate::audio::CurveType, ) { let _ = self.command_tx.push(Command::AddAutomationPoint( - track_id, lane_id, time.beats_to_f64(), value, curve, + track_id, lane_id, time, value, curve, )); } @@ -3979,7 +3989,7 @@ impl EngineController { tolerance: Beats, ) { let _ = self.command_tx.push(Command::RemoveAutomationPoint( - track_id, lane_id, time.beats_to_f64(), tolerance.beats_to_f64(), + track_id, lane_id, time, tolerance, )); } @@ -4018,13 +4028,13 @@ impl EngineController { time: Beats, value: f32, interpolation: String, ease_out: (f32, f32), ease_in: (f32, f32)) { let _ = self.command_tx.push(Command::AutomationAddKeyframe( - track_id, node_id, time.beats_to_f64(), value, interpolation, ease_out, ease_in)); + track_id, node_id, time, value, interpolation, ease_out, ease_in)); } /// Remove a keyframe from an AutomationInput node pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) { let _ = self.command_tx.push(Command::AutomationRemoveKeyframe( - track_id, node_id, time.beats_to_f64())); + track_id, node_id, time)); } /// Set the display name of an AutomationInput node @@ -4560,7 +4570,7 @@ impl EngineController { } /// Get file info from pool (duration, sample_rate, channels) - pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(f64, u32, u32), String> { + pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(Seconds, u32, u32), String> { // Send query if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) { return Err("Failed to send query - queue full".to_string()); diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 29a6934..e14a19b 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -34,7 +34,7 @@ pub enum Command { /// Pause playback (maintains position) Pause, /// Seek to a specific position in seconds - Seek(f64), + Seek(Seconds), // Track management commands /// Set track volume (0.0 = silence, 1.0 = unity gain) @@ -51,7 +51,7 @@ pub enum Command { TrimClip(TrackId, ClipId, TrimRange), /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) /// If duration > internal duration, the clip will loop - ExtendClip(TrackId, ClipId, f64), + ExtendClip(TrackId, ClipId, Beats), // Metatrack management commands /// Create a new metatrack with a name and optional parent group @@ -67,15 +67,15 @@ pub enum Command { SetTimeStretch(TrackId, f32), /// Set metatrack time offset in seconds (track_id, offset) /// Positive = shift content later, negative = shift earlier - SetOffset(TrackId, f64), + SetOffset(TrackId, Seconds), /// Set metatrack pitch shift in semitones (track_id, semitones) - for future use SetPitchShift(TrackId, f32), /// Set metatrack trim start in seconds (track_id, trim_start) /// Children won't hear content before this point - SetTrimStart(TrackId, f64), + SetTrimStart(TrackId, Seconds), /// Set metatrack trim end in seconds (track_id, trim_end) /// None means no end trim - SetTrimEnd(TrackId, Option), + SetTrimEnd(TrackId, Option), // Audio track commands /// Create a new audio track with a name and optional parent group @@ -94,14 +94,14 @@ pub enum Command { /// Add a MIDI clip to the pool without placing it on a track AddMidiClipToPool(MidiClip), /// Create a new MIDI clip on a track (track_id, start_time, duration) - CreateMidiClip(TrackId, f64, f64), + CreateMidiClip(TrackId, Beats, Beats), /// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration) - AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64), + AddMidiNote(TrackId, MidiClipId, Beats, u8, u8, Beats), /// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time) - AddLoadedMidiClip(TrackId, MidiClip, f64), + AddLoadedMidiClip(TrackId, MidiClip, Beats), /// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>) /// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes - UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>), + UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(Beats, u8, u8, Beats)>), /// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing. UpdateMidiClipEvents(TrackId, MidiClipId, Vec), /// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support @@ -117,9 +117,9 @@ pub enum Command { /// Create a new automation lane on a track (track_id, parameter_id) CreateAutomationLane(TrackId, ParameterId), /// Add an automation point to a lane (track_id, lane_id, time, value, curve) - AddAutomationPoint(TrackId, AutomationLaneId, f64, f32, CurveType), + AddAutomationPoint(TrackId, AutomationLaneId, Beats, f32, CurveType), /// Remove an automation point at a specific time (track_id, lane_id, time, tolerance) - RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64), + RemoveAutomationPoint(TrackId, AutomationLaneId, Beats, Beats), /// Clear all automation points from a lane (track_id, lane_id) ClearAutomationLane(TrackId, AutomationLaneId), /// Remove an automation lane (track_id, lane_id) @@ -258,9 +258,9 @@ pub enum Command { // Automation Input Node commands /// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in) - AutomationAddKeyframe(TrackId, u32, f64, f32, String, (f32, f32), (f32, f32)), + AutomationAddKeyframe(TrackId, u32, Beats, f32, String, (f32, f32), (f32, f32)), /// Remove a keyframe from an AutomationInput node (track_id, node_id, time) - AutomationRemoveKeyframe(TrackId, u32, f64), + AutomationRemoveKeyframe(TrackId, u32, Beats), /// Set the display name of an AutomationInput node (track_id, node_id, name) AutomationSetName(TrackId, u32, String), @@ -292,7 +292,7 @@ pub enum Command { #[derive(Debug, Clone)] pub enum AudioEvent { /// Current playback position in seconds - PlaybackPosition(f64), + PlaybackPosition(Seconds), /// Playback has stopped (reached end of audio) PlaybackStopped, /// Audio buffer underrun detected @@ -379,7 +379,7 @@ pub enum AudioEvent { WaveformChunksReady { pool_index: usize, detail_level: u8, - chunks: Vec<(u32, (f64, f64), Vec)>, + chunks: Vec<(u32, (Seconds, Seconds), Vec)>, }, /// An audio file has been imported and is ready for playback. @@ -390,7 +390,7 @@ pub enum AudioEvent { path: String, channels: u32, sample_rate: u32, - duration: f64, + duration: Seconds, format: crate::io::audio_file::AudioFormat, }, @@ -464,7 +464,7 @@ pub enum Query { /// Export audio to file (settings, output_path) ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), /// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID - AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64), + AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, Beats), /// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID /// The clip must already exist in the MidiClipPool AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), @@ -509,16 +509,21 @@ pub struct OscilloscopeData { } /// MIDI clip data for serialization +/// +/// `Beats`/`Seconds` are `#[serde(transparent)]`, so naming the domain here costs nothing on disk — +/// the `.beam` still holds a plain number. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MidiClipData { - pub duration: f64, + /// MIDI content length is musical, so beats. + pub duration: Beats, pub events: Vec, } /// Automation keyframe data for serialization #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AutomationKeyframeData { - pub time: f64, + /// Automation x-axes are all beats. + pub time: Beats, pub value: f32, pub interpolation: String, pub ease_out: (f32, f32), @@ -555,7 +560,7 @@ pub enum QueryResponse { /// Pool waveform data PoolWaveform(Result, String>), /// Pool file info (duration, sample_rate, channels) - PoolFileInfo(Result<(f64, u32, u32), String>), + PoolFileInfo(Result<(Seconds, u32, u32), String>), /// Audio exported AudioExported(Result<(), String>), /// MIDI clip instance added (returns instance ID) diff --git a/daw-backend/src/lib.rs b/daw-backend/src/lib.rs index a8153e6..c258ca9 100644 --- a/daw-backend/src/lib.rs +++ b/daw-backend/src/lib.rs @@ -20,7 +20,7 @@ pub use audio::{ TrackNode, }; pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode}; -pub use time::{Beats, Seconds}; +pub use time::{Beats, ContentTime, Seconds}; pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack}; pub use command::{AudioEvent, Command, OscilloscopeData}; pub use command::types::AutomationKeyframeData; diff --git a/daw-backend/src/time.rs b/daw-backend/src/time.rs index b2e2913..fdea4f4 100644 --- a/daw-backend/src/time.rs +++ b/daw-backend/src/time.rs @@ -16,6 +16,50 @@ pub struct Beats(pub f64); #[serde(transparent)] pub struct Seconds(pub f64); +/// A time *inside a clip's own content*, in whatever unit that clip measures content in. +/// +/// Clip content time is domain-polymorphic: SECONDS for sampled audio, video and vector, but BEATS +/// for MIDI (musical, so it survives tempo changes). `ClipInstance::trim_start`/`trim_end` are +/// content times, and storing them as bare `f64`s is what let a seconds delta get added to a MIDI +/// clip's beats trim — splitting a MIDI clip at beat 4 landed at beat 2 at 120 BPM. +/// +/// This type is deliberately a **dead end**: it has no `.to_seconds()`, no `.to_beats()`, and no +/// arithmetic with `Seconds` or `Beats`. Content times can be compared and combined with each other +/// (that's domain-safe — both operands are in the same clip's domain), but the only way to get a +/// real timeline duration out is to resolve it against the clip that knows the domain, via +/// `AudioClip::resolve_content_time` / `Document::resolve_content_time`. So a passthrough costs +/// nothing, and mixing domains won't compile. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ContentTime(pub f64); + +impl ContentTime { + 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)) } + + /// The raw magnitude, with the domain discarded. + /// + /// Only for code that is *already* working in this clip's content domain (trim arithmetic, + /// serialization, drawing a waveform whose x-axis is the clip's own content). If you are about + /// to combine this with a timeline position, resolve it against the clip instead. + pub fn raw(self) -> f64 { self.0 } +} + +impl Add for ContentTime { + type Output = Self; + fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) } +} +impl Sub for ContentTime { + type Output = Self; + fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) } +} +impl Rem for ContentTime { + type Output = Self; + fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) } +} + impl Beats { pub const ZERO: Self = Self(0.0); diff --git a/daw-backend/src/tui/mod.rs b/daw-backend/src/tui/mod.rs index 0d44a6b..645c699 100644 --- a/daw-backend/src/tui/mod.rs +++ b/daw-backend/src/tui/mod.rs @@ -556,7 +556,7 @@ pub fn run_tui( while let Ok(event) = rx.pop() { match event { AudioEvent::PlaybackPosition(pos) => { - app.update_playback_position(pos); + app.update_playback_position(pos.seconds_to_f64()); } AudioEvent::PlaybackStopped => { app.set_playing(false); diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 4909e85..4ac3547 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -77,11 +77,18 @@ impl BackendContext<'_> { .ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?; let resolved = clip.resolve(instance.active_take); - let content_duration = clip.content_duration().native(); + let content = clip.content_duration(); let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(content_duration); + let internal_end = instance + .trim_end + .unwrap_or(daw_backend::ContentTime(content.native())); let start_time = instance.timeline_start; + // How long the clip occupies the timeline, in BEATS. `effective_duration_beats` resolves the + // content window in the clip's own domain — beats content carries over directly, wall-clock + // content converts at the clip's position — so neither kind can be read as the other here. + let effective_duration = instance.effective_duration_beats(content, document.tempo_map()); + let controller = self .audio_controller .as_mut() @@ -91,18 +98,14 @@ impl BackendContext<'_> { ResolvedContent::Midi { midi_clip_id } => { use daw_backend::command::{Query, QueryResponse}; - // MIDI trims are in the BEATS domain, so the fallback span is beats too. - let external_duration = instance - .timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - + // MIDI content time IS beats, so the trims carry straight over. let midi_instance = daw_backend::MidiClipInstance::new( 0, // assigned by the backend midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), + daw_backend::Beats(internal_start.raw()), + daw_backend::Beats(internal_end.raw()), start_time, - external_duration, + effective_duration, ); match controller @@ -114,26 +117,13 @@ impl BackendContext<'_> { } } ResolvedContent::Audio { audio_pool_index } => { - // `trim_*` and the clip's content duration are SECONDS (audio content time); the - // backend's start/duration are BEATS. - // - // When `timeline_duration` is set it's already beats; otherwise the clip occupies - // its natural content length, so convert that seconds-span to beats *at the clip's - // start* (NOT `internal_end - internal_start`, which is seconds — that was the - // seconds-as-beats bug that made clips stop early at anything but 60 BPM). - let effective_duration = instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - + // Sampled-audio content time is SECONDS; the backend's start/duration are BEATS. let id = controller.add_audio_clip( track_id, audio_pool_index, start_time, effective_duration, - daw_backend::Seconds(internal_start), + daw_backend::Seconds(internal_start.raw()), ); BackendClipInstanceId::Audio(id) } 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 fe88754..e7aa768 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -89,10 +89,11 @@ impl Action for AddClipInstanceAction { // `get_clip_duration` is the content length in seconds; the placement span // must be beats (the timeline is beats-domain), so convert via the clip's // typed helper rather than treating the seconds span as beats. - let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id) + // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI. + let clip_content = document.clip_trim_duration(&self.clip_instance.clip_id) .ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?; let effective_duration = self.clip_instance - .effective_duration_beats(clip_duration, document.tempo_map()); + .effective_duration_beats(clip_content, document.tempo_map()); // Auto-adjust position for audio/video layers to avoid overlaps let adjusted_start = document.find_nearest_valid_position( diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index e8e3099..a68795a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -128,17 +128,13 @@ impl LoopClipInstancesAction { (new_dur, new_lb) }; - let content_window = { - let trim_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - (trim_end - instance.trim_start).max(0.0) // seconds - }; - // Natural content length as a beats span at the clip's start (the - // fallback when no explicit timeline_duration is set). - let tempo_map = document.tempo_map(); - let content_window_beats = tempo_map.seconds_to_beats( - tempo_map.beats_to_seconds(instance.timeline_start) - + daw_backend::Seconds(content_window), - ) - instance.timeline_start; + // Natural content length as a beats span (the fallback when no explicit + // timeline_duration is set). Resolved in the clip's own domain, so MIDI's beats + // content carries over directly rather than being read as seconds. + let content_window_beats = instance.effective_duration_beats( + clip.content_duration(), + document.tempo_map(), + ); let right_duration = target_duration.unwrap_or(content_window_beats); let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO); let external_duration = left_duration + right_duration; 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 ceb3777..0439e04 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -105,7 +105,7 @@ impl Action for MoveClipInstancesAction { let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| { let inst = clip_instances.iter().find(|ci| &ci.id == id)?; - let dur = document.get_clip_duration(&inst.clip_id)?; + let dur = document.clip_trim_duration(&inst.clip_id)?; let eff = inst.effective_duration_beats(dur, document.tempo_map()); Some((*id, *old_start, eff)) }).collect(); @@ -211,8 +211,9 @@ impl Action for MoveClipInstancesAction { // Check if this clip has a metatrack if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -295,8 +296,9 @@ impl Action for MoveClipInstancesAction { if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } 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 4415ef6..319e397 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -138,92 +138,23 @@ impl Action for RemoveClipInstancesAction { backend: &mut BackendContext, document: &Document, ) -> Result<(), String> { - use crate::clip::ResolvedContent; + if backend.audio_controller.is_none() { + return Ok(()); + } - let controller = match backend.audio_controller.as_mut() { - Some(c) => c, - None => return Ok(()), - }; - - // Re-add clips that were removed from backend - for (layer_id, instance) in &self.saved { - let layer = match document.get_layer(layer_id) { - Some(l) => l, - None => continue, - }; - if !matches!(layer, AnyLayer::Audio(_)) { + // Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same + // helper the add and split actions use, so the trim/duration conversions (and take-folder + // resolution) stay in exactly one place instead of being copied into every action that has + // to put a clip back. + let saved = std::mem::take(&mut self.saved); + for (layer_id, instance) in &saved { + if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) { continue; } - - let track_id = match backend.layer_to_track_map.get(layer_id) { - Some(id) => *id, - None => continue, - }; - - let clip = match document.get_audio_clip(&instance.clip_id) { - Some(c) => c, - None => continue, - }; - - match &clip.resolve(instance.active_take) { - ResolvedContent::Midi { midi_clip_id } => { - use daw_backend::command::{Query, QueryResponse}; - - let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - let external_start = instance.timeline_start; - // MIDI trims are beats-domain, so the fallback span is beats too. - let external_duration = instance - .timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - - let midi_instance = daw_backend::MidiClipInstance::new( - 0, - *midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), - external_start, - external_duration, - ); - - let query = Query::AddMidiClipInstanceSync(track_id, midi_instance); - if let Ok(QueryResponse::MidiClipInstanceAdded(Ok(new_id))) = - controller.send_query(query) - { - backend.clip_instance_to_backend_map.insert( - instance.id, - BackendClipInstanceId::Midi(new_id), - ); - } - } - ResolvedContent::Audio { audio_pool_index } => { - let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - let start_time = instance.timeline_start; - // Fallback span is the content seconds converted to beats at the - // clip's start (not the seconds span treated as beats). - let effective_duration = instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - - let new_id = controller.add_audio_clip( - track_id, - *audio_pool_index, - start_time, - effective_duration, - daw_backend::Seconds(internal_start), - ); - backend.clip_instance_to_backend_map.insert( - instance.id, - BackendClipInstanceId::Audio(new_id), - ); - } - ResolvedContent::Recording => {} - } + // A missing track/clip just means there's nothing to restore on the backend. + let _ = backend.add_clip_instance(document, layer_id, instance); } + self.saved = saved; // Clear saved backend IDs self.saved_backend_ids.clear(); 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 b071d47..c234510 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -7,6 +7,7 @@ use crate::action::{Action, BackendContext}; use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; +use daw_backend::ContentTime; use uuid::Uuid; /// Action that splits a clip instance at a specific timeline position @@ -25,7 +26,7 @@ pub struct SplitClipInstanceAction { // Stored during execute for rollback /// Original trim_end value of the left (original) instance - original_trim_end: Option, + original_trim_end: Option, /// Original timeline_duration value of the left (original) instance (beats) original_timeline_duration: Option, /// ID of the new (right) instance created by the split @@ -122,13 +123,15 @@ impl Action for SplitClipInstanceAction { .find(|ci| ci.id == self.instance_id) .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; - // Get the clip's duration - let clip_duration = document - .get_clip_duration(&instance.clip_id) + // The clip's content duration in its OWN domain — seconds for audio/video/vector, beats for + // MIDI. All the content math below is trim-domain, so it has to be done in whichever domain + // this clip uses; a seconds duration would silently be added to a MIDI clip's beats trim. + let trim_duration = document + .clip_trim_duration(&instance.clip_id) .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; // Calculate the effective duration and timeline end (both in beats) - let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); + let effective_duration = instance.effective_duration(trim_duration, document.tempo_map()); let timeline_end = instance.timeline_start + effective_duration; // Validate: split_time must be strictly within the clip's timeline span @@ -146,33 +149,26 @@ impl Action for SplitClipInstanceAction { self.original_trim_end = instance.trim_end; self.original_timeline_duration = instance.timeline_duration; - // The clip's content duration in the SAME domain as its trims — seconds for audio/video/ - // vector, beats for MIDI. All the content math below is trim-domain, so it has to be done - // in whichever domain this clip uses; `clip_duration` above is always seconds and would - // silently add a seconds delta to a MIDI clip's beats trim. - let trim_duration = document - .clip_trim_duration(&instance.clip_id) - .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; - let is_looping = instance.timeline_duration.is_some(); - let content_duration = instance.trim_end.unwrap_or(trim_duration.native()) - instance.trim_start; + let content_duration = ContentTime(instance.content_window(trim_duration).native()); // Timeline split point (beats). let time_into_clip = self.split_time - instance.timeline_start; let left_duration = time_into_clip; let right_duration = effective_duration - left_duration; - // How far the split lands into the clip's *content*, expressed in the trim domain. + // How far the split lands into the clip's *content*, expressed in the content domain: beats + // content takes the beats delta directly, wall-clock content takes the seconds delta. let tempo_map = document.tempo_map(); - let time_into_content = match trim_duration { + let time_into_content = ContentTime(match trim_duration { crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(), crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time) - tempo_map.beats_to_seconds(instance.timeline_start)) .seconds_to_f64(), - }; + }); - // Calculate the content split point (trim domain). - let content_split_time = if is_looping && content_duration > 0.0 { + // Calculate the content split point (content domain). + let content_split_time = if is_looping && content_duration > ContentTime::ZERO { // For looping clips, wrap around content instance.trim_start + (time_into_content % content_duration) } else { @@ -367,119 +363,63 @@ impl Action for SplitClipInstanceAction { .get_audio_clip(&new_instance.clip_id) .ok_or_else(|| "Audio clip not found".to_string())?; - // Look up backend track ID from layer mapping - let backend_track_id = backend + use crate::clip::ResolvedContent; + if matches!(clip.resolve(original_instance.active_take), ResolvedContent::Recording) { + return Err("Cannot split a clip that is currently recording".to_string()); + } + + // A split is: shorten the left half's backend clip, then add the right half as a new one. + // + // 1. Trim the left (original) instance. `trim_range` tags the bounds with the clip's own + // content domain, so a MIDI clip's beats trims can't be sent as seconds. + let left_trim = clip.trim_range( + original_instance.trim_start, + original_instance + .trim_end + .unwrap_or(ContentTime(clip.content_duration().native())), + ); + let new_instance = new_instance.clone(); + + let backend_track_id = *backend .layer_to_track_map .get(&self.layer_id) .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; + let left_backend_id = backend + .clip_instance_to_backend_map + .get(&self.instance_id) + .copied(); - // Get audio controller - let controller = backend - .audio_controller - .as_mut() - .ok_or_else(|| "Audio controller not available".to_string())?; - - // Handle different clip types - use crate::clip::ResolvedContent; - match &clip.resolve(original_instance.active_take) { - ResolvedContent::Midi { midi_clip_id } => { - use daw_backend::command::{Query, QueryResponse}; - - // 1. Trim the original (left) instance - let orig_internal_start = original_instance.trim_start; - let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native()); - - // Look up the original backend instance ID - if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = - backend.clip_instance_to_backend_map.get(&self.instance_id) - { - controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); + { + let controller = backend + .audio_controller + .as_mut() + .ok_or_else(|| "Audio controller not available".to_string())?; + match left_backend_id { + Some(crate::action::BackendClipInstanceId::Midi(id)) + | Some(crate::action::BackendClipInstanceId::Audio(id)) => { + controller.trim_clip(backend_track_id, id, left_trim); } - - // 2. Add the new (right) instance - let internal_start = new_instance.trim_start; - let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native()); - let external_start = new_instance.timeline_start; - // MIDI trims are beats-domain, so the fallback span is beats too. - let external_duration = new_instance - .timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - - let instance = daw_backend::MidiClipInstance::new( - 0, - *midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), - external_start, - external_duration, - ); - - let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance); - - match controller.send_query(query)? { - QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => { - self.backend_track_id = Some(*backend_track_id); - self.backend_midi_instance_id = Some(instance_id); - - backend.clip_instance_to_backend_map.insert( - new_instance_id, - crate::action::BackendClipInstanceId::Midi(instance_id), - ); - - Ok(()) - } - QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e), - _ => Err("Unexpected query response".to_string()), - } - } - ResolvedContent::Audio { audio_pool_index } => { - // 1. Trim the original (left) instance - let orig_internal_start = original_instance.trim_start; - let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native()); - - // Look up the original backend instance ID - if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) = - backend.clip_instance_to_backend_map.get(&self.instance_id) - { - controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); - } - - // 2. Add the new (right) instance - let internal_start = new_instance.trim_start; - let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native()); - let start_time = new_instance.timeline_start; - // Fallback span is the content seconds converted to beats at the - // clip's start (not the seconds span treated as beats). - let effective_duration = new_instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - - let instance_id = controller.add_audio_clip( - *backend_track_id, - *audio_pool_index, - start_time, - effective_duration, - daw_backend::Seconds(internal_start), - ); - - self.backend_track_id = Some(*backend_track_id); - self.backend_audio_instance_id = Some(instance_id); - - backend.clip_instance_to_backend_map.insert( - new_instance_id, - crate::action::BackendClipInstanceId::Audio(instance_id), - ); - - Ok(()) - } - ResolvedContent::Recording => { - // Recording clips cannot be split - Err("Cannot split a clip that is currently recording".to_string()) + None => {} } } + + // 2. Add the right (new) instance via the shared helper — same one AddClipInstanceAction + // uses, so the trim/duration conversions live in exactly one place. + if let Some((track_id, backend_id)) = + backend.add_clip_instance(document, &self.layer_id, &new_instance)? + { + self.backend_track_id = Some(track_id); + match backend_id { + crate::action::BackendClipInstanceId::Midi(id) => { + self.backend_midi_instance_id = Some(id) + } + crate::action::BackendClipInstanceId::Audio(id) => { + self.backend_audio_instance_id = Some(id) + } + } + } + + Ok(()) } fn rollback_backend( @@ -509,7 +449,9 @@ impl Action for SplitClipInstanceAction { if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) { if let Some(clip) = document.get_audio_clip(&instance.clip_id) { let orig_internal_start = instance.trim_start; - let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native()); + let orig_internal_end = self + .original_trim_end + .unwrap_or(ContentTime(clip.content_duration().native())); // Restore based on clip type use crate::clip::ResolvedContent; @@ -564,8 +506,8 @@ mod tests { // Create a clip instance at timeline 0, with trim 0-10 (10 seconds) let mut clip_instance = ClipInstance::new(clip_id); clip_instance.timeline_start = daw_backend::Beats::ZERO; - clip_instance.trim_start = 0.0; - clip_instance.trim_end = Some(10.0); + clip_instance.trim_start = ContentTime::ZERO; + clip_instance.trim_end = Some(ContentTime(10.0)); let instance_id = clip_instance.id; vector_layer.clip_instances.push(clip_instance); @@ -606,8 +548,8 @@ mod tests { let mut audio_layer = crate::layer::AudioLayer::new("Layer 1"); let mut instance = ClipInstance::new(clip_id); instance.timeline_start = daw_backend::Beats::ZERO; - instance.trim_start = 0.0; - instance.trim_end = Some(8.0); // beats + instance.trim_start = ContentTime::ZERO; + instance.trim_end = Some(ContentTime(8.0)); // beats let instance_id = instance.id; audio_layer.clip_instances.push(instance); let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer)); @@ -620,7 +562,7 @@ mod tests { let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap(); let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap(); - assert_eq!(right.trim_start, 4.0, "right half must start 4 BEATS into the content"); - assert_eq!(left.trim_end, Some(4.0), "left half must end 4 BEATS into the content"); + assert_eq!(right.trim_start, ContentTime(4.0), "right half must start 4 BEATS into the content"); + assert_eq!(left.trim_end, Some(ContentTime(4.0)), "left half must end 4 BEATS into the content"); } } 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 3e3d1be..ef97fee 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -6,7 +6,7 @@ use crate::action::Action; use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; -use daw_backend::{Beats, Seconds}; +use daw_backend::{Beats, ContentTime, Seconds}; use std::collections::HashMap; use uuid::Uuid; @@ -32,15 +32,57 @@ pub struct TrimClipInstancesAction { pub struct TrimData { /// For TrimLeft: trim_start value /// For TrimRight: trim_end value (Option because it can be None) - pub trim_value: Option, + /// + /// A content time — measured in the clip's own domain (seconds for audio/video/vector, beats + /// for MIDI), so it must be resolved against the clip before meeting a timeline position. + pub trim_value: Option, /// For TrimLeft: timeline_start value (where the clip appears on timeline, beats) /// For TrimRight: unused (None) pub timeline_start: Option, } +/// A wall-clock gap on the timeline, expressed in a clip's content domain. +/// +/// Trim validation clamps how far a clip may be dragged against the empty space next to it, and that +/// space is measured on the timeline (seconds) while the trim lives in the clip's content domain. For +/// wall-clock content they're the same number; for MIDI (beats content) the gap has to be converted +/// at the clip's position, or a seconds gap silently clamps a beats trim. +fn gap_to_content( + gap: Seconds, + clip_content: crate::clip::ClipDuration, + timeline_start: Beats, + tempo_map: &crate::tempo_map::TempoMap, +) -> ContentTime { + match clip_content { + crate::clip::ClipDuration::Seconds(_) => ContentTime(gap.seconds_to_f64()), + crate::clip::ClipDuration::Beats(_) => { + let beats = tempo_map + .seconds_to_beats(tempo_map.beats_to_seconds(timeline_start) + gap) + - timeline_start; + ContentTime(beats.beats_to_f64()) + } + } +} + +/// The inverse: a content-domain span as wall-clock seconds at the clip's position. +fn content_to_secs( + span: ContentTime, + clip_content: crate::clip::ClipDuration, + timeline_start: Beats, + tempo_map: &crate::tempo_map::TempoMap, +) -> Seconds { + match clip_content { + crate::clip::ClipDuration::Seconds(_) => Seconds(span.raw()), + crate::clip::ClipDuration::Beats(_) => { + tempo_map.beats_to_seconds(timeline_start + Beats(span.raw())) + - tempo_map.beats_to_seconds(timeline_start) + } + } +} + impl TrimData { /// Create TrimData for left trim - pub fn left(trim_start: f64, timeline_start: Beats) -> Self { + pub fn left(trim_start: ContentTime, timeline_start: Beats) -> Self { Self { trim_value: Some(trim_start), timeline_start: Some(timeline_start), @@ -48,7 +90,7 @@ impl TrimData { } /// Create TrimData for right trim - pub fn right(trim_end: Option) -> Self { + pub fn right(trim_end: Option) -> Self { Self { trim_value: trim_end, timeline_start: None, @@ -192,7 +234,8 @@ impl Action for TrimClipInstancesAction { .find(|ci| &ci.id == instance_id) .ok_or_else(|| format!("Instance {} not found", instance_id))?; - let clip_duration = document.get_clip_duration(&instance.clip_id) + // The clip's content duration in ITS OWN domain, so trims resolve correctly for MIDI. + let clip_content = document.clip_trim_duration(&instance.clip_id) .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; let mut clamped_new = new.clone(); @@ -204,23 +247,34 @@ impl Action for TrimClipInstancesAction { { // If extending to the left (new_trim < old_trim) if should_validate && new_trim < old_trim { - // Max leftward extension as content seconds (the gap's wall-clock span). - let max_extend_secs = document.find_max_trim_extend_left( - layer_id, - instance_id, - instance.timeline_start, - ).seconds_to_f64(); - - // Calculate how much we want to extend (content seconds) - let desired_extend = old_trim - new_trim; - - // Clamp to max allowed - let actual_extend = desired_extend.min(max_extend_secs); - let clamped_trim_start = old_trim - actual_extend; - // Move the timeline left by the same wall-clock seconds. let tempo_map = document.tempo_map(); + + // Max leftward extension: the gap's wall-clock span, converted into + // the clip's content domain so it can clamp a content-domain trim. + let max_extend = gap_to_content( + document.find_max_trim_extend_left( + layer_id, + instance_id, + instance.timeline_start, + ), + clip_content, + instance.timeline_start, + tempo_map, + ); + + let desired_extend = old_trim - new_trim; + let actual_extend = desired_extend.min(max_extend); + let clamped_trim_start = old_trim - actual_extend; + + // Move the timeline left by the same span, as wall-clock seconds. + let shift = content_to_secs( + actual_extend, + clip_content, + instance.timeline_start, + tempo_map, + ); let clamped_timeline_start = tempo_map - .seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - Seconds(actual_extend)) + .seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - shift) .max(Beats::ZERO); clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start); @@ -228,36 +282,39 @@ impl Action for TrimClipInstancesAction { } } TrimType::TrimRight => { - let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64()); - let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64()); + let content_end = ContentTime(clip_content.native()); + let old_trim_end = old.trim_value.unwrap_or(content_end); + let new_trim_end = new.trim_value.unwrap_or(content_end); // If extending to the right (new_trim_end > old_trim_end) if should_validate && new_trim_end > old_trim_end { let tempo_map = document.tempo_map(); - // Current effective duration in beats (content seconds - // converted to beats at the clip's start). - let content_secs = Seconds(old_trim_end - instance.trim_start); - let current_effective_duration = tempo_map.seconds_to_beats( - tempo_map.beats_to_seconds(instance.timeline_start) + content_secs, - ) - instance.timeline_start; - // Max rightward extension as content seconds (the gap's wall-clock span). - let max_extend_secs = document.find_max_trim_extend_right( - layer_id, - instance_id, + // How long the clip currently occupies the timeline, in beats. Resolved + // in the clip's own domain, so a MIDI clip's beats content isn't run + // through the seconds→beats conversion a second time. + let current_effective_duration = instance + .effective_duration_beats(clip_content, tempo_map); + + // Max rightward extension: the gap's wall-clock span, in content domain. + let max_extend = gap_to_content( + document.find_max_trim_extend_right( + layer_id, + instance_id, + instance.timeline_start, + current_effective_duration, + ), + clip_content, instance.timeline_start, - current_effective_duration, - ).seconds_to_f64(); + tempo_map, + ); - // Calculate how much we want to extend (content seconds) let desired_extend = new_trim_end - old_trim_end; - - // Clamp to max allowed - let actual_extend = desired_extend.min(max_extend_secs); + let actual_extend = desired_extend.min(max_extend); let clamped_trim_end = old_trim_end + actual_extend; - // Don't exceed clip duration - let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64()); + // Don't exceed the clip's content. + let final_trim_end = clamped_trim_end.min(content_end); clamped_new = TrimData::right(Some(final_trim_end)); } @@ -387,8 +444,9 @@ impl Action for TrimClipInstancesAction { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { // Instance already has new values after execute() controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -424,7 +482,9 @@ impl Action for TrimClipInstancesAction { // Calculate new internal_start and internal_end for backend // Note: instance already has the new trim values after execute() let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); + let internal_end = instance + .trim_end + .unwrap_or(ContentTime(clip.content_duration().native())); // Handle trim based on clip type match &clip.resolve(instance.active_take) { @@ -477,8 +537,9 @@ impl Action for TrimClipInstancesAction { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { // Instance already has old values after rollback() controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -512,13 +573,14 @@ impl Action for TrimClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Calculate old internal_start and internal_end for backend + let content_end = ContentTime(clip.content_duration().native()); let internal_start = match trim_type { - TrimType::TrimLeft => old.trim_value.unwrap_or(0.0), + TrimType::TrimLeft => old.trim_value.unwrap_or(ContentTime::ZERO), TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed }; let internal_end = match trim_type { - TrimType::TrimLeft => instance.trim_end.unwrap_or(clip.content_duration().native()), // trim_end wasn't changed - TrimType::TrimRight => old.trim_value.unwrap_or(clip.content_duration().native()), + TrimType::TrimLeft => instance.trim_end.unwrap_or(content_end), // trim_end wasn't changed + TrimType::TrimRight => old.trim_value.unwrap_or(content_end), }; // Handle trim based on clip type @@ -569,7 +631,7 @@ mod tests { let mut clip_instance = ClipInstance::new(clip_id); clip_instance.timeline_start = Beats::ZERO; - clip_instance.trim_start = 0.0; + clip_instance.trim_start = ContentTime::ZERO; let instance_id = clip_instance.id; vector_layer.clip_instances.push(clip_instance); @@ -582,8 +644,8 @@ mod tests { vec![( instance_id, TrimType::TrimLeft, - TrimData::left(0.0, Beats::ZERO), - TrimData::left(2.0, Beats(2.0)), + TrimData::left(ContentTime::ZERO, Beats::ZERO), + TrimData::left(ContentTime(2.0), Beats(2.0)), )], ); @@ -599,7 +661,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_start, 2.0); + assert_eq!(instance.trim_start, ContentTime(2.0)); assert_eq!(instance.timeline_start, Beats(2.0)); } @@ -613,7 +675,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_start, 0.0); + assert_eq!(instance.trim_start, ContentTime::ZERO); assert_eq!(instance.timeline_start, Beats::ZERO); } } @@ -644,7 +706,7 @@ mod tests { instance_id, TrimType::TrimRight, TrimData::right(None), - TrimData::right(Some(8.0)), + TrimData::right(Some(ContentTime(8.0))), )], ); @@ -660,7 +722,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_end, Some(8.0)); + assert_eq!(instance.trim_end, Some(ContentTime(8.0))); } // Rollback diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 45459e3..447fbfe 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -14,7 +14,7 @@ use crate::layer::AnyLayer; use crate::layer_tree::LayerTree; use crate::object::Transform; -use daw_backend::{Beats, Seconds}; +use daw_backend::{Beats, ContentTime, Seconds}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use uuid::Uuid; @@ -130,10 +130,14 @@ impl VectorClip { let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration { ci.timeline_start + td_beats } else if let Some(te) = ci.trim_end { - let secs = (te - ci.trim_start).max(0.0); + // `clip_duration_fn` hands back seconds, so this whole path treats nested + // content as wall-clock. That's right for the vector/video/audio clips a vector + // clip actually nests; a nested MIDI clip (beats content) would need resolving + // against its clip, which this callback can't do. Pre-existing limitation. + let secs = (te - ci.trim_start).raw().max(0.0); tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) } else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) { - let secs = (clip_dur_secs - ci.trim_start).max(0.0); + let secs = (clip_dur_secs - ci.trim_start.raw()).max(0.0); tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) } else { continue; @@ -201,7 +205,9 @@ impl VectorClip { // 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().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + // Nested clips here are vector clips, whose content is wall-clock seconds. + let nested_clip_time = + ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Look up the nested clip definition let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) { @@ -535,6 +541,17 @@ impl ClipDuration { ClipDuration::Beats(b) => b.beats_to_f64(), } } + + /// Tag a [`ContentTime`] with *this* duration's domain. + /// + /// Handy when you already hold a clip's content duration (so you know the domain) and need to + /// resolve one of its trim bounds, without going back to the clip. + pub fn same_domain(self, t: ContentTime) -> ClipDuration { + match self { + ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(t.raw())), + ClipDuration::Beats(_) => ClipDuration::Beats(Beats(t.raw())), + } + } } /// Audio clip @@ -739,21 +756,33 @@ impl AudioClip { } } - /// Tag a pair of raw trim bounds with this clip's content domain, ready for the backend. + /// Resolve a content time against this clip's domain. /// - /// `ClipInstance::trim_start`/`trim_end` are bare `f64`s whose unit depends on the clip — - /// SECONDS for sampled audio, BEATS for MIDI. Building the [`TrimRange`] from the clip means a - /// caller can't reach for the wrong variant: the clip is the one thing that knows. - pub fn trim_range(&self, start: f64, end: f64) -> daw_backend::command::TrimRange { + /// This is the ONLY sanctioned way to turn a [`ContentTime`] into a real duration — the type has + /// no `.to_seconds()` of its own precisely so that the clip, which is the one thing that knows + /// whether its content is measured in seconds or beats, has to be consulted. + pub fn resolve_content_time(&self, t: ContentTime) -> ClipDuration { + if self.is_midi_domain() { + ClipDuration::Beats(Beats(t.raw())) + } else { + ClipDuration::Seconds(Seconds(t.raw())) + } + } + + /// Tag a pair of trim bounds with this clip's content domain, ready for the backend. + /// + /// Building the [`TrimRange`] from the clip means a caller can't reach for the wrong variant: + /// the clip is the one thing that knows the domain. + pub fn trim_range(&self, start: ContentTime, end: ContentTime) -> daw_backend::command::TrimRange { if self.is_midi_domain() { daw_backend::command::TrimRange::Beats { - start: Beats(start), - end: Beats(end), + start: Beats(start.raw()), + end: Beats(end.raw()), } } else { daw_backend::command::TrimRange::Seconds { - start: Seconds(start), - end: Seconds(end), + start: Seconds(start.raw()), + end: Seconds(end.raw()), } } } @@ -885,16 +914,17 @@ pub struct ClipInstance { /// Default: None (use trimmed clip duration, no looping) pub timeline_duration: Option, - /// 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 + /// Trim start: offset into the clip's internal content. + /// + /// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled + /// audio/video/vector but BEATS for MIDI. Resolve it against the clip + /// ([`Document::resolve_content_time`]) before combining it with anything on the timeline. /// Default: 0.0 - pub trim_start: f64, + pub trim_start: ContentTime, - /// Trim end: offset into the clip's internal content, in **seconds**. + /// Trim end: offset into the clip's internal content. See [`Self::trim_start`]. /// Default: None (use full clip duration) - pub trim_end: Option, + pub trim_end: Option, /// Playback speed multiplier /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed @@ -973,7 +1003,7 @@ impl ClipInstance { name: None, timeline_start: Beats::ZERO, timeline_duration: None, - trim_start: 0.0, + trim_start: ContentTime::ZERO, trim_end: None, playback_speed: 1.0, gain: 1.0, @@ -992,7 +1022,7 @@ impl ClipInstance { name: None, timeline_start: Beats::ZERO, timeline_duration: None, - trim_start: 0.0, + trim_start: ContentTime::ZERO, trim_end: None, playback_speed: 1.0, gain: 1.0, @@ -1033,7 +1063,7 @@ impl ClipInstance { } /// Set trimming (start and end time within the clip's internal content) - pub fn with_trimming(mut self, trim_start: f64, trim_end: Option) -> Self { + pub fn with_trimming(mut self, trim_start: ContentTime, trim_end: Option) -> Self { self.trim_start = trim_start; self.trim_end = trim_end; self @@ -1057,24 +1087,40 @@ impl ClipInstance { self } - /// Content window size in seconds: `trim_end - trim_start`. + /// Content window (`trim_end - trim_start`) in the clip's own content domain. /// Used for internal looping calculations. - pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds { - let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64()); - Seconds((end - self.trim_start).max(0.0)) + pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration { + let end = self.trim_end.map_or(clip_content.native(), |t| t.raw()); + let window = (end - self.trim_start.raw()).max(0.0); + match clip_content { + ClipDuration::Beats(_) => ClipDuration::Beats(Beats(window)), + ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(window)), + } } /// 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: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + /// If `timeline_duration` is set, returns that (enabling content looping). Otherwise the clip + /// occupies its content window — converted to beats *in the clip's own domain*: + /// + /// - MIDI content is already beats and is tempo-invariant, so it carries over directly. + /// - Wall-clock content (audio/video/vector) is a seconds span, so it converts at the clip's + /// position on the timeline. + /// + /// Taking a `ClipDuration` rather than a bare `Seconds` is what keeps those apart: this used to + /// take seconds and subtract `trim_start` from it, which for a TRIMMED MIDI clip subtracted a + /// beats offset from a seconds duration and got the clip's length wrong. + pub fn effective_duration_beats(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { if let Some(td) = self.timeline_duration { return td; } - let window = self.content_window_secs(clip_duration_secs); - let start_secs = tempo_map.beats_to_seconds(self.timeline_start); - tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start + match self.content_window(clip_content) { + ClipDuration::Beats(b) => b, + ClipDuration::Seconds(s) => { + let start_secs = tempo_map.beats_to_seconds(self.timeline_start); + tempo_map.seconds_to_beats(start_secs + s) - self.timeline_start + } + } } /// Left edge of the clip's visual extent on the timeline, in **beats**. @@ -1083,27 +1129,32 @@ impl ClipInstance { } /// Total visual duration (loop_before + effective_duration), in **beats**. - pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { - self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map) + pub fn total_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_content, tempo_map) } /// Map a playback time (in **seconds**) to clip-local content time (in **seconds**). /// - /// Returns `None` if the clip instance is not active at `time_secs`. - pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option { + /// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats + /// trims are converted rather than read as seconds. Callers are the wall-clock consumers (video + /// seek, vector/raster rendering), which want seconds regardless of how the clip stores content. + /// + /// Returns `None` if the clip instance is not active at `time`. + pub fn remap_time_secs(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option { let start_secs = tempo_map.beats_to_seconds(self.timeline_start); - let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map); + let dur_beats = self.effective_duration_beats(clip_content, tempo_map); let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats); if time < start_secs || time >= end_secs { return None; } + let trim_start_secs = clip_content.same_domain(self.trim_start).to_seconds(tempo_map); let content_time = (time - start_secs) * self.playback_speed; - let content_window = self.content_window_secs(clip_duration_secs); + let content_window = self.content_window(clip_content).to_seconds(tempo_map); if content_window == Seconds::ZERO { - return Some(Seconds(self.trim_start)); + return Some(trim_start_secs); } let looped = if content_time > content_window { @@ -1112,19 +1163,19 @@ impl ClipInstance { content_time }; - Some(Seconds(self.trim_start) + looped) + Some(trim_start_secs + looped) } /// Alias for `remap_time_secs`. #[inline] - pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option { - self.remap_time_secs(time, clip_duration_secs, tempo_map) + pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option { + self.remap_time_secs(time, clip_content, tempo_map) } /// Alias for `effective_duration_beats`. #[inline] - pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { - self.effective_duration_beats(clip_duration_secs, tempo_map) + pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + self.effective_duration_beats(clip_content, tempo_map) } /// Convert to affine transform @@ -1201,7 +1252,7 @@ mod tests { assert_eq!(instance.clip_id, clip_id); assert_eq!(instance.opacity, 1.0); assert_eq!(instance.timeline_start, Beats::ZERO); - assert_eq!(instance.trim_start, 0.0); + assert_eq!(instance.trim_start, ContentTime::ZERO); assert_eq!(instance.trim_end, None); assert_eq!(instance.playback_speed, 1.0); assert_eq!(instance.gain, 1.0); @@ -1211,27 +1262,52 @@ mod tests { fn test_clip_instance_trimming() { let clip_id = Uuid::new_v4(); let instance = ClipInstance::new(clip_id) - .with_trimming(2.0, Some(8.0)); + .with_trimming(ContentTime(2.0), Some(ContentTime(8.0))); - assert_eq!(instance.trim_start, 2.0); - assert_eq!(instance.trim_end, Some(8.0)); + assert_eq!(instance.trim_start, ContentTime(2.0)); + assert_eq!(instance.trim_end, Some(ContentTime(8.0))); // At 60 BPM the tempo map is identity (1 beat == 1 second), so the // beats-domain effective duration equals the seconds content window. let tempo_map = crate::tempo_map::TempoMap::constant(60.0); - assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(6.0)); + let content = ClipDuration::Seconds(Seconds(10.0)); + assert_eq!(instance.effective_duration(content, &tempo_map), Beats(6.0)); } #[test] fn test_clip_instance_no_end_trim() { let clip_id = Uuid::new_v4(); let instance = ClipInstance::new(clip_id) - .with_trimming(2.0, None); + .with_trimming(ContentTime(2.0), None); - assert_eq!(instance.trim_start, 2.0); + assert_eq!(instance.trim_start, ContentTime(2.0)); assert_eq!(instance.trim_end, None); // At 60 BPM the tempo map is identity (1 beat == 1 second). let tempo_map = crate::tempo_map::TempoMap::constant(60.0); - assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(8.0)); + let content = ClipDuration::Seconds(Seconds(10.0)); + assert_eq!(instance.effective_duration(content, &tempo_map), Beats(8.0)); + } + + #[test] + fn trimmed_midi_clip_keeps_its_beats_length_across_tempo() { + // Regression: `effective_duration_beats` used to take a SECONDS clip duration and subtract + // `trim_start` from it. For a TRIMMED MIDI clip that subtracted a beats offset from a + // seconds duration, so the clip's timeline length came out wrong at any tempo but 60 BPM. + // + // MIDI content is beats and tempo-invariant: a clip trimmed to beats 2..6 is 4 beats long + // whatever the tempo says. + let clip_id = Uuid::new_v4(); + let instance = ClipInstance::new(clip_id) + .with_trimming(ContentTime(2.0), Some(ContentTime(6.0))); + let content = ClipDuration::Beats(Beats(8.0)); + + for bpm in [60.0, 120.0, 90.0] { + let tempo_map = crate::tempo_map::TempoMap::constant(bpm); + assert_eq!( + instance.effective_duration(content, &tempo_map), + Beats(4.0), + "a MIDI clip trimmed to beats 2..6 is 4 beats long at {bpm} BPM", + ); + } } #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 0029a75..a5ec6b6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -464,8 +464,11 @@ impl Document { let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration { instance.timeline_start + timeline_duration } else { - let trim_end = instance.trim_end.unwrap_or(clip_duration); - let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0); + // `clip_duration` arrives as seconds (the recursive helper's signature), so this + // path is the wall-clock one; MIDI content would need resolving against its clip. + let trim_end = instance.trim_end.map_or(clip_duration, |t| t.raw()); + let trimmed_secs = + ((trim_end - instance.trim_start.raw()) / instance.playback_speed).max(0.0); let start_secs = tempo_map.beats_to_seconds(instance.timeline_start); tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs)) }; @@ -915,11 +918,10 @@ impl Document { /// have infinite internal duration. /// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**. /// - /// `ClipInstance::trim_*` is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for - /// sampled audio, video and vector, but BEATS for MIDI (the backend takes MIDI trims as - /// `Beats`). Anything doing arithmetic against a trim value — mapping a timeline position into - /// the clip's content, say — has to work in that same domain, and [`Self::get_clip_duration`] - /// can't tell it which: that one always converts to seconds. + /// Content time is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for sampled + /// audio, video and vector, but BEATS for MIDI. Anything doing arithmetic against a trim value — + /// mapping a timeline position into the clip's content, say — has to work in that same domain, + /// and [`Self::get_clip_duration`] can't tell it which: that one always converts to seconds. /// /// Returns `None` for unknown clips. pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option { @@ -930,6 +932,29 @@ impl Document { self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds) } + /// Resolve a [`ContentTime`] (a trim bound) against the clip it belongs to. + /// + /// The clip is the only thing that knows whether its content is measured in seconds or beats, so + /// this is the sanctioned exit from `ContentTime`. Works for every clip kind, not just audio. + /// Returns `None` for unknown clips. + pub fn resolve_content_time( + &self, + clip_id: &Uuid, + t: daw_backend::ContentTime, + ) -> Option { + if let Some(clip) = self.audio_clips.get(clip_id) { + return Some(clip.resolve_content_time(t)); + } + if self.vector_clips.contains_key(clip_id) + || self.video_clips.contains_key(clip_id) + || self.effect_definitions.contains_key(clip_id) + { + // Wall-clock content. + return Some(crate::clip::ClipDuration::Seconds(Seconds(t.raw()))); + } + None + } + pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option { if let Some(clip) = self.vector_clips.get(clip_id) { if clip.is_group { @@ -983,9 +1008,9 @@ impl Document { }; let instance = instances.iter().find(|inst| &inst.id == instance_id)?; - let clip_duration = self.get_clip_duration(&instance.clip_id)?; - // End position on the timeline, in beats (convert the seconds content window via tempo map). - Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map())) + // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI. + let clip_content = self.clip_trim_duration(&instance.clip_id)?; + Some(instance.timeline_start + instance.effective_duration_beats(clip_content, self.tempo_map())) } /// Check if a time range overlaps with any existing clip on the layer @@ -1025,13 +1050,14 @@ impl Document { continue; } - // Calculate instance extent (accounting for loop_before) - let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { + // Calculate instance extent (accounting for loop_before). Content duration in the clip's + // own domain, so the trims resolve correctly for MIDI. + let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else { continue; }; let instance_start = instance.effective_start(); - let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map()); + let instance_end = instance.timeline_start + instance.effective_duration(clip_content, self.tempo_map()); // Check overlap: start_a < end_b AND start_b < end_a if start_time < instance_end && instance_start < end_time { @@ -1088,7 +1114,7 @@ impl Document { continue; } - if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) { + if let Some(clip_dur) = self.clip_trim_duration(&instance.clip_id) { let inst_start = instance.effective_start(); let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map()); occupied_ranges.push((inst_start, inst_end, instance.id)); @@ -1184,7 +1210,7 @@ impl Document { if group_ids.contains(&inst.id) { continue; } - if let Some(dur) = self.get_clip_duration(&inst.clip_id) { + if let Some(dur) = self.clip_trim_duration(&inst.clip_id) { let start = inst.effective_start(); let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map()); non_group.push((start, end)); @@ -1258,9 +1284,8 @@ impl Document { } // Calculate other clip's extent (accounting for loop_before) - if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { + if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) { let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); - // (clip_duration is Seconds via get_clip_duration; effective_duration converts.) // If this clip is to the left and closer than current nearest if other_end <= current_timeline_start && other_end > nearest_end { @@ -1358,7 +1383,7 @@ impl Document { continue; } - if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { + if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) { let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); if other_end <= current_effective_start && other_end > nearest_end { diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs index 3d52675..a6f762a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs @@ -152,7 +152,12 @@ impl EffectLayer { self.clip_instances .iter() .filter(|e| { - let end = e.timeline_start + e.effective_duration(daw_backend::Seconds(EFFECT_DURATION), tempo_map); + // Effects have an "infinite" wall-clock content length. + let end = e.timeline_start + + e.effective_duration( + crate::clip::ClipDuration::Seconds(daw_backend::Seconds(EFFECT_DURATION)), + tempo_map, + ); time_beats >= e.timeline_start && time_beats < end }) .collect() diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index 9cc7a92..e9d9f39 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -3,7 +3,7 @@ //! Provides functions for testing if points or rectangles intersect with //! vector graph elements and clip instances, taking into account transform hierarchies. -use crate::clip::ClipInstance; +use crate::clip::{ClipDuration, ClipInstance}; use crate::vector_graph::{VertexId, EdgeId, FillId}; use crate::layer::VectorLayer; use crate::shape::Shape; @@ -260,7 +260,10 @@ pub fn hit_test_clip_instances( 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(daw_backend::Seconds::ZERO); + // Hit-testing runs on vector/raster content, which is wall-clock seconds. + let clip_duration = ClipDuration::Seconds( + document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO), + ); let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time)); if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { @@ -269,7 +272,8 @@ pub fn hit_test_clip_instances( // clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds) let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = + ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { vector_clip.calculate_content_bounds(document, clip_time) @@ -304,7 +308,10 @@ pub fn hit_test_clip_instances_in_rect( 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(daw_backend::Seconds::ZERO); + // Hit-testing runs on vector/raster content, which is wall-clock seconds. + let clip_duration = ClipDuration::Seconds( + document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO), + ); let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time)); if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { @@ -312,7 +319,8 @@ pub fn hit_test_clip_instances_in_rect( } let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = + ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); 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/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 9c2cbd8..72df6cd 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -9,7 +9,7 @@ //! The compositing mode enables proper per-layer opacity, blend modes, and effects. use crate::animation::TransformProperty; -use crate::clip::{ClipInstance, ImageAsset}; +use crate::clip::{ClipDuration, ClipInstance, ImageAsset}; use crate::document::Document; use daw_backend::Seconds; use crate::gpu::BlendMode; @@ -568,7 +568,7 @@ pub fn render_layer_isolated( 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(Seconds(time), Seconds(video_clip.duration), tempo_map) else { continue }; + let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else { continue }; let clip_time = clip_time.seconds_to_f64(); let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue }; @@ -1019,7 +1019,10 @@ fn render_clip_instance( } 0.0 } else { - let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); + // A vector clip's content is wall-clock seconds. + let clip_dur = ClipDuration::Seconds( + document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)), + ); let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return; // Clip instance not active at this time }; @@ -1174,7 +1177,7 @@ fn render_video_layer( // Remap timeline time to clip's internal time let tempo_map = document.tempo_map(); - let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { + let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else { continue; // Clip instance not active at this time }; let clip_time = clip_time.seconds_to_f64(); @@ -1910,7 +1913,10 @@ fn render_clip_instance_cpu( if time < start_secs || time >= end { return; } 0.0 } else { - let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); + // A vector clip's content is wall-clock seconds. + let clip_dur = ClipDuration::Seconds( + document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)), + ); let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return }; t.seconds_to_f64() }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index bea0759..cde1545 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -1064,7 +1064,12 @@ fn composite_document_to_hdr( } let tempo_map = document.tempo_map(); let effect_end_beats = effect_instance.timeline_start - + effect_instance.effective_duration(daw_backend::Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map); + + effect_instance.effective_duration( + lightningbeam_core::clip::ClipDuration::Seconds(daw_backend::Seconds( + lightningbeam_core::effect::EFFECT_DURATION, + )), + tempo_map, + ); let effect_inst = lightningbeam_core::effect::EffectInstance::new( effect_def, tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 6a35973..33adcfd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2387,7 +2387,7 @@ impl EditorApp { ) -> Vec { let mut result = Vec::new(); for instance in clip_instances { - if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) { + if let Some(clip_duration) = document.clip_trim_duration(&instance.clip_id) { let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let timeline_end = instance.timeline_start + effective_duration; @@ -3276,7 +3276,9 @@ impl EditorApp { let duplicates: Vec = clips_to_duplicate.iter().map(|original| { let mut duplicate = original.clone(); duplicate.id = uuid::Uuid::new_v4(); - let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(Seconds(1.0)); + let clip_duration = document + .clip_trim_duration(&original.clip_id) + .unwrap_or(ClipDuration::Seconds(Seconds(1.0))); 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) { @@ -5477,7 +5479,7 @@ impl EditorApp { // matches the video clip exactly). let (_dur, sample_rate, channels) = controller .get_pool_file_info(pool_index) - .unwrap_or((video_duration, 0, 0)); + .unwrap_or((Seconds(video_duration), 0, 0)); drop(controller); let audio_clip_name = format!("{} (Audio)", video_name); @@ -6384,7 +6386,9 @@ impl eframe::App for EditorApp { use daw_backend::AudioEvent; match event { AudioEvent::PlaybackPosition(time) => { - self.playback_time = time; + // `playback_time` is the UI's seconds playhead (see the timeline's + // seconds/beats model); unwrap at this boundary, not before it. + self.playback_time = time.seconds_to_f64(); } AudioEvent::PlaybackStopped => { self.is_playing = false; @@ -6595,8 +6599,11 @@ impl eframe::App for EditorApp { if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) { inst.timeline_start = loop_start; inst.timeline_duration = None; - inst.trim_start = 0.0; - inst.trim_end = Some(loop_len_seconds.seconds_to_f64()); + // Audio take content is seconds, and each take spans + // exactly one cycle region. + inst.trim_start = daw_backend::ContentTime::ZERO; + inst.trim_end = + Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64())); inst.active_take = Some(last_take); } } @@ -6655,6 +6662,7 @@ impl eframe::App for EditorApp { let mut controller = controller_arc.lock().unwrap(); match controller.get_pool_file_info(pool_index) { Ok((dur, _, _)) => { + let dur = dur.seconds_to_f64(); eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur); self.audio_duration_cache.insert(pool_index, dur); dur @@ -6688,7 +6696,7 @@ impl eframe::App for EditorApp { None } }) - .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, 0.0)) + .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, daw_backend::ContentTime::ZERO)) }; if !clip_id.is_nil() { @@ -6850,7 +6858,7 @@ impl eframe::App for EditorApp { .map(|(id, _)| id); 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) { - clip.set_content_duration(ClipDuration::Beats(Beats(midi_clip_data.duration))); + clip.set_content_duration(ClipDuration::Beats(midi_clip_data.duration)); clip.name = format!("MIDI Recording {}", clip_id); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 74f0d78..0df4edf 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -1612,8 +1612,12 @@ impl InfopanelPane { ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64())); }); - let clip_dur = document.get_clip_duration(&ci.clip_id) - .unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); + let clip_dur = document.clip_trim_duration(&ci.clip_id) + .unwrap_or_else(|| lightningbeam_core::clip::ClipDuration::Seconds( + daw_backend::Seconds( + (ci.trim_end.unwrap_or(daw_backend::ContentTime(1.0)) - ci.trim_start).raw(), + ), + )); let total_dur = ci.total_duration(clip_dur, document.tempo_map()); let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur) - document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64(); @@ -1622,10 +1626,16 @@ impl InfopanelPane { ui.label(format!("{:.2}s", total_dur_secs)); }); - if ci.trim_start > 0.0 { + if ci.trim_start > daw_backend::ContentTime::ZERO { ui.horizontal(|ui| { ui.label("Trim Start:"); - ui.label(format!("{:.2}s", ci.trim_start)); + // Content time in the clip's own domain: seconds for sampled + // audio/video/vector, beats for MIDI. Label it accordingly. + let unit = match clip_dur { + lightningbeam_core::clip::ClipDuration::Beats(_) => "beats", + lightningbeam_core::clip::ClipDuration::Seconds(_) => "s", + }; + ui.label(format!("{:.2}{}", ci.trim_start.raw(), unit)); }); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 2505ef3..fa3cd2e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -466,8 +466,10 @@ 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.content_duration().to_seconds(document.tempo_map()), document.tempo_map()); - clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id)); + let duration = instance.effective_duration(clip.content_duration(), document.tempo_map()); + // A MIDI clip's content time IS beats, which is what the piano roll's + // x-axis uses. + clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), instance.id)); } } } @@ -2463,7 +2465,8 @@ impl PianoRollPane { }); // Get sample rate from raw_audio_cache if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) { - clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), *sr)); + // A sampled clip's content time is seconds. + clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), *sr)); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 73dd94d..2b0854a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -6,7 +6,7 @@ use eframe::egui; use daw_backend::Seconds; use lightningbeam_core::action::Action; -use lightningbeam_core::clip::ClipInstance; +use lightningbeam_core::clip::{ClipDuration, ClipInstance}; use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter}; use lightningbeam_core::layer::{AnyLayer, AudioLayer}; use lightningbeam_core::renderer::RenderedLayerType; @@ -1854,7 +1854,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // For now, create a simple effect instance with default parameters let tempo_map = self.ctx.document.tempo_map(); let effect_end_beats = effect_instance.timeline_start - + effect_instance.effective_duration(Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map); + + effect_instance.effective_duration( + ClipDuration::Seconds(Seconds(lightningbeam_core::effect::EFFECT_DURATION)), + tempo_map, + ); let effect_inst = lightningbeam_core::effect::EffectInstance::new( effect_def, tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), @@ -2209,7 +2212,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Calculate clip bounds for preview let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64(); - let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start; + let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start.raw(); let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) { vector_clip.calculate_content_bounds(&self.ctx.document, clip_time) } else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) { @@ -2299,7 +2302,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { for &clip_id in self.ctx.selection.clip_instances() { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { // Skip clip instances not active at current time (compare in seconds). - let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO); + let clip_dur = ClipDuration::Seconds( + self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO), + ); let tempo_map = self.ctx.document.tempo_map(); let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let instance_end = tempo_map.beats_to_seconds( @@ -2310,7 +2315,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } // Calculate clip-local time - let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Get dynamic clip bounds from content at current time let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) { @@ -2680,7 +2685,9 @@ 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(Seconds::ZERO); + let clip_duration = ClipDuration::Seconds( + self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO), + ); let tempo_map = self.ctx.document.tempo_map(); let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64(); @@ -10142,7 +10149,7 @@ impl StagePane { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { // Calculate clip-local time let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Get dynamic clip bounds from content at current time use vello::kurbo::Rect as KurboRect; @@ -10343,7 +10350,7 @@ impl StagePane { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) { // Calculate clip-local time let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Get dynamic clip bounds from content at current time let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) { @@ -11059,7 +11066,9 @@ impl StagePane { let document = shared.action_executor.document(); 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(Seconds::ZERO); + let clip_duration = ClipDuration::Seconds( + document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO), + ); let tempo_map = document.tempo_map(); let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 03eb16d..798180c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -7,8 +7,8 @@ /// - Basic layer visualization use eframe::egui; -use daw_backend::{Beats, Seconds}; -use lightningbeam_core::clip::ClipInstance; +use daw_backend::{Beats, ContentTime, Seconds}; +use lightningbeam_core::clip::{ClipDuration, ClipInstance}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait}; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; @@ -89,7 +89,7 @@ fn compute_clip_stacking( let tempo_map = document.tempo_map(); // Stacking only needs relative overlap, so compare in the beats domain. let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| { - let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO); + let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO)); let start = ci.effective_start(); let end = start + ci.total_duration(clip_dur, tempo_map); (start.beats_to_f64(), end.beats_to_f64()) @@ -203,11 +203,14 @@ fn draw_video_thumbnail_strip( /// Get the effective clip duration for a clip instance on a given layer. /// For groups on vector layers, the duration spans all consecutive keyframes /// where the group is present. For regular clips, returns the clip's internal duration. +/// A clip's content duration **in its own domain** — seconds for vector/video/effect and sampled +/// audio, BEATS for MIDI. Returning a `ClipDuration` rather than bare `Seconds` is what lets the +/// instance's trim bounds (which are content times, in that same domain) be resolved correctly. fn effective_clip_duration( document: &lightningbeam_core::document::Document, layer: &AnyLayer, clip_instance: &ClipInstance, -) -> Option { +) -> Option { match layer { AnyLayer::Vector(vl) => { let vc = document.get_vector_clip(&clip_instance.clip_id)?; @@ -215,17 +218,21 @@ fn effective_clip_duration( let frame_duration = 1.0 / document.framerate; let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration); - Some(Seconds((end - start_secs).max(0.0))) + Some(ClipDuration::Seconds(Seconds((end - start_secs).max(0.0)))) } else { // Movie clips: duration based on all internal content (keyframes + clip instances) - document.get_clip_duration(&clip_instance.clip_id) + document.get_clip_duration(&clip_instance.clip_id).map(ClipDuration::Seconds) } } - // Delegate to get_clip_duration so MIDI clips (whose `duration` is stored in beats, - // not seconds) are converted correctly rather than read as raw seconds. - AnyLayer::Audio(_) => document.get_clip_duration(&clip_instance.clip_id), - AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)), - AnyLayer::Effect(_) => Some(Seconds(lightningbeam_core::effect::EFFECT_DURATION)), + // An audio layer can hold a sampled clip (seconds content) or a MIDI clip (beats content), + // so ask the clip which it is rather than flattening both to seconds. + AnyLayer::Audio(_) => document.clip_trim_duration(&clip_instance.clip_id), + AnyLayer::Video(_) => document + .get_video_clip(&clip_instance.clip_id) + .map(|c| ClipDuration::Seconds(Seconds(c.duration))), + AnyLayer::Effect(_) => Some(ClipDuration::Seconds(Seconds( + lightningbeam_core::effect::EFFECT_DURATION, + ))), AnyLayer::Group(_) => None, AnyLayer::Raster(_) => None, AnyLayer::Text(_) => None, @@ -859,7 +866,9 @@ impl TimelinePane { .unwrap_or_default() .iter() .map(|k| crate::curve_editor::CurvePoint { - time: k.time, // beats (backend stores beats; curve editor x-axis is beats) + // The curve editor's x-axis is beats, same as the backend — unwrap at this + // boundary because CurvePoint stores a plain f64. + time: k.time.beats_to_f64(), value: k.value, interpolation: match k.interpolation.as_str() { "bezier" => crate::curve_editor::CurveInterpolation::Bezier, @@ -1441,8 +1450,10 @@ impl TimelinePane { 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(|| { - Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) + let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| { + ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + )) }); let start = ci.effective_start(); let end = start + ci.total_duration(clip_dur, tempo_map); @@ -1756,15 +1767,18 @@ impl TimelinePane { /// 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: Seconds, tempo_map: &daw_backend::TempoMap) -> f64 { - (tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) + fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, tempo_map: &daw_backend::TempoMap) -> f64 { + (tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_content, tempo_map)) - tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64() } - /// 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: Seconds, _bpm: f64) -> (f64, f64) { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs.seconds_to_f64()); - (ci.trim_start, (trim_end - ci.trim_start).max(0.0)) + /// The clip's content start (trim_start) and window length, as raw magnitudes in the clip's own + /// content domain — seconds for audio/video/vector, beats for MIDI. The drag/preview math below + /// works in that domain throughout, converting to the timeline only at the edges. + fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, _bpm: f64) -> (f64, f64) { + let trim_end = ci.trim_end.map_or(clip_content.native(), |t| t.raw()); + let start = ci.trim_start.raw(); + (start, (trim_end - start).max(0.0)) } /// Convert pixel x-coordinate to time (seconds) @@ -3255,8 +3269,10 @@ impl TimelinePane { let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move); let mut ranges: Vec<(Beats, Beats)> = Vec::new(); for (_child_layer_id, ci) in &child_clips { - let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { - Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) + let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| { + ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + )) }); let mut start = ci.effective_start(); let dur = ci.total_duration(clip_dur, document.tempo_map()); @@ -3329,8 +3345,10 @@ impl TimelinePane { if let Some(video_child) = g.children.iter().find(|c| matches!(c, AnyLayer::Video(_))) { if let AnyLayer::Video(vl) = video_child { for ci in &vl.clip_instances { - let clip_dur = document.get_clip_duration(&ci.clip_id) - .unwrap_or_else(|| Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); + let clip_dur = document.clip_trim_duration(&ci.clip_id) + .unwrap_or_else(|| ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + ))); let mut ci_start = ci.effective_start(); if is_move_drag && selection.contains_clip_instance(&ci.id) { ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate); @@ -3357,7 +3375,8 @@ impl TimelinePane { ); // 4th elem = clip's TRUE (unclamped) origin x, for correct // hover content time when scrolled partly off the left. - video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, rect.min.x + sx)); + // Video content is wall-clock, so its content time IS seconds. + video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start.raw(), rect.min.x + sx)); let thumb_display_height = (thumb_y_max - span_y_min) - 4.0; if thumb_display_height > 8.0 { @@ -3370,7 +3389,7 @@ impl TimelinePane { &video_mgr, &mut self.video_thumbnail_textures, ci.clip_id, - ci.trim_start, + ci.trim_start.raw(), rect.min.x + sx, ex - sx, ci_rect, @@ -3419,7 +3438,7 @@ impl TimelinePane { }; let audio_file_duration = total_frames as f64 / eff_sr as f64; - let clip_dur = audio_clip.content_duration().to_seconds(document.tempo_map()); + let clip_dur = audio_clip.content_duration(); let mut ci_start = ci.effective_start(); if is_move_drag && selection.contains_clip_instance(&ci.id) { ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate); @@ -3470,7 +3489,8 @@ impl TimelinePane { audio_duration: audio_file_duration as f32, sample_rate: eff_sr, clip_start_time: ci_screen_start, - trim_start: ci.trim_start as f32, + // A sampled clip's content time is seconds. + trim_start: ci.trim_start.raw() as f32, tex_width: crate::waveform_gpu::tex_width() as f32, total_frames: total_frames as f32, segment_start_frame: 0.0, @@ -3553,7 +3573,7 @@ impl TimelinePane { let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter() .filter(|ci| selection.contains_clip_instance(&ci.id)) .filter_map(|ci| { - let dur = document.get_clip_duration(&ci.clip_id)?; + let dur = document.clip_trim_duration(&ci.clip_id)?; Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map()))) }) .collect(); @@ -3577,8 +3597,11 @@ impl TimelinePane { let shift_beats = |anchor: Beats, secs: f64| tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs)); - let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO); - let clip_dur_secs = clip_dur.seconds_to_f64(); + let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO)); + // Raw magnitudes in the clip's own content domain — the drag math below stays in + // that domain and only converts at the timeline edges. + let clip_dur_secs = clip_dur.native(); + let ci_trim_start = ci.trim_start.raw(); let mut start = ci.effective_start(); let mut duration = ci.total_duration(clip_dur, tmap); @@ -3600,25 +3623,25 @@ impl TimelinePane { } } ClipDragType::TrimLeft => { - let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs); - let trim_offset_secs = new_trim - ci.trim_start; + let new_trim = self.snap_to_grid(ci_trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs); + let trim_offset_secs = new_trim - ci_trim_start; start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO); let dur_secs = if let Some(trim_end) = ci.trim_end { - (trim_end - new_trim).max(0.0) + (trim_end.raw() - new_trim).max(0.0) } else { (clip_dur_secs - new_trim).max(0.0) }; duration = secs_to_beats_at(start, dur_secs); } ClipDragType::TrimRight => { - let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci.trim_start).min(clip_dur_secs); - let dur_secs = (new_trim_end - ci.trim_start).max(0.0); + let old_trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw()); + let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci_trim_start).min(clip_dur_secs); + let dur_secs = (new_trim_end - ci_trim_start).max(0.0); duration = secs_to_beats_at(start, dur_secs); } ClipDragType::LoopExtendRight => { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let content_window_secs = (trim_end - ci.trim_start).max(0.0); + let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.0); let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs); let current_right = ci.timeline_duration.unwrap_or(content_window); let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset; @@ -3629,8 +3652,8 @@ impl TimelinePane { duration = loop_before + new_right; } ClipDragType::LoopExtendLeft => { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let content_window_secs = (trim_end - ci.trim_start).max(0.001); + let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.001); let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs); let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO); // drag_offset (seconds) as a beats delta at this clip's start. @@ -3691,6 +3714,9 @@ 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()); + // The instance's trim start as a raw magnitude in the clip's content domain; the + // preview math below stays in that domain. + let ci_trim_start = clip_instance.trim_start.raw(); let mut preview_trim_start = base_trim_start; let mut preview_clip_duration = base_clip_duration; @@ -3706,11 +3732,11 @@ 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.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) + let desired_trim_start = self.snap_to_grid(ci_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) .max(0.0) - .min(clip_duration.seconds_to_f64()); + .min(clip_duration.native()); - let new_trim_start = if desired_trim_start < clip_instance.trim_start { + let new_trim_start = if desired_trim_start < ci_trim_start { // Extending left - limit is the content-seconds gap to the previous clip. let max_extend_secs = document.find_max_trim_extend_left( &layer.id(), @@ -3718,25 +3744,25 @@ impl TimelinePane { clip_instance.effective_start(), ).seconds_to_f64(); - let desired_extend = clip_instance.trim_start - desired_trim_start; + let desired_extend = ci_trim_start - desired_trim_start; let actual_extend = desired_extend.min(max_extend_secs); - clip_instance.trim_start - actual_extend + ci_trim_start - actual_extend } else { // Shrinking - no snap needed desired_trim_start }; - let actual_offset = new_trim_start - clip_instance.trim_start; + let actual_offset = new_trim_start - ci_trim_start; // Move start (display seconds) and reduce duration by the clamped offset. instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset) .max(0.0); - instance_duration = (clip_duration.seconds_to_f64() - new_trim_start).max(0.0); + instance_duration = (clip_duration.native() - new_trim_start).max(0.0); // Adjust for existing trim_end if let Some(trim_end) = clip_instance.trim_end { - instance_duration = (trim_end - new_trim_start).max(0.0); + instance_duration = (trim_end.raw() - new_trim_start).max(0.0); } // Update preview trim for waveform rendering @@ -3745,14 +3771,14 @@ 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.seconds_to_f64()); + let old_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) - .max(clip_instance.trim_start) - .min(clip_duration.seconds_to_f64()); + .max(ci_trim_start) + .min(clip_duration.native()); let new_trim_end = if desired_trim_end > old_trim_end { // Extending right - limit is the content-seconds gap to the next clip. - let current_duration_secs = old_trim_end - clip_instance.trim_start; + let current_duration_secs = old_trim_end - ci_trim_start; let tmap = document.tempo_map(); let current_duration = tmap.seconds_to_beats( tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs) @@ -3772,7 +3798,7 @@ impl TimelinePane { desired_trim_end }; - instance_duration = (new_trim_end - clip_instance.trim_start).max(0.0); + instance_duration = (new_trim_end - ci_trim_start).max(0.0); // Update preview clip duration for waveform rendering // (the waveform system uses clip_duration to determine visible range) @@ -3780,8 +3806,8 @@ impl TimelinePane { } ClipDragType::LoopExtendRight => { // Loop extend right: extend clip beyond content window - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); + let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.0); let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; // content window and right-duration are beats-domain timeline spans. @@ -3816,8 +3842,8 @@ impl TimelinePane { ClipDragType::LoopExtendLeft => { // Loop extend left: extend loop_before (pre-loop region) // Snap to multiples of content_window so iterations align with backend - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); + let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.001); let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; // content window is a beats-domain span; guard against zero for division. @@ -4057,7 +4083,7 @@ impl TimelinePane { // Calculate content window for loop detection // Use trimmed content window (preview_trim_start accounts for TrimLeft drag) - let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); + let preview_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); let content_window = (preview_trim_end - preview_trim_start).max(0.0); let is_looping = instance_duration > content_window + 0.001; @@ -4254,7 +4280,7 @@ impl TimelinePane { &video_mgr, &mut self.video_thumbnail_textures, clip_instance.clip_id, - clip_instance.trim_start, + clip_instance.trim_start.raw(), rect.min.x + start_x, end_x - start_x, clip_rect, @@ -4269,7 +4295,7 @@ impl TimelinePane { // clip's TRUE (unclamped) origin x so the hover content time is // correct even when the clip is scrolled partly off the left. if let lightningbeam_core::layer::AnyLayer::Video(_) = layer { - video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, rect.min.x + start_x)); + video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start.raw(), rect.min.x + start_x)); } // Draw border per segment (per loop iteration for looping clips) @@ -5009,18 +5035,23 @@ impl TimelinePane { for clip_instance in clip_instances { if selection.contains_clip_instance(&clip_instance.id) { let clip_duration = effective_clip_duration(document, layer, clip_instance); + // Raw magnitude in the clip's content domain; re-tagged as a + // ContentTime when it goes back into TrimData. + let ci_trim_start = clip_instance.trim_start.raw(); if let Some(clip_duration) = clip_duration { match drag_type { ClipDragType::TrimLeft => { - let old_trim_start = clip_instance.trim_start; + // Raw magnitude in the clip's content domain; re-tagged as a + // ContentTime when it goes back into TrimData below. + let old_trim_start = clip_instance.trim_start.raw(); let old_timeline_start = clip_instance.timeline_start; // 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.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE, - ).max(0.0).min(clip_duration.seconds_to_f64()); + ).max(0.0).min(clip_duration.native()); // Apply overlap prevention when extending left (content-seconds gap). let new_trim_start = if desired_trim_start < old_trim_start { @@ -5050,11 +5081,11 @@ impl TimelinePane { clip_instance.id, lightningbeam_core::actions::TrimType::TrimLeft, lightningbeam_core::actions::TrimData::left( - old_trim_start, + ContentTime(old_trim_start), old_timeline_start, ), lightningbeam_core::actions::TrimData::left( - new_trim_start, + ContentTime(new_trim_start), new_timeline_start, ), )); @@ -5065,10 +5096,10 @@ impl TimelinePane { // Calculate new trim_end based on current duration let current_duration = clip_instance.effective_duration(clip_duration, document.tempo_map()); - let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); + let old_trim_end_val = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); let desired_trim_end = self.snap_to_grid( old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE, - ).max(clip_instance.trim_start).min(clip_duration.seconds_to_f64()); + ).max(ci_trim_start).min(clip_duration.native()); // Apply overlap prevention when extending right (content-seconds gap). let new_trim_end_val = if desired_trim_end > old_trim_end_val { @@ -5085,13 +5116,15 @@ impl TimelinePane { desired_trim_end }; - let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0); + let new_duration = (new_trim_end_val - ci_trim_start).max(0.0); // Convert new duration back to trim_end value - let new_trim_end = if new_duration >= clip_duration.seconds_to_f64() { + let new_trim_end = if new_duration >= clip_duration.native() { None // Use full clip duration } else { - Some((clip_instance.trim_start + new_duration).min(clip_duration.seconds_to_f64())) + Some(ContentTime( + (ci_trim_start + new_duration).min(clip_duration.native()), + )) }; layer_trims @@ -5143,8 +5176,9 @@ impl TimelinePane { if let Some(clip_duration) = clip_duration { let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); + let ci_trim_start = clip_instance.trim_start.raw(); + let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.0); let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts; let current_right = clip_instance.timeline_duration.unwrap_or(content_window); // Snap the right edge in the seconds/pixel domain. @@ -5217,8 +5251,9 @@ impl TimelinePane { if let Some(clip_duration) = clip_duration { let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); + let ci_trim_start = clip_instance.trim_start.raw(); + let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.001); let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts; let cw = content_window.beats_to_f64().max(1e-9); let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO); @@ -6461,7 +6496,7 @@ impl PaneRenderer for TimelinePane { let instances = layer_clips(layer); for inst in instances { if !shared.selection.contains_clip_instance(&inst.id) { continue; } - if let Some(dur) = document.get_clip_duration(&inst.clip_id) { + if let Some(dur) = document.clip_trim_duration(&inst.clip_id) { let eff = inst.effective_duration(dur, document.tempo_map()); let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64(); let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64(); @@ -6487,7 +6522,7 @@ impl PaneRenderer for TimelinePane { enabled = instances.iter() .filter(|ci| shared.selection.contains_clip_instance(&ci.id)) .all(|ci| { - if let Some(dur) = document.get_clip_duration(&ci.clip_id) { + if let Some(dur) = document.clip_trim_duration(&ci.clip_id) { let eff = ci.effective_duration(dur, document.tempo_map()); // Room to duplicate = seconds gap to the right ≥ this clip's own length. let max_extend_secs = document.find_max_trim_extend_right( @@ -6532,7 +6567,7 @@ impl PaneRenderer for TimelinePane { enabled = instances.iter().all(|ci| { let paste_start = (ci.timeline_start + offset).max(Beats::ZERO); - if let Some(dur) = document.get_clip_duration(&ci.clip_id) { + if let Some(dur) = document.clip_trim_duration(&ci.clip_id) { let eff = ci.effective_duration(dur, document.tempo_map()); document .find_nearest_valid_position(