Massive tempo refactor - make beats canonical time rep and allow them to be non constant

This commit is contained in:
Skyler Lehmkuhl 2026-04-02 10:26:01 -04:00
parent ae146533d9
commit f372a84313
48 changed files with 1334 additions and 2136 deletions

View File

@ -1,5 +1,6 @@
/// Automation system for parameter modulation over time
use serde::{Deserialize, Serialize};
use crate::time::Beats;
/// Unique identifier for automation lanes
pub type AutomationLaneId = u32;
@ -35,17 +36,13 @@ pub enum CurveType {
/// A single automation point
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AutomationPoint {
/// Time in seconds
pub time: f64,
/// Parameter value (normalized 0.0 to 1.0, or actual value depending on parameter)
pub time: Beats,
pub value: f32,
/// Curve type to next point
pub curve: CurveType,
}
impl AutomationPoint {
/// Create a new automation point
pub fn new(time: f64, value: f32, curve: CurveType) -> Self {
pub fn new(time: Beats, value: f32, curve: CurveType) -> Self {
Self { time, value, curve }
}
}
@ -93,8 +90,7 @@ impl AutomationLane {
}
}
/// Remove point at specific time
pub fn remove_point_at_time(&mut self, time: f64, tolerance: f64) -> bool {
pub fn remove_point_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
if let Some(idx) = self.points.iter().position(|p| (p.time - time).abs() < tolerance) {
self.points.remove(idx);
true
@ -113,8 +109,7 @@ impl AutomationLane {
&self.points
}
/// Get value at a specific time with interpolation
pub fn evaluate(&self, time: f64) -> Option<f32> {
pub fn evaluate(&self, time: Beats) -> Option<f32> {
if !self.enabled || self.points.is_empty() {
return None;
}
@ -148,14 +143,12 @@ impl AutomationLane {
}
}
/// Interpolate between two automation points based on curve type
fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: f64) -> f32 {
// Calculate normalized position between points (0.0 to 1.0)
fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: Beats) -> f32 {
let t = if p2.time == p1.time {
0.0
0.0f64
} else {
((time - p1.time) / (p2.time - p1.time)) as f32
};
(time - p1.time) / (p2.time - p1.time)
} as f32;
// Apply curve
let curved_t = match p1.curve {

View File

@ -1,5 +1,7 @@
use std::sync::Arc;
use serde::{Serialize, Deserialize};
use crate::time::{Beats, Seconds};
use crate::tempo_map::TempoMap;
/// Audio clip instance ID type
pub type AudioClipInstanceId = u32;
@ -9,47 +11,31 @@ pub type ClipId = AudioClipInstanceId;
/// Audio clip instance that references content in the AudioClipPool
///
/// This represents a placed instance of audio content on the timeline.
/// The actual audio data is stored in the AudioClipPool and referenced by `audio_pool_index`.
///
/// ## Timing Model
/// - `internal_start` / `internal_end`: Define the region of the source audio to play (trimming)
/// - `external_start` / `external_duration`: Define where the clip appears on the timeline and how long
/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display
/// - `internal_start` / `internal_end`: Region of the source audio to play (seconds — audio file seek positions)
/// - `external_start` / `external_duration`: Where the clip appears on the timeline (**beats**)
///
/// ## Looping
/// If `external_duration` is greater than `internal_end - internal_start`,
/// the clip will seamlessly loop back to `internal_start` when it reaches `internal_end`.
/// If `external_duration_secs(bpm)` > `internal_end - internal_start`, the clip loops.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioClipInstance {
pub id: AudioClipInstanceId,
pub audio_pool_index: usize,
/// Start position within the audio content (seconds)
pub internal_start: f64,
#[serde(default)] pub internal_start_beats: f64,
#[serde(default)] pub internal_start_frames: f64,
/// End position within the audio content (seconds)
pub internal_end: f64,
#[serde(default)] pub internal_end_beats: f64,
#[serde(default)] pub internal_end_frames: f64,
/// Start position within the audio content
pub internal_start: Seconds,
/// End position within the audio content
pub internal_end: Seconds,
/// Start position on the timeline (seconds)
pub external_start: f64,
#[serde(default)] pub external_start_beats: f64,
#[serde(default)] pub external_start_frames: f64,
/// Duration on the timeline (seconds) - can be longer than internal duration for looping
pub external_duration: f64,
#[serde(default)] pub external_duration_beats: f64,
#[serde(default)] pub external_duration_frames: f64,
/// Start position on the timeline
pub external_start: Beats,
/// Duration on the timeline
pub external_duration: Beats,
/// Clip-level gain
pub gain: f32,
/// Per-instance read-ahead buffer for compressed audio streaming.
/// Each clip instance gets its own buffer so multiple instances of the
/// same file (on different tracks or at different positions) don't fight
/// over a single target_frame.
#[serde(skip)]
pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>,
}
@ -58,154 +44,71 @@ pub struct AudioClipInstance {
pub type Clip = AudioClipInstance;
impl AudioClipInstance {
/// Create a new audio clip instance
pub fn new(
id: AudioClipInstanceId,
audio_pool_index: usize,
internal_start: f64,
internal_end: f64,
external_start: f64,
external_duration: f64,
internal_start: Seconds,
internal_end: Seconds,
external_start: Beats,
external_duration: Beats,
) -> Self {
Self {
id,
audio_pool_index,
internal_start,
internal_start_beats: 0.0,
internal_start_frames: 0.0,
internal_end,
internal_end_beats: 0.0,
internal_end_frames: 0.0,
external_start,
external_start_beats: 0.0,
external_start_frames: 0.0,
external_duration,
external_duration_beats: 0.0,
external_duration_frames: 0.0,
gain: 1.0,
read_ahead: None,
}
}
/// Create a clip instance from legacy parameters (for backwards compatibility)
/// Maps old start_time/duration/offset to new timing model
pub fn from_legacy(
id: AudioClipInstanceId,
audio_pool_index: usize,
start_time: f64,
duration: f64,
offset: f64,
) -> Self {
Self {
id,
audio_pool_index,
internal_start: offset,
internal_start_beats: 0.0,
internal_start_frames: 0.0,
internal_end: offset + duration,
internal_end_beats: 0.0,
internal_end_frames: 0.0,
external_start: start_time,
external_start_beats: 0.0,
external_start_frames: 0.0,
external_duration: duration,
external_duration_beats: 0.0,
external_duration_frames: 0.0,
gain: 1.0,
read_ahead: None,
}
}
/// Check if this clip instance is active at a given timeline position
pub fn is_active_at(&self, time_seconds: f64) -> bool {
time_seconds >= self.external_start && time_seconds < self.external_end()
}
/// Get the end time of this clip instance on the timeline
pub fn external_end(&self) -> f64 {
pub fn external_end(&self) -> Beats {
self.external_start + self.external_duration
}
/// Get the end time of this clip instance on the timeline
/// (Alias for external_end(), for backwards compatibility)
pub fn end_time(&self) -> f64 {
self.external_end()
pub fn external_start_secs(&self, tempo_map: &TempoMap) -> Seconds {
tempo_map.beats_to_seconds(self.external_start)
}
/// Get the start time on the timeline
/// (Alias for external_start, for backwards compatibility)
pub fn start_time(&self) -> f64 {
self.external_start
pub fn external_end_secs(&self, tempo_map: &TempoMap) -> Seconds {
tempo_map.beats_to_seconds(self.external_end())
}
/// Get the internal (content) duration
pub fn internal_duration(&self) -> f64 {
pub fn external_duration_secs(&self, tempo_map: &TempoMap) -> Seconds {
tempo_map.beats_to_seconds(self.external_end()) - tempo_map.beats_to_seconds(self.external_start)
}
pub fn is_active_at(&self, time: Seconds, tempo_map: &TempoMap) -> bool {
time >= self.external_start_secs(tempo_map) && time < self.external_end_secs(tempo_map)
}
pub fn internal_duration(&self) -> Seconds {
self.internal_end - self.internal_start
}
/// Check if this clip instance loops
pub fn is_looping(&self) -> bool {
self.external_duration > self.internal_duration()
pub fn is_looping(&self, tempo_map: &TempoMap) -> bool {
self.external_duration_secs(tempo_map) > self.internal_duration()
}
/// Get the position within the audio content for a given timeline position
/// Returns None if the timeline position is outside this clip instance
/// Handles looping automatically
pub fn get_content_position(&self, timeline_pos: f64) -> Option<f64> {
if timeline_pos < self.external_start || timeline_pos >= self.external_end() {
/// Get the audio content position for a given timeline position. Handles looping.
pub fn get_content_position(&self, timeline_pos: Seconds, tempo_map: &TempoMap) -> Option<Seconds> {
let start_secs = self.external_start_secs(tempo_map);
let end_secs = self.external_end_secs(tempo_map);
if timeline_pos < start_secs || timeline_pos >= end_secs {
return None;
}
let relative_pos = timeline_pos - self.external_start;
let relative_pos = timeline_pos - start_secs;
let internal_duration = self.internal_duration();
if internal_duration <= 0.0 {
if internal_duration.0 <= 0.0 {
return None;
}
// Wrap around for looping
let content_offset = relative_pos % internal_duration;
Some(self.internal_start + content_offset)
}
/// Set clip gain
pub fn set_gain(&mut self, gain: f32) {
self.gain = gain.max(0.0);
}
/// Populate beats/frames from the current seconds values.
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
self.external_start_beats = self.external_start * bpm / 60.0;
self.external_start_frames = self.external_start * fps;
self.external_duration_beats = self.external_duration * bpm / 60.0;
self.external_duration_frames = self.external_duration * fps;
self.internal_start_beats = self.internal_start * bpm / 60.0;
self.internal_start_frames = self.internal_start * fps;
self.internal_end_beats = self.internal_end * bpm / 60.0;
self.internal_end_frames = self.internal_end * fps;
}
/// BPM changed; recompute seconds/frames from the stored beats values.
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
self.external_start = self.external_start_beats * 60.0 / bpm;
self.external_start_frames = self.external_start * fps;
self.external_duration = self.external_duration_beats * 60.0 / bpm;
self.external_duration_frames = self.external_duration * fps;
self.internal_start = self.internal_start_beats * 60.0 / bpm;
self.internal_start_frames = self.internal_start * fps;
self.internal_end = self.internal_end_beats * 60.0 / bpm;
self.internal_end_frames = self.internal_end * fps;
}
/// FPS changed; recompute seconds/beats from the stored frames values.
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
self.external_start = self.external_start_frames / fps;
self.external_start_beats = self.external_start * bpm / 60.0;
self.external_duration = self.external_duration_frames / fps;
self.external_duration_beats = self.external_duration * bpm / 60.0;
self.internal_start = self.internal_start_frames / fps;
self.internal_start_beats = self.internal_start * bpm / 60.0;
self.internal_end = self.internal_end_frames / fps;
self.internal_end_beats = self.internal_end * bpm / 60.0;
}
}

View File

@ -9,6 +9,7 @@ use crate::audio::recording::{MidiRecordingState, RecordingState};
use crate::audio::track::{Track, TrackId, TrackNode};
use crate::command::{AudioEvent, Command, Query, QueryResponse};
use crate::io::MidiInputManager;
use crate::time::{Beats, Seconds};
use petgraph::stable_graph::NodeIndex;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
@ -109,9 +110,8 @@ pub struct Engine {
timing_sum_total_us: u64,
timing_overrun_count: u64,
// Current tempo/framerate — kept in sync with SetTempo/ApplyBpmChange so that
// newly-created clip instances can be immediately synced via sync_from_seconds.
current_bpm: f64,
// Current tempo map — kept in sync with SetTempo/SetTempoMap commands.
tempo_map: crate::TempoMap,
current_fps: f64,
}
@ -189,7 +189,7 @@ impl Engine {
timing_worst_render_us: 0,
timing_sum_total_us: 0,
timing_overrun_count: 0,
current_bpm: 120.0,
tempo_map: crate::TempoMap::constant(120.0),
current_fps: 30.0,
}
}
@ -379,7 +379,7 @@ impl Engine {
}
// Convert playhead from frames to seconds for timeline-based rendering
let playhead_seconds = self.playhead as f64 / self.sample_rate as f64;
let playhead_seconds = Seconds(self.playhead as f64 / self.sample_rate as f64);
// Reset per-clip read-ahead targets before rendering.
self.project.reset_read_ahead_targets();
@ -390,6 +390,7 @@ impl Engine {
&self.audio_pool,
&mut self.buffer_pool,
playhead_seconds,
&self.tempo_map,
self.sample_rate,
self.channels,
false,
@ -427,7 +428,8 @@ impl Engine {
// Send MIDI recording progress if active
if let Some(recording) = &self.midi_recording_state {
let current_time = self.playhead as f64 / self.sample_rate as f64;
let current_time_secs = Seconds(self.playhead as f64 / self.sample_rate as f64);
let current_time = self.tempo_map.seconds_to_beats(current_time_secs);
let duration = current_time - recording.start_time;
let notes = recording.get_notes_with_active(current_time);
let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress(
@ -451,7 +453,7 @@ impl Engine {
} else {
// Not playing: render live MIDI (keyboard input + note-off tails) through the
// normal group hierarchy so mixer gain is correctly applied.
let playhead_seconds = self.playhead as f64 / self.sample_rate as f64;
let playhead_seconds = Seconds(self.playhead as f64 / self.sample_rate as f64);
if self.mix_buffer.len() != output.len() {
self.mix_buffer.resize(output.len(), 0.0);
}
@ -463,6 +465,7 @@ impl Engine {
&self.audio_pool,
&mut self.buffer_pool,
playhead_seconds,
&self.tempo_map,
self.sample_rate,
self.channels,
true, // live_only
@ -562,7 +565,8 @@ impl Engine {
if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.internal_end = clip.internal_start + duration;
clip.external_duration = duration;
let duration_beats = self.tempo_map.seconds_to_beats(duration);
clip.external_duration = duration_beats;
}
}
@ -734,20 +738,16 @@ impl Engine {
}
}
Command::MoveClip(track_id, clip_id, new_start_time) => {
// Moving just changes external_start, external_duration stays the same
let bpm = self.current_bpm;
let fps = self.current_fps;
// Moving just changes external_start (beats), external_duration stays the same
match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.external_start = new_start_time;
clip.sync_from_seconds(bpm, fps);
clip.external_start = Beats(new_start_time);
}
}
Some(crate::audio::track::TrackNode::Midi(track)) => {
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) {
instance.external_start = new_start_time;
instance.sync_from_seconds(bpm, fps);
instance.external_start = Beats(new_start_time);
}
}
_ => {}
@ -757,24 +757,19 @@ impl Engine {
Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => {
// Trim changes which portion of the source content is used
// Also updates external_duration to match internal duration (no looping after trim)
let bpm = self.current_bpm;
let fps = self.current_fps;
match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.internal_start = new_internal_start;
clip.internal_end = new_internal_end;
clip.external_duration = new_internal_end - new_internal_start;
clip.sync_from_seconds(bpm, fps);
clip.internal_start = Seconds(new_internal_start);
clip.internal_end = Seconds(new_internal_end);
clip.external_duration = Beats(new_internal_end - new_internal_start);
}
}
Some(crate::audio::track::TrackNode::Midi(track)) => {
// Note: clip_id here is the pool clip ID, not instance ID
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
instance.internal_start = new_internal_start;
instance.internal_end = new_internal_end;
instance.external_duration = new_internal_end - new_internal_start;
instance.sync_from_seconds(bpm, fps);
instance.internal_start = Beats(new_internal_start);
instance.internal_end = Beats(new_internal_end);
instance.external_duration = Beats(new_internal_end - new_internal_start);
}
}
_ => {}
@ -782,21 +777,16 @@ impl Engine {
self.refresh_clip_snapshot();
}
Command::ExtendClip(track_id, clip_id, new_external_duration) => {
// Extend changes the external duration (enables looping if > internal duration)
let bpm = self.current_bpm;
let fps = self.current_fps;
// Extend changes the external duration (beats; enables looping if > internal duration)
match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.external_duration = new_external_duration;
clip.sync_from_seconds(bpm, fps);
clip.external_duration = Beats(new_external_duration);
}
}
Some(crate::audio::track::TrackNode::Midi(track)) => {
// Note: clip_id here is the pool clip ID, not instance ID
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
instance.external_duration = new_external_duration;
instance.sync_from_seconds(bpm, fps);
instance.external_duration = Beats(new_external_duration);
}
}
_ => {}
@ -823,7 +813,7 @@ impl Engine {
}
Command::SetOffset(track_id, offset) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
metatrack.offset = offset;
metatrack.offset = Seconds(offset);
}
}
Command::SetPitchShift(track_id, semitones) => {
@ -833,12 +823,12 @@ impl Engine {
}
Command::SetTrimStart(track_id, trim_start) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
metatrack.trim_start = trim_start.max(0.0);
metatrack.trim_start = Seconds(trim_start.max(0.0));
}
}
Command::SetTrimEnd(track_id, trim_end) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
metatrack.trim_end = trim_end.map(|t| t.max(0.0));
metatrack.trim_end = trim_end.map(|t| Seconds(t.max(0.0)));
}
}
Command::CreateAudioTrack(name, parent_id) => {
@ -915,14 +905,20 @@ impl Engine {
}
Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => {
// Create a new clip instance with the pre-assigned clip_id
let mut clip = AudioClipInstance::from_legacy(
// start_time and duration are in beats; offset (internal_start) is seconds
let start_beats = Beats(start_time);
let end_beats = Beats(start_time + duration);
let start_secs = self.tempo_map.beats_to_seconds(start_beats);
let end_secs = self.tempo_map.beats_to_seconds(end_beats);
let content_dur_secs = (end_secs - start_secs).seconds_to_f64();
let clip = AudioClipInstance::new(
clip_id,
pool_index,
start_time,
duration,
offset,
Seconds(offset),
Seconds(offset + content_dur_secs),
start_beats,
Beats(duration),
);
clip.sync_from_seconds(self.current_bpm, self.current_fps);
// Add clip to track
if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) {
@ -945,13 +941,12 @@ impl Engine {
let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
// Create clip content in the pool
let clip = MidiClip::empty(clip_id, duration, format!("MIDI Clip {}", clip_id));
let clip = MidiClip::empty(clip_id, Beats(duration), format!("MIDI Clip {}", clip_id));
self.project.midi_clip_pool.add_existing_clip(clip);
// Create an instance for this clip on the track
let instance_id = self.project.next_midi_clip_instance_id();
let mut instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time);
instance.sync_from_seconds(self.current_bpm, self.current_fps);
let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time));
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
track.clip_instances.push(instance);
@ -965,12 +960,12 @@ impl Engine {
// Add a MIDI note event to the specified clip in the pool
// Note: clip_id here refers to the clip in the pool, not the instance
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) {
// Timestamp is now in seconds (sample-rate independent)
let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
// Timestamp is in beats (canonical)
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity);
clip.add_event(note_on);
// Add note off event
let note_off_time = time_offset + duration;
let note_off_time = Beats(time_offset + duration);
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.add_event(note_off);
} else {
@ -979,9 +974,9 @@ impl Engine {
if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) {
let actual_clip_id = instance.clip_id;
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) {
let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity);
clip.add_event(note_on);
let note_off_time = time_offset + duration;
let note_off_time = Beats(time_offset + duration);
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.add_event(note_off);
}
@ -991,14 +986,8 @@ impl Engine {
}
Command::AddLoadedMidiClip(track_id, clip, start_time) => {
// Add a pre-loaded MIDI clip to the track with the given start time
let bpm = self.current_bpm;
let fps = self.current_fps;
if let Ok(instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) {
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
if let Some(inst) = track.clip_instances.iter_mut().find(|i| i.id == instance_id) {
inst.sync_from_seconds(bpm, fps);
}
}
if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) {
// instance positions are already in beats; nothing to sync
}
self.refresh_clip_snapshot();
}
@ -1009,13 +998,13 @@ impl Engine {
clip.events.clear();
// Add new events from the notes array
// Timestamps are now stored in seconds (sample-rate independent)
// Timestamps are in beats (canonical)
for (start_time, note, velocity, duration) in notes {
let note_on = MidiEvent::note_on(start_time, 0, note, velocity);
let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity);
clip.events.push(note_on);
// Add note off event
let note_off_time = start_time + duration;
let note_off_time = Beats(start_time + duration);
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.events.push(note_off);
}
@ -1077,7 +1066,7 @@ impl Engine {
}
Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => {
// Add an automation point to the specified lane
let point = crate::audio::AutomationPoint::new(time, value, curve);
let point = crate::audio::AutomationPoint::new(Beats(time), value, curve);
match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => {
@ -1103,17 +1092,17 @@ impl Engine {
match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
lane.remove_point_at_time(time, tolerance);
lane.remove_point_at_time(Beats(time), Beats(tolerance));
}
}
Some(crate::audio::track::TrackNode::Midi(track)) => {
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
lane.remove_point_at_time(time, tolerance);
lane.remove_point_at_time(Beats(time), Beats(tolerance));
}
}
Some(crate::audio::track::TrackNode::Group(group)) => {
if let Some(lane) = group.get_automation_lane_mut(lane_id) {
lane.remove_point_at_time(time, tolerance);
lane.remove_point_at_time(Beats(time), Beats(tolerance));
}
}
None => {}
@ -1250,9 +1239,9 @@ impl Engine {
// If MIDI recording is active on this track, capture the event
if let Some(recording) = &mut self.midi_recording_state {
if recording.track_id == track_id {
let absolute_time = self.playhead as f64 / self.sample_rate as f64;
eprintln!("[MIDI_RECORDING] NoteOn captured: note={}, velocity={}, absolute_time={:.3}s, playhead={}, sample_rate={}",
note, velocity, absolute_time, self.playhead, self.sample_rate);
let absolute_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64));
eprintln!("[MIDI_RECORDING] NoteOn captured: note={}, velocity={}, absolute_time={:.3} beats, playhead={}, sample_rate={}",
note, velocity, absolute_time.0, self.playhead, self.sample_rate);
recording.note_on(note, velocity, absolute_time);
}
}
@ -1273,9 +1262,9 @@ impl Engine {
// If MIDI recording is active on this track, capture the event
if let Some(recording) = &mut self.midi_recording_state {
if recording.track_id == track_id {
let absolute_time = self.playhead as f64 / self.sample_rate as f64;
eprintln!("[MIDI_RECORDING] NoteOff captured: note={}, absolute_time={:.3}s, playhead={}, sample_rate={}",
note, absolute_time, self.playhead, self.sample_rate);
let absolute_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64));
eprintln!("[MIDI_RECORDING] NoteOff captured: note={}, absolute_time={:.3} beats, playhead={}, sample_rate={}",
note, absolute_time.0, self.playhead, self.sample_rate);
recording.note_off(note, absolute_time);
}
}
@ -1303,14 +1292,7 @@ impl Engine {
Command::SetTempo(bpm, time_sig) => {
self.metronome.update_timing(bpm, time_sig);
self.project.set_tempo(bpm, time_sig.0);
self.current_bpm = bpm as f64;
}
Command::ApplyBpmChange(from_bpm, to_bpm, fps, midi_durations) => {
self.current_bpm = to_bpm;
self.current_fps = fps;
self.project.apply_bpm_change(from_bpm, to_bpm, fps, &midi_durations);
self.refresh_clip_snapshot();
self.tempo_map.set_global_bpm(bpm as f64);
}
// Node graph commands
@ -2181,16 +2163,13 @@ impl Engine {
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
// Downcast to AutomationInputNode using as_any_mut
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
let mut keyframe = AutomationKeyframe {
time,
time_beats: 0.0,
time_frames: 0.0,
let keyframe = AutomationKeyframe {
time: Beats(time),
value,
interpolation,
ease_out,
ease_in,
};
keyframe.sync_from_seconds(self.current_bpm, self.current_fps);
auto_node.add_keyframe(keyframe);
} else {
eprintln!("Node {} is not an AutomationInputNode", node_id);
@ -2208,7 +2187,7 @@ impl Engine {
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
auto_node.remove_keyframe_at_time(time, 0.001); // 1ms tolerance
auto_node.remove_keyframe_at_time(Beats(time), Beats(0.001)); // 1ms tolerance
} else {
eprintln!("Node {} is not an AutomationInputNode", node_id);
}
@ -2517,7 +2496,7 @@ impl Engine {
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
use crate::command::MidiClipData;
QueryResponse::MidiClipData(Ok(MidiClipData {
duration: clip.duration,
duration: clip.duration.0,
events: clip.events.clone(),
}))
} else {
@ -2547,7 +2526,7 @@ impl Engine {
}.to_string();
AutomationKeyframeData {
time: kf.time,
time: kf.time.0,
value: kf.value,
interpolation: interpolation_str,
ease_out: kf.ease_out,
@ -2752,19 +2731,9 @@ impl Engine {
}
}
Query::AddMidiClipSync(track_id, clip, start_time) => {
// Add MIDI clip to track and return the instance ID
let bpm = self.current_bpm;
let fps = self.current_fps;
let result = match self.project.add_midi_clip_at(track_id, clip, start_time) {
Ok(instance_id) => {
// Sync beats/frames on the newly created instance
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
if let Some(inst) = track.clip_instances.iter_mut().find(|i| i.id == instance_id) {
inst.sync_from_seconds(bpm, fps);
}
}
QueryResponse::MidiClipInstanceAdded(Ok(instance_id))
}
// Add MIDI clip to track and return the instance ID (positions already in beats)
let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) {
Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())),
};
self.refresh_clip_snapshot();
@ -2772,10 +2741,9 @@ impl Engine {
}
Query::AddMidiClipInstanceSync(track_id, mut instance) => {
// Add MIDI clip instance to track (clip must already be in pool)
// Assign instance ID
// Assign instance ID; positions are already in beats
let instance_id = self.project.next_midi_clip_instance_id();
instance.id = instance_id;
instance.sync_from_seconds(self.current_bpm, self.current_fps);
let result = match self.project.add_midi_clip_instance(track_id, instance) {
Ok(_) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
@ -2943,7 +2911,7 @@ impl Engine {
}
/// Handle starting a recording
fn handle_start_recording(&mut self, track_id: TrackId, start_time: f64) {
fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats) {
use crate::io::WavWriter;
use std::env;
@ -2966,10 +2934,10 @@ impl Engine {
let clip = crate::audio::clip::Clip::new(
clip_id,
0, // Temporary pool index, will be updated on finalization
0.0, // internal_start
0.0, // internal_end - Duration starts at 0, will be updated during recording
start_time, // external_start (timeline position)
start_time, // external_end - will be updated during recording
Seconds(0.0), // internal_start
Seconds(0.0), // internal_end - Duration starts at 0, will be updated during recording
start_time, // external_start (timeline position, already Beats)
Beats(0.0), // external_duration - will be updated during recording
);
// Add clip to track
@ -3098,7 +3066,7 @@ impl Engine {
}
/// Handle starting MIDI recording
fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) {
fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) {
// Check if track exists and is a MIDI track
if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) {
// Create MIDI recording state
@ -3108,7 +3076,7 @@ impl Engine {
// so they start at t=0 of the recording rather than being lost
if let Some(held) = self.midi_held_notes.get(&track_id) {
for (&note, &velocity) in held {
eprintln!("[MIDI_RECORDING] Injecting held note {} vel {} at start_time {:.3}s", note, velocity, start_time);
eprintln!("[MIDI_RECORDING] Injecting held note {} vel {} at start_time {:.3}", note, velocity, start_time.0);
recording_state.note_on(note, velocity, start_time);
}
}
@ -3135,8 +3103,8 @@ impl Engine {
}
// Close out any active notes at the current playhead position
let end_time = self.playhead as f64 / self.sample_rate as f64;
eprintln!("[MIDI_RECORDING] Closing active notes at time {}", end_time);
let end_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64));
eprintln!("[MIDI_RECORDING] Closing active notes at time {}", end_time.0);
recording.close_active_notes(end_time);
let clip_id = recording.clip_id;
@ -3145,8 +3113,8 @@ impl Engine {
let note_count = notes.len();
let recording_duration = end_time - recording.start_time;
eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3}s",
clip_id, track_id, note_count, recording_duration);
eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats",
clip_id, track_id, note_count, recording_duration.0);
// Update the MIDI clip in the pool (new model: clips are stored centrally in the pool)
eprintln!("[MIDI_RECORDING] Looking for clip {} in midi_clip_pool", clip_id);
@ -3155,41 +3123,37 @@ impl Engine {
// Clear existing events
clip.events.clear();
// Update clip duration to match the actual recording time
clip.duration = recording_duration;
// Recording duration is already in beats (canonical)
let duration_beats = recording_duration;
clip.duration = duration_beats;
// Add new events from the recorded notes
// Timestamps are now stored in seconds (sample-rate independent)
let bpm = self.current_bpm;
let fps = self.current_fps;
// Add new events from the recorded notes.
// Recorded timestamps are already in beats (canonical).
for (start_time, note, velocity, duration) in notes.iter() {
let mut note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity);
note_on.sync_from_seconds(bpm, fps);
let note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity);
eprintln!("[MIDI_RECORDING] Note {}: start_time={:.3}s, duration={:.3}s",
note, start_time, duration);
eprintln!("[MIDI_RECORDING] Note {}: start={:.3} beats, duration={:.3} beats",
note, start_time.0, duration.0);
clip.events.push(note_on);
// Add note off event
let note_off_time = *start_time + *duration;
let mut note_off = MidiEvent::note_off(note_off_time, 0, *note, 64);
note_off.sync_from_seconds(bpm, fps);
let note_off = MidiEvent::note_off(note_off_time, 0, *note, 64);
clip.events.push(note_off);
}
// Sort events by timestamp (using partial_cmp for f64)
// Sort events by timestamp (using partial_cmp for Beats)
clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
eprintln!("[MIDI_RECORDING] Updated clip {} with {} notes ({} events)", clip_id, note_count, clip.events.len());
// Also update the clip instance's internal_end and external_duration to match the recording duration
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
if let Some(instance) = track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) {
instance.internal_end = recording_duration;
instance.external_duration = recording_duration;
instance.sync_from_seconds(bpm, fps);
eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3}s, external_duration={:.3}s",
instance.internal_end, instance.external_duration);
instance.internal_end = duration_beats;
instance.external_duration = duration_beats;
eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3} beats, external_duration={:.3} beats",
instance.internal_end.0, instance.external_duration.0);
}
}
} else {
@ -3643,7 +3607,7 @@ impl EngineController {
/// Start recording on a track
pub fn start_recording(&mut self, track_id: TrackId, start_time: f64) {
let _ = self.command_tx.push(Command::StartRecording(track_id, start_time));
let _ = self.command_tx.push(Command::StartRecording(track_id, Beats(start_time)));
}
/// Stop the current recording
@ -3663,7 +3627,7 @@ impl EngineController {
/// Start MIDI recording on a track
pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) {
let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time));
let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, Beats(start_time)));
}
/// Stop the current MIDI recording
@ -3705,10 +3669,6 @@ impl EngineController {
/// automation keyframe times to preserve beat positions.
/// `from_bpm` is the BPM before the change; `to_bpm` is the new BPM.
/// Call this after move_clip() has been called for all affected clips.
pub fn apply_bpm_change(&mut self, from_bpm: f64, to_bpm: f64, fps: f64, midi_durations: Vec<(crate::audio::MidiClipId, f64)>) {
let _ = self.command_tx.push(Command::ApplyBpmChange(from_bpm, to_bpm, fps, midi_durations));
}
// Node graph operations
/// Add a node to a track's instrument graph

View File

@ -2,6 +2,8 @@ use super::buffer_pool::BufferPool;
use super::pool::AudioPool;
use super::project::Project;
use crate::command::AudioEvent;
use crate::tempo_map::TempoMap;
use crate::time::Seconds;
use std::path::Path;
/// Render chunk size for offline export. Matches the real-time playback buffer size
@ -42,10 +44,12 @@ pub struct ExportSettings {
pub bit_depth: u16,
/// MP3 bitrate in kbps (128, 192, 256, 320)
pub mp3_bitrate: u32,
/// Start time in seconds
pub start_time: f64,
/// End time in seconds
pub end_time: f64,
/// Start time
pub start_time: Seconds,
/// End time
pub end_time: Seconds,
/// Tempo map for beat-position scheduling
pub tempo_map: TempoMap,
}
impl Default for ExportSettings {
@ -56,8 +60,9 @@ impl Default for ExportSettings {
channels: 2,
bit_depth: 16,
mp3_bitrate: 320,
start_time: 0.0,
end_time: 60.0,
start_time: Seconds::ZERO,
end_time: Seconds(60.0),
tempo_map: TempoMap::constant(120.0),
}
}
}
@ -79,15 +84,15 @@ pub fn export_audio<P: AsRef<Path>>(
{
// Validate duration
let duration = settings.end_time - settings.start_time;
if duration <= 0.0 {
if duration <= Seconds::ZERO {
return Err(format!(
"Export duration is zero or negative (start={:.3}s, end={:.3}s). \
Check that the timeline has content.",
settings.start_time, settings.end_time
settings.start_time.seconds_to_f64(), settings.end_time.seconds_to_f64()
));
}
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
if total_frames == 0 {
return Err("Export would produce zero audio frames".to_string());
}
@ -148,11 +153,11 @@ pub fn render_to_memory(
{
// Calculate total number of frames
let duration = settings.end_time - settings.start_time;
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
let total_samples = total_frames * settings.channels as usize;
println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}",
duration, total_frames, total_samples, settings.channels);
duration.seconds_to_f64(), total_frames, total_samples, settings.channels);
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
@ -178,6 +183,7 @@ pub fn render_to_memory(
pool,
&mut buffer_pool,
playhead,
&settings.tempo_map,
settings.sample_rate,
settings.channels,
false,
@ -185,9 +191,9 @@ pub fn render_to_memory(
// Calculate how many samples we actually need from this chunk
let remaining_time = settings.end_time - playhead;
let samples_needed = if remaining_time < chunk_duration {
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
// Calculate frames needed and ensure it's a whole number
let frames_needed = (remaining_time * settings.sample_rate as f64).round() as usize;
let frames_needed = (remaining_time.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
let samples = frames_needed * settings.channels as usize;
// Ensure we don't exceed chunk size
samples.min(chunk_samples)
@ -207,7 +213,7 @@ pub fn render_to_memory(
});
}
playhead += chunk_duration;
playhead = playhead + Seconds(chunk_duration);
}
println!("Export: rendered {} samples total", all_samples.len());
@ -361,7 +367,7 @@ fn export_mp3<P: AsRef<Path>>(
// Calculate rendering parameters
let duration = settings.end_time - settings.start_time;
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
@ -396,6 +402,7 @@ fn export_mp3<P: AsRef<Path>>(
pool,
&mut buffer_pool,
playhead,
&settings.tempo_map,
settings.sample_rate,
settings.channels,
false,
@ -403,8 +410,8 @@ fn export_mp3<P: AsRef<Path>>(
// Calculate how many samples we need from this chunk
let remaining_time = settings.end_time - playhead;
let samples_needed = if remaining_time < chunk_duration {
((remaining_time * settings.sample_rate as f64) as usize * settings.channels as usize)
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize)
.min(chunk_samples)
} else {
chunk_samples
@ -445,7 +452,7 @@ fn export_mp3<P: AsRef<Path>>(
}
}
playhead += chunk_duration;
playhead = playhead + Seconds(chunk_duration);
}
// Encode any remaining samples as the final frame
@ -529,7 +536,7 @@ fn export_aac<P: AsRef<Path>>(
// Calculate rendering parameters
let duration = settings.end_time - settings.start_time;
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
@ -564,6 +571,7 @@ fn export_aac<P: AsRef<Path>>(
pool,
&mut buffer_pool,
playhead,
&settings.tempo_map,
settings.sample_rate,
settings.channels,
false,
@ -571,8 +579,8 @@ fn export_aac<P: AsRef<Path>>(
// Calculate how many samples we need from this chunk
let remaining_time = settings.end_time - playhead;
let samples_needed = if remaining_time < chunk_duration {
((remaining_time * settings.sample_rate as f64) as usize * settings.channels as usize)
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize)
.min(chunk_samples)
} else {
chunk_samples
@ -613,7 +621,7 @@ fn export_aac<P: AsRef<Path>>(
}
}
playhead += chunk_duration;
playhead = playhead + Seconds(chunk_duration);
}
// Encode any remaining samples as the final frame

View File

@ -1,14 +1,10 @@
use crate::time::Beats;
/// MIDI event representing a single MIDI message
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct MidiEvent {
/// Time position within the clip in seconds (sample-rate independent)
pub timestamp: f64,
/// Time position in beats (quarter-note beats from clip start); derived from timestamp
#[serde(default)]
pub timestamp_beats: f64,
/// Time position in frames; derived from timestamp
#[serde(default)]
pub timestamp_frames: f64,
/// Time position in beats (quarter-note beats)
pub timestamp: Beats,
/// MIDI status byte (includes channel)
pub status: u8,
/// First data byte (note number, CC number, etc.)
@ -18,79 +14,28 @@ pub struct MidiEvent {
}
impl MidiEvent {
/// Create a new MIDI event
pub fn new(timestamp: f64, status: u8, data1: u8, data2: u8) -> Self {
Self {
timestamp,
timestamp_beats: 0.0,
timestamp_frames: 0.0,
status,
data1,
data2,
}
pub fn new(timestamp: Beats, status: u8, data1: u8, data2: u8) -> Self {
Self { timestamp, status, data1, data2 }
}
/// Create a note on event
pub fn note_on(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self {
Self {
timestamp,
timestamp_beats: 0.0,
timestamp_frames: 0.0,
status: 0x90 | (channel & 0x0F),
data1: note,
data2: velocity,
}
pub fn note_on(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self {
Self { timestamp, status: 0x90 | (channel & 0x0F), data1: note, data2: velocity }
}
/// Create a note off event
pub fn note_off(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self {
Self {
timestamp,
timestamp_beats: 0.0,
timestamp_frames: 0.0,
status: 0x80 | (channel & 0x0F),
data1: note,
data2: velocity,
}
pub fn note_off(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self {
Self { timestamp, status: 0x80 | (channel & 0x0F), data1: note, data2: velocity }
}
/// Sync beats and frames from seconds (call after constructing or when seconds is canonical)
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
self.timestamp_beats = self.timestamp * bpm / 60.0;
self.timestamp_frames = self.timestamp * fps;
}
/// Recompute seconds and frames from beats (call when BPM changes in Measures mode)
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
self.timestamp = self.timestamp_beats * 60.0 / bpm;
self.timestamp_frames = self.timestamp * fps;
}
/// Recompute seconds and beats from frames (call when FPS changes in Frames mode)
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
self.timestamp = self.timestamp_frames / fps;
self.timestamp_beats = self.timestamp * bpm / 60.0;
}
/// Check if this is a note on event (with non-zero velocity)
pub fn is_note_on(&self) -> bool {
(self.status & 0xF0) == 0x90 && self.data2 > 0
}
/// Check if this is a note off event (or note on with zero velocity)
pub fn is_note_off(&self) -> bool {
(self.status & 0xF0) == 0x80 || ((self.status & 0xF0) == 0x90 && self.data2 == 0)
}
/// Get the MIDI channel (0-15)
pub fn channel(&self) -> u8 {
self.status & 0x0F
}
/// Get the message type (upper 4 bits of status)
pub fn message_type(&self) -> u8 {
self.status & 0xF0
}
pub fn channel(&self) -> u8 { self.status & 0x0F }
pub fn message_type(&self) -> u8 { self.status & 0xF0 }
}
/// MIDI clip ID type (for clips stored in the pool)
@ -99,240 +44,118 @@ pub type MidiClipId = u32;
/// MIDI clip instance ID type (for instances placed on tracks)
pub type MidiClipInstanceId = u32;
/// MIDI clip content - stores the actual MIDI events
///
/// This represents the content data stored in the MidiClipPool.
/// Events have timestamps relative to the start of the clip (0.0 = clip beginning).
/// MIDI clip content — stores the actual MIDI events.
/// `duration` is in beats.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClip {
pub id: MidiClipId,
pub events: Vec<MidiEvent>,
pub duration: f64, // Total content duration in seconds
/// Total content duration in beats
pub duration: Beats,
pub name: String,
}
impl MidiClip {
/// Create a new MIDI clip with content
pub fn new(id: MidiClipId, events: Vec<MidiEvent>, duration: f64, name: String) -> Self {
let mut clip = Self {
id,
events,
duration,
name,
};
// Sort events by timestamp
pub fn new(id: MidiClipId, events: Vec<MidiEvent>, duration: Beats, name: String) -> Self {
let mut clip = Self { id, events, duration, name };
clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
clip
}
/// Create an empty MIDI clip
pub fn empty(id: MidiClipId, duration: f64, name: String) -> Self {
Self {
id,
events: Vec::new(),
duration,
name,
}
pub fn empty(id: MidiClipId, duration: Beats, name: String) -> Self {
Self { id, events: Vec::new(), duration, name }
}
/// Add a MIDI event to the clip
pub fn add_event(&mut self, event: MidiEvent) {
self.events.push(event);
// Keep events sorted by timestamp
self.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
}
/// Get events within a time range (relative to clip start)
/// This is used by MidiClipInstance to fetch events for a given portion
pub fn get_events_in_range(&self, start: f64, end: f64) -> Vec<MidiEvent> {
self.events
.iter()
/// Get events within a beat range (relative to clip start)
pub fn get_events_in_range(&self, start: Beats, end: Beats) -> Vec<MidiEvent> {
self.events.iter()
.filter(|e| e.timestamp >= start && e.timestamp < end)
.copied()
.collect()
}
}
/// MIDI clip instance - a reference to MidiClip content with timeline positioning
/// MIDI clip instance a reference to MidiClip content with timeline positioning.
///
/// ## Timing Model
/// - `internal_start` / `internal_end`: Define the region of the source clip to play (trimming)
/// - `external_start` / `external_duration`: Define where the instance appears on the timeline and how long
/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display
///
/// ## Looping
/// If `external_duration` is greater than `internal_end - internal_start`,
/// the instance will seamlessly loop back to `internal_start` when it reaches `internal_end`.
/// All timing fields are in beats.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClipInstance {
pub id: MidiClipInstanceId,
pub clip_id: MidiClipId, // Reference to MidiClip in pool
pub clip_id: MidiClipId,
/// Start position within the clip content (seconds)
pub internal_start: f64,
#[serde(default)] pub internal_start_beats: f64,
#[serde(default)] pub internal_start_frames: f64,
/// End position within the clip content (seconds)
pub internal_end: f64,
#[serde(default)] pub internal_end_beats: f64,
#[serde(default)] pub internal_end_frames: f64,
/// Start of the trimmed region within the clip content (beats)
pub internal_start: Beats,
/// End of the trimmed region within the clip content (beats)
pub internal_end: Beats,
/// Start position on the timeline (seconds)
pub external_start: f64,
#[serde(default)] pub external_start_beats: f64,
#[serde(default)] pub external_start_frames: f64,
/// Duration on the timeline (seconds) - can be longer than internal duration for looping
pub external_duration: f64,
#[serde(default)] pub external_duration_beats: f64,
#[serde(default)] pub external_duration_frames: f64,
/// Start position on the timeline (beats)
pub external_start: Beats,
/// Duration on the timeline (beats); > internal duration = looping
pub external_duration: Beats,
}
impl MidiClipInstance {
/// Create a new MIDI clip instance
pub fn new(
id: MidiClipInstanceId,
clip_id: MidiClipId,
internal_start: f64,
internal_end: f64,
external_start: f64,
external_duration: f64,
internal_start: Beats,
internal_end: Beats,
external_start: Beats,
external_duration: Beats,
) -> Self {
Self {
id,
clip_id,
internal_start,
internal_start_beats: 0.0,
internal_start_frames: 0.0,
internal_end,
internal_end_beats: 0.0,
internal_end_frames: 0.0,
external_start,
external_start_beats: 0.0,
external_start_frames: 0.0,
external_duration,
external_duration_beats: 0.0,
external_duration_frames: 0.0,
}
Self { id, clip_id, internal_start, internal_end, external_start, external_duration }
}
/// Create an instance that uses the full clip content (no trimming, no looping)
/// Create an instance covering the full clip with no trim
pub fn from_full_clip(
id: MidiClipInstanceId,
clip_id: MidiClipId,
clip_duration: f64,
external_start: f64,
clip_duration: Beats,
external_start: Beats,
) -> Self {
Self {
id,
clip_id,
internal_start: 0.0,
internal_start_beats: 0.0,
internal_start_frames: 0.0,
internal_start: Beats::ZERO,
internal_end: clip_duration,
internal_end_beats: 0.0,
internal_end_frames: 0.0,
external_start,
external_start_beats: 0.0,
external_start_frames: 0.0,
external_duration: clip_duration,
external_duration_beats: 0.0,
external_duration_frames: 0.0,
}
}
/// Get the internal (content) duration
pub fn internal_duration(&self) -> f64 {
self.internal_end - self.internal_start
}
pub fn internal_duration(&self) -> Beats { self.internal_end - self.internal_start }
pub fn external_end(&self) -> Beats { self.external_start + self.external_duration }
pub fn is_looping(&self) -> bool { self.external_duration > self.internal_duration() }
/// Get the end time on the timeline
pub fn external_end(&self) -> f64 {
self.external_start + self.external_duration
}
/// Check if this instance loops
pub fn is_looping(&self) -> bool {
self.external_duration > self.internal_duration()
}
/// Get the end time on the timeline (for backwards compatibility)
pub fn end_time(&self) -> f64 {
self.external_end()
}
/// Get the start time on the timeline (for backwards compatibility)
pub fn start_time(&self) -> f64 {
self.external_start
}
/// Check if this instance overlaps with a time range
pub fn overlaps_range(&self, range_start: f64, range_end: f64) -> bool {
/// Check if this instance overlaps with a beat range
pub fn overlaps_range(&self, range_start: Beats, range_end: Beats) -> bool {
self.external_start < range_end && self.external_end() > range_start
}
/// Populate beats/frames from the current seconds values.
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
self.external_start_beats = self.external_start * bpm / 60.0;
self.external_start_frames = self.external_start * fps;
self.external_duration_beats = self.external_duration * bpm / 60.0;
self.external_duration_frames = self.external_duration * fps;
self.internal_start_beats = self.internal_start * bpm / 60.0;
self.internal_start_frames = self.internal_start * fps;
self.internal_end_beats = self.internal_end * bpm / 60.0;
self.internal_end_frames = self.internal_end * fps;
}
/// BPM changed; recompute seconds/frames from the stored beats values.
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
self.external_start = self.external_start_beats * 60.0 / bpm;
self.external_start_frames = self.external_start * fps;
self.external_duration = self.external_duration_beats * 60.0 / bpm;
self.external_duration_frames = self.external_duration * fps;
self.internal_start = self.internal_start_beats * 60.0 / bpm;
self.internal_start_frames = self.internal_start * fps;
self.internal_end = self.internal_end_beats * 60.0 / bpm;
self.internal_end_frames = self.internal_end * fps;
}
/// FPS changed; recompute seconds/beats from the stored frames values.
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
self.external_start = self.external_start_frames / fps;
self.external_start_beats = self.external_start * bpm / 60.0;
self.external_duration = self.external_duration_frames / fps;
self.external_duration_beats = self.external_duration * bpm / 60.0;
self.internal_start = self.internal_start_frames / fps;
self.internal_start_beats = self.internal_start * bpm / 60.0;
self.internal_end = self.internal_end_frames / fps;
self.internal_end_beats = self.internal_end * bpm / 60.0;
}
/// Get events that should be triggered in a given timeline range
///
/// This handles:
/// - Trimming (internal_start/internal_end)
/// - Looping (when external duration > internal duration)
/// - Time mapping from timeline to clip content
///
/// Returns events with timestamps adjusted to timeline time (not clip-relative)
/// Get events that should fire in a given beat range on the timeline.
/// Returns events with `timestamp` set to their global timeline beat position.
pub fn get_events_in_range(
&self,
clip: &MidiClip,
range_start_seconds: f64,
range_end_seconds: f64,
range_start: Beats,
range_end: Beats,
) -> Vec<MidiEvent> {
let mut result = Vec::new();
// Check if instance overlaps with the range
if !self.overlaps_range(range_start_seconds, range_end_seconds) {
if !self.overlaps_range(range_start, range_end) {
return result;
}
let internal_duration = self.internal_duration();
if internal_duration <= 0.0 {
if internal_duration <= Beats::ZERO {
return result;
}
// Calculate how many complete loops fit in the external duration
let num_loops = if self.external_duration > internal_duration {
(self.external_duration / internal_duration).ceil() as usize
} else {
@ -342,24 +165,18 @@ impl MidiClipInstance {
let external_end = self.external_end();
for loop_idx in 0..num_loops {
let loop_offset = loop_idx as f64 * internal_duration;
let loop_offset = internal_duration * loop_idx as f64;
// Get events from the clip that fall within the internal range
for event in &clip.events {
// Skip events outside the trimmed region
// Use > (not >=) for internal_end so note-offs at the clip boundary are included
if event.timestamp < self.internal_start || event.timestamp > self.internal_end {
continue;
}
// Convert to timeline time
let relative_content_time = event.timestamp - self.internal_start;
let timeline_time = self.external_start + loop_offset + relative_content_time;
// Check if within current buffer range and instance bounds
// Use <= for external_end so note-offs at the clip boundary are included
if timeline_time >= range_start_seconds
&& timeline_time < range_end_seconds
if timeline_time >= range_start
&& timeline_time < range_end
&& timeline_time <= external_end
{
let mut adjusted_event = *event;

View File

@ -1,6 +1,7 @@
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use super::midi::{MidiClip, MidiClipId, MidiEvent};
use crate::time::Beats;
/// Pool for storing MIDI clip content
/// Similar to AudioClipPool but for MIDI data
@ -21,7 +22,7 @@ impl MidiClipPool {
/// Add a new clip to the pool with the given events and duration
/// Returns the ID of the newly created clip
pub fn add_clip(&mut self, events: Vec<MidiEvent>, duration: f64, name: String) -> MidiClipId {
pub fn add_clip(&mut self, events: Vec<MidiEvent>, duration: Beats, name: String) -> MidiClipId {
let id = self.next_id;
self.next_id += 1;

View File

@ -1,6 +1,7 @@
use super::node_trait::AudioNode;
use super::types::{ConnectionError, SignalType};
use crate::audio::midi::MidiEvent;
use crate::time::Beats;
use petgraph::algo::has_path_connecting;
use petgraph::stable_graph::{NodeIndex, StableGraph};
use petgraph::visit::{EdgeRef, IntoEdgeReferences};
@ -93,8 +94,8 @@ pub struct AudioGraph {
/// UI positions for nodes (node_index -> (x, y))
node_positions: std::collections::HashMap<u32, (f32, f32)>,
/// Current playback time (for automation nodes)
playback_time: f64,
/// Current playback time in beats (for automation nodes)
playback_time: Beats,
/// Project tempo (synced from Engine via SetTempo)
bpm: f32,
@ -123,7 +124,7 @@ impl AudioGraph {
// Pre-allocate MIDI input buffers (max 128 events per port)
midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(),
node_positions: std::collections::HashMap::new(),
playback_time: 0.0,
playback_time: Beats::ZERO,
bpm: 120.0,
beats_per_bar: 4,
topo_cache: None,
@ -475,7 +476,7 @@ impl AudioGraph {
}
/// Process the graph and produce audio output
pub fn process(&mut self, output_buffer: &mut [f32], midi_events: &[MidiEvent], playback_time: f64) {
pub fn process(&mut self, output_buffer: &mut [f32], midi_events: &[MidiEvent], playback_time: Beats) {
// Update playback time
self.playback_time = playback_time;
@ -484,6 +485,7 @@ impl AudioGraph {
for node in self.graph.node_weights_mut() {
if let Some(auto_node) = node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
auto_node.set_playback_time(playback_time);
auto_node.set_bpm(self.bpm as f64);
} else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() {
beat_node.set_playback_time(playback_time);
beat_node.set_tempo(self.bpm, self.beats_per_bar);
@ -737,18 +739,6 @@ impl AudioGraph {
self.graph.node_indices()
}
/// BPM changed: rescale all AutomationInput keyframe times to preserve beat positions.
/// `from_bpm` is the BPM before the change (used to bootstrap beats if not yet populated).
/// `to_bpm` is the new BPM (used to re-derive seconds from beats).
pub fn apply_beats_to_automation_keyframes(&mut self, from_bpm: f64, to_bpm: f64, fps: f64) {
use super::nodes::AutomationInputNode;
for node in self.graph.node_weights_mut() {
if let Some(auto_node) = node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
auto_node.apply_beats_to_keyframes(from_bpm, to_bpm, fps);
}
}
}
/// Reallocate a node's output buffers to match its current port list.
///
/// Must be called after `SubtrackInputsNode::update_subtracks` changes the port count,
@ -1029,7 +1019,7 @@ impl AudioGraph {
serialized.automation_display_name = Some(auto_node.display_name().to_string());
serialized.automation_keyframes = auto_node.keyframes().iter().map(|kf| {
SerializedKeyframe {
time: kf.time,
time: kf.time.0,
value: kf.value,
interpolation: match kf.interpolation {
InterpolationType::Linear => "linear",
@ -1369,9 +1359,7 @@ impl AudioGraph {
auto_node.clear_keyframes();
for kf in &serialized_node.automation_keyframes {
auto_node.add_keyframe(AutomationKeyframe {
time: kf.time,
time_beats: 0.0,
time_frames: 0.0,
time: crate::time::Beats(kf.time),
value: kf.value,
interpolation: match kf.interpolation.as_str() {
"bezier" => InterpolationType::Bezier,

View File

@ -1,5 +1,6 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use crate::time::Beats;
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
@ -16,14 +17,7 @@ pub enum InterpolationType {
/// A single keyframe in an automation curve
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationKeyframe {
/// Time in seconds (absolute project time)
pub time: f64,
/// Time in beats (derived; canonical in Measures mode)
#[serde(default)]
pub time_beats: f64,
/// Time in frames (derived; canonical in Frames mode)
#[serde(default)]
pub time_frames: f64,
pub time: Beats,
/// CV output value
pub value: f32,
/// Interpolation type to next keyframe
@ -35,35 +29,15 @@ pub struct AutomationKeyframe {
}
impl AutomationKeyframe {
pub fn new(time: f64, value: f32) -> Self {
pub fn new(time: Beats, value: f32) -> Self {
Self {
time,
time_beats: 0.0,
time_frames: 0.0,
value,
interpolation: InterpolationType::Linear,
ease_out: (0.58, 1.0),
ease_in: (0.42, 0.0),
}
}
/// Populate beats/frames from the current seconds value.
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
self.time_beats = self.time * bpm / 60.0;
self.time_frames = self.time * fps;
}
/// BPM changed; beats are canonical → recompute seconds and frames.
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
self.time = self.time_beats * 60.0 / bpm;
self.time_frames = self.time * fps;
}
/// FPS changed; frames are canonical → recompute seconds and beats.
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
self.time = self.time_frames / fps;
self.time_beats = self.time * bpm / 60.0;
}
}
/// Automation Input Node - outputs CV signal controlled by timeline curves
@ -73,8 +47,10 @@ pub struct AutomationInputNode {
keyframes: Vec<AutomationKeyframe>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
/// Shared playback time (set by the graph before processing)
playback_time: Arc<RwLock<f64>>,
/// Shared playback time in beats (set by the graph before processing)
playback_time: Arc<RwLock<Beats>>,
/// Current BPM (set by the graph before processing, used for per-sample beat advancement)
bpm: f64,
/// Minimum output value (for UI display range)
pub value_min: f32,
/// Maximum output value (for UI display range)
@ -97,22 +73,26 @@ impl AutomationInputNode {
Self {
name: name.clone(),
display_name: "Automation".to_string(),
keyframes: vec![AutomationKeyframe::new(0.0, 0.0)],
keyframes: vec![AutomationKeyframe::new(Beats::ZERO, 0.0)],
outputs,
parameters,
playback_time: Arc::new(RwLock::new(0.0)),
playback_time: Arc::new(RwLock::new(Beats::ZERO)),
bpm: 120.0,
value_min: -1.0,
value_max: 1.0,
}
}
/// Set the playback time (called by graph before processing)
pub fn set_playback_time(&mut self, time: f64) {
pub fn set_playback_time(&mut self, time: Beats) {
if let Ok(mut playback) = self.playback_time.write() {
*playback = time;
}
}
pub fn set_bpm(&mut self, bpm: f64) {
self.bpm = bpm;
}
/// Get the display name (shown in UI)
pub fn display_name(&self) -> &str {
&self.display_name
@ -142,8 +122,7 @@ impl AutomationInputNode {
}
}
/// Remove keyframe at specific time (with tolerance)
pub fn remove_keyframe_at_time(&mut self, time: f64, tolerance: f64) -> bool {
pub fn remove_keyframe_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
if let Some(idx) = self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) {
self.keyframes.remove(idx);
true
@ -152,10 +131,8 @@ impl AutomationInputNode {
}
}
/// Update an existing keyframe
pub fn update_keyframe(&mut self, keyframe: AutomationKeyframe) {
// Remove old keyframe at this time, then add new one
self.remove_keyframe_at_time(keyframe.time, 0.001);
self.remove_keyframe_at_time(keyframe.time, Beats(0.001));
self.add_keyframe(keyframe);
}
@ -169,42 +146,20 @@ impl AutomationInputNode {
self.keyframes.clear();
}
/// Populate beats/frames on all keyframes from their current seconds values.
pub fn sync_keyframes_from_seconds(&mut self, bpm: f64, fps: f64) {
for kf in &mut self.keyframes {
kf.sync_from_seconds(bpm, fps);
}
}
/// BPM changed: for each keyframe, bootstrap beats from seconds (using `from_bpm`) if not yet
/// set, then re-derive seconds and frames from beats using `to_bpm`.
pub fn apply_beats_to_keyframes(&mut self, from_bpm: f64, to_bpm: f64, fps: f64) {
for kf in &mut self.keyframes {
if kf.time_beats == 0.0 && kf.time.abs() > 1e-9 {
kf.sync_from_seconds(from_bpm, fps);
}
kf.apply_beats(to_bpm, fps);
}
}
/// Evaluate curve at a specific time
fn evaluate_at_time(&self, time: f64) -> f32 {
fn evaluate_at_time(&self, time: Beats) -> f32 {
if self.keyframes.is_empty() {
return 0.0;
}
// Before first keyframe
if time <= self.keyframes[0].time {
return self.keyframes[0].value;
}
// After last keyframe
let last_idx = self.keyframes.len() - 1;
if time >= self.keyframes[last_idx].time {
return self.keyframes[last_idx].value;
}
// Find bracketing keyframes
for i in 0..self.keyframes.len() - 1 {
let kf1 = &self.keyframes[i];
let kf2 = &self.keyframes[i + 1];
@ -217,14 +172,12 @@ impl AutomationInputNode {
0.0
}
/// Interpolate between two keyframes
fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: f64) -> f32 {
// Calculate normalized position between keyframes (0.0 to 1.0)
fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: Beats) -> f32 {
let t = if kf2.time == kf1.time {
0.0
0.0f64
} else {
((time - kf1.time) / (kf2.time - kf1.time)) as f32
};
(time - kf1.time) / (kf2.time - kf1.time)
} as f32;
match kf1.interpolation {
InterpolationType::Linear => {
@ -301,19 +254,17 @@ impl AudioNode for AutomationInputNode {
let output = &mut outputs[0];
let length = output.len();
// Get the starting playback time
let playhead = if let Ok(playback) = self.playback_time.read() {
*playback
} else {
0.0
Beats::ZERO
};
// Calculate time per sample
let sample_duration = 1.0 / sample_rate as f64;
// Advance per sample in beats: beats_per_sample = bpm / 60 / sample_rate
let beats_per_sample = self.bpm / 60.0 / sample_rate as f64;
// Evaluate curve for each sample
for i in 0..length {
let time = playhead + (i as f64 * sample_duration);
let time = playhead + Beats(i as f64 * beats_per_sample);
output[i] = self.evaluate_at_time(time);
}
}
@ -337,7 +288,8 @@ impl AudioNode for AutomationInputNode {
keyframes: self.keyframes.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),
playback_time: Arc::new(RwLock::new(0.0)),
playback_time: Arc::new(RwLock::new(Beats::ZERO)),
bpm: self.bpm,
value_min: self.value_min,
value_max: self.value_max,
})

View File

@ -1,5 +1,6 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent;
use crate::time::Beats;
const PARAM_RESOLUTION: u32 = 0;
@ -61,12 +62,12 @@ pub struct BeatNode {
bpm: f32,
beats_per_bar: u32,
resolution: BeatResolution,
/// Playback time in seconds, set by the graph before process()
playback_time: f64,
/// Playback time in beats, set by the graph before process()
playback_time: Beats,
/// Previous playback_time to detect paused state
prev_playback_time: f64,
/// Free-running time accumulator for when playback is stopped
free_run_time: f64,
prev_playback_time: Beats,
/// Free-running beat accumulator for when playback is stopped
free_run_time: Beats,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
@ -92,16 +93,16 @@ impl BeatNode {
bpm: DEFAULT_BPM,
beats_per_bar: DEFAULT_BEATS_PER_BAR,
resolution: BeatResolution::Quarter,
playback_time: 0.0,
prev_playback_time: -1.0,
free_run_time: 0.0,
playback_time: Beats::ZERO,
prev_playback_time: Beats(-1.0),
free_run_time: Beats::ZERO,
inputs,
outputs,
parameters,
}
}
pub fn set_playback_time(&mut self, time: f64) {
pub fn set_playback_time(&mut self, time: Beats) {
self.playback_time = time;
}
@ -169,8 +170,8 @@ impl AudioNode for BeatNode {
let base_time = if paused { self.free_run_time } else { self.playback_time };
for i in 0..len {
let time = base_time + i as f64 * sample_period;
let beat_pos = time * beats_per_second;
// base_time is already in beats; advance by beats_per_second per second
let beat_pos = base_time.0 + i as f64 * beats_per_second * sample_period;
// Beat subdivision phase: 0→1 sawtooth
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32;
@ -188,13 +189,13 @@ impl AudioNode for BeatNode {
}
// Advance free-run time (always ticks, so it's ready when playback stops)
self.free_run_time += len as f64 * sample_period;
self.free_run_time += Beats(len as f64 * beats_per_second * sample_period);
}
fn reset(&mut self) {
self.playback_time = 0.0;
self.prev_playback_time = -1.0;
self.free_run_time = 0.0;
self.playback_time = Beats::ZERO;
self.prev_playback_time = Beats(-1.0);
self.free_run_time = Beats::ZERO;
}
fn node_type(&self) -> &str {
@ -211,9 +212,9 @@ impl AudioNode for BeatNode {
bpm: self.bpm,
beats_per_bar: self.beats_per_bar,
resolution: self.resolution,
playback_time: 0.0,
prev_playback_time: -1.0,
free_run_time: 0.0,
playback_time: Beats::ZERO,
prev_playback_time: Beats(-1.0),
free_run_time: Beats::ZERO,
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
parameters: self.parameters.clone(),

View File

@ -1,5 +1,6 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent;
use crate::time::Beats;
const PARAM_MODE: u32 = 0;
const PARAM_STEPS: u32 = 1;
@ -244,14 +245,14 @@ impl AudioNode for SequencerNode {
// Note-off for notes no longer active
for &note in &self.prev_active_notes {
if !new_notes.contains(&note) {
midi_outputs[0].push(MidiEvent::note_off(0.0, 0, note, 0));
midi_outputs[0].push(MidiEvent::note_off(Beats::ZERO, 0, note, 0));
}
}
// Note-on for newly active notes
for &note in &new_notes {
if !self.prev_active_notes.contains(&note) {
midi_outputs[0].push(MidiEvent::note_on(0.0, 0, note, self.velocity));
midi_outputs[0].push(MidiEvent::note_on(Beats::ZERO, 0, note, self.velocity));
}
}

View File

@ -348,7 +348,7 @@ impl AudioNode for VoiceAllocatorNode {
// Process this voice's graph with its MIDI events
// Note: playback_time is 0.0 since voice allocator doesn't track time
self.voice_instances[voice_idx].process(mix_slice, &midi_events, 0.0);
self.voice_instances[voice_idx].process(mix_slice, &midi_events, crate::time::Beats::ZERO);
// Auto-deactivate releasing voices that have gone silent
if voice_state.releasing {

View File

@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::f32::consts::PI;
use serde::{Deserialize, Serialize};
use crate::time::Seconds;
/// Windowed sinc interpolation for high-quality time stretching
/// This is stateless and can handle arbitrary fractional positions
@ -442,19 +443,20 @@ impl AudioClipPool {
}
/// Render audio from a file in the pool with high-quality windowed sinc interpolation
/// start_time_seconds: position in the audio file to start reading from (in seconds)
/// start_time: position in the audio file to start reading from (in seconds)
/// clip_read_ahead: per-clip-instance read-ahead buffer for compressed audio streaming
/// Returns the number of samples actually rendered
pub fn render_from_file(
&self,
pool_index: usize,
output: &mut [f32],
start_time_seconds: f64,
start_time: Seconds,
gain: f32,
engine_sample_rate: u32,
engine_channels: u32,
clip_read_ahead: Option<&super::disk_reader::ReadAheadBuffer>,
) -> usize {
let start_time_seconds = start_time.0;
let Some(audio_file) = self.files.get(pool_index) else {
return 0;
};

View File

@ -4,6 +4,8 @@ use super::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, Mi
use super::midi_pool::MidiClipPool;
use super::pool::AudioClipPool;
use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode};
use crate::tempo_map::TempoMap;
use crate::time::{Beats, Seconds};
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
@ -216,52 +218,6 @@ impl Project {
self.tracks.iter().map(|(&id, node)| (id, node))
}
/// After a BPM change, update MIDI clip durations, sync clip beats/frames, and rescale
/// automation keyframe times to preserve beat positions.
///
/// `from_bpm` is the BPM before the change (used to bootstrap beats on legacy data).
/// `to_bpm` is the new BPM.
/// `midi_durations` maps each MidiClipId to its new content duration in seconds.
/// Call this after the seconds positions have already been updated (e.g. via MoveClip).
pub fn apply_bpm_change(&mut self, from_bpm: f64, to_bpm: f64, fps: f64, midi_durations: &[(crate::audio::midi::MidiClipId, f64)]) {
for (_, track) in self.tracks.iter_mut() {
match track {
crate::audio::track::TrackNode::Audio(t) => {
for clip in &mut t.clips {
clip.sync_from_seconds(to_bpm, fps);
}
}
crate::audio::track::TrackNode::Midi(t) => {
// Rescale automation keyframe times in this track's graph
t.instrument_graph.apply_beats_to_automation_keyframes(from_bpm, to_bpm, fps);
// Update content durations first so internal_end is correct before sync
for instance in &mut t.clip_instances {
if let Some(&new_dur) = midi_durations.iter()
.find(|(id, _)| *id == instance.clip_id)
.map(|(_, d)| d)
{
let old_internal_dur = instance.internal_duration();
instance.internal_end = instance.internal_start + new_dur;
// Scale external_duration by the same ratio (works for both looping and non-looping)
if old_internal_dur > 1e-12 {
instance.external_duration = instance.external_duration * new_dur / old_internal_dur;
}
}
instance.sync_from_seconds(to_bpm, fps);
}
// Update pool clip durations
for &(clip_id, new_dur) in midi_durations {
if let Some(clip) = self.midi_clip_pool.get_clip_mut(clip_id) {
clip.duration = new_dur;
}
}
}
_ => {}
}
}
}
/// Get oscilloscope data from a node in a track's graph
pub fn get_oscilloscope_data(&self, track_id: TrackId, node_id: u32, sample_count: usize) -> Option<(Vec<f32>, Vec<f32>)> {
if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) {
@ -334,9 +290,9 @@ impl Project {
&mut self,
track_id: TrackId,
events: Vec<MidiEvent>,
duration: f64,
duration: Beats,
name: String,
external_start: f64,
external_start: Beats,
) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> {
// Verify track exists and is a MIDI track
if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) {
@ -369,11 +325,11 @@ impl Project {
/// Legacy method for backwards compatibility - creates clip and instance from old MidiClip format
pub fn add_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) -> Result<MidiClipInstanceId, &'static str> {
self.add_midi_clip_at(track_id, clip, 0.0)
self.add_midi_clip_at(track_id, clip, Beats::ZERO)
}
/// Add a MIDI clip to the pool and create an instance at the given timeline position
pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: f64) -> Result<MidiClipInstanceId, &'static str> {
pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) -> Result<MidiClipInstanceId, &'static str> {
// Add the clip to the pool (it already has events and duration)
let duration = clip.duration;
let clip_id = clip.id;
@ -417,7 +373,8 @@ impl Project {
output: &mut [f32],
audio_pool: &AudioClipPool,
buffer_pool: &mut BufferPool,
playhead_seconds: f64,
playhead_seconds: Seconds,
tempo_map: &TempoMap,
sample_rate: u32,
channels: u32,
live_only: bool,
@ -429,7 +386,7 @@ impl Project {
// Create initial render context
let ctx = RenderContext {
live_only,
..RenderContext::new(playhead_seconds, sample_rate, channels, output.len())
..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len())
};
// Render each root track (index-based to avoid clone)
@ -503,7 +460,7 @@ impl Project {
let mut track_buffer = buffer_pool.acquire();
track_buffer.resize(output.len(), 0.0);
track_buffer.fill(0.0);
track.render(&mut track_buffer, audio_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels);
track.render(&mut track_buffer, audio_pool, ctx);
// Accumulate peak level for VU metering (max over meter interval)
let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
track.peak_level = track.peak_level.max(buffer_peak);
@ -591,7 +548,7 @@ impl Project {
let mut graph_output = buffer_pool.acquire();
graph_output.resize(output.len(), 0.0);
graph_output.fill(0.0);
group.audio_graph.process(&mut graph_output, &[], ctx.playhead_seconds);
group.audio_graph.process(&mut graph_output, &[], ctx.playhead_beats());
for (out_sample, graph_sample) in output.iter_mut().zip(graph_output.iter()) {
*out_sample += graph_sample * group.volume;
@ -686,7 +643,7 @@ impl Project {
pub fn send_midi_note_on(&mut self, track_id: TrackId, note: u8, velocity: u8) {
// Queue the MIDI note-on event to the track's live MIDI queue
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
let event = MidiEvent::note_on(0.0, 0, note, velocity);
let event = MidiEvent::note_on(Beats::ZERO, 0, note, velocity);
track.queue_live_midi(event);
}
}
@ -695,7 +652,7 @@ impl Project {
pub fn send_midi_note_off(&mut self, track_id: TrackId, note: u8) {
// Queue the MIDI note-off event to the track's live MIDI queue
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
let event = MidiEvent::note_off(0.0, 0, note, 0);
let event = MidiEvent::note_off(Beats::ZERO, 0, note, 0);
track.queue_live_midi(event);
}
}

View File

@ -1,6 +1,7 @@
/// Audio recording system for capturing microphone input
use crate::audio::{ClipId, MidiClipId, TrackId};
use crate::io::{WavWriter, WaveformPeak};
use crate::time::{Beats, Seconds};
use std::collections::HashMap;
use std::path::PathBuf;
@ -18,8 +19,8 @@ pub struct RecordingState {
pub sample_rate: u32,
/// Number of channels
pub channels: u32,
/// Timeline start position in seconds
pub start_time: f64,
/// Timeline start position
pub start_time: Beats,
/// Total frames recorded
pub frames_written: usize,
/// Whether recording is currently paused
@ -45,7 +46,7 @@ impl RecordingState {
writer: WavWriter,
sample_rate: u32,
channels: u32,
start_time: f64,
start_time: Beats,
_flush_interval_seconds: f64, // No longer used - kept for API compatibility
) -> Self {
// Calculate frames per waveform peak
@ -130,9 +131,9 @@ impl RecordingState {
}
}
/// Get current recording duration in seconds
pub fn duration(&self) -> f64 {
self.frames_written as f64 / self.sample_rate as f64
/// Get current recording duration
pub fn duration(&self) -> Seconds {
Seconds(self.frames_written as f64 / self.sample_rate as f64)
}
/// Finalize the recording and return the temp file path, waveform, and audio data
@ -175,33 +176,23 @@ impl RecordingState {
/// Active MIDI note waiting for its noteOff event
#[derive(Debug, Clone)]
struct ActiveMidiNote {
/// MIDI note number (0-127)
note: u8,
/// Velocity (0-127)
velocity: u8,
/// Absolute time when note started (seconds)
start_time: f64,
start_time: Beats,
}
/// State of an active MIDI recording session
/// State of an active MIDI recording session.
pub struct MidiRecordingState {
/// Track being recorded to
pub track_id: TrackId,
/// MIDI clip ID
pub clip_id: MidiClipId,
/// Timeline start position in seconds
pub start_time: f64,
/// Currently active notes (noteOn without matching noteOff)
/// Maps note number to ActiveMidiNote
pub start_time: Beats,
active_notes: HashMap<u8, ActiveMidiNote>,
/// Completed notes ready to be added to clip
/// Format: (time_offset, note, velocity, duration)
pub completed_notes: Vec<(f64, u8, u8, f64)>,
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats
pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
}
impl MidiRecordingState {
/// Create a new MIDI recording state
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: f64) -> Self {
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: Beats) -> Self {
Self {
track_id,
clip_id,
@ -211,87 +202,62 @@ impl MidiRecordingState {
}
}
/// Handle a MIDI note on event
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: f64) {
self.active_notes.insert(note, ActiveMidiNote {
note,
velocity,
start_time: absolute_time,
});
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) {
self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time });
}
/// Handle a MIDI note off event
pub fn note_off(&mut self, note: u8, absolute_time: f64) {
// Find the matching noteOn
pub fn note_off(&mut self, note: u8, absolute_time: Beats) {
if let Some(active_note) = self.active_notes.remove(&note) {
// If the note was fully released before the recording start (e.g. during count-in
// pre-roll), discard it — only notes still held at the clip start are kept.
if absolute_time <= self.start_time {
return;
}
// Clamp note start to clip start: notes held across the recording boundary
// are treated as starting at the clip position.
let note_start = active_note.start_time.max(self.start_time);
let time_offset = note_start - self.start_time;
let duration = absolute_time - note_start;
eprintln!("[MIDI_RECORDING_STATE] Completing note {}: note_start={:.3}s, note_end={:.3}s, recording_start={:.3}s, time_offset={:.3}s, duration={:.3}s",
note, note_start, absolute_time, self.start_time, time_offset, duration);
self.completed_notes.push((
time_offset,
note_start - self.start_time,
active_note.note,
active_note.velocity,
duration,
absolute_time - note_start,
));
}
// If no matching noteOn found, ignore the noteOff
}
/// Get all completed notes
pub fn get_notes(&self) -> &[(f64, u8, u8, f64)] {
pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] {
&self.completed_notes
}
/// Get the number of completed notes
pub fn note_count(&self) -> usize {
self.completed_notes.len()
}
/// Get all completed notes plus currently-held notes with a provisional duration.
/// Used for live preview during recording so held notes appear immediately.
pub fn get_notes_with_active(&self, current_time: f64) -> Vec<(f64, u8, u8, f64)> {
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
let mut notes = self.completed_notes.clone();
for active in self.active_notes.values() {
let note_start = active.start_time.max(self.start_time);
let time_offset = note_start - self.start_time;
let provisional_dur = (current_time - note_start).max(0.0);
notes.push((time_offset, active.note, active.velocity, provisional_dur));
notes.push((
note_start - self.start_time,
active.note,
active.velocity,
(current_time - note_start).max(Beats::ZERO),
));
}
notes
}
/// Get the note numbers of all currently held (active) notes
pub fn active_note_numbers(&self) -> Vec<u8> {
self.active_notes.keys().copied().collect()
}
/// Close out all active notes at the given time
/// This should be called when stopping recording to end any held notes
pub fn close_active_notes(&mut self, end_time: f64) {
pub fn close_active_notes(&mut self, end_time: Beats) {
let active_notes: Vec<_> = self.active_notes.drain().collect();
for (_note_num, active_note) in active_notes {
let note_start = active_note.start_time.max(self.start_time);
let time_offset = note_start - self.start_time;
let duration = end_time - note_start;
self.completed_notes.push((
time_offset,
note_start - self.start_time,
active_note.note,
active_note.velocity,
duration,
end_time - note_start,
));
}
}

View File

@ -6,6 +6,8 @@ use super::node_graph::AudioGraph;
use super::node_graph::nodes::{AudioInputNode, AudioOutputNode};
use super::node_graph::preset::GraphPreset;
use super::pool::AudioClipPool;
use crate::tempo_map::TempoMap;
use crate::time::{Beats, Seconds};
use serde::{Serialize, Deserialize};
use std::collections::{HashMap, HashSet};
@ -23,10 +25,12 @@ pub type Track = AudioTrack;
/// Rendering context that carries timing information through the track hierarchy
///
/// This allows metatracks to transform time for their children (time stretch, offset, etc.)
#[derive(Debug, Clone, Copy)]
pub struct RenderContext {
#[derive(Clone, Copy)]
pub struct RenderContext<'a> {
/// Current playhead position in seconds (in transformed time)
pub playhead_seconds: f64,
pub playhead_seconds: Seconds,
/// Tempo map for beat ↔ second conversion
pub tempo_map: &'a TempoMap,
/// Audio sample rate
pub sample_rate: u32,
/// Number of channels
@ -41,16 +45,17 @@ pub struct RenderContext {
pub live_only: bool,
}
impl RenderContext {
/// Create a new render context
impl<'a> RenderContext<'a> {
pub fn new(
playhead_seconds: f64,
playhead_seconds: Seconds,
tempo_map: &'a TempoMap,
sample_rate: u32,
channels: u32,
buffer_size: usize,
) -> Self {
Self {
playhead_seconds,
tempo_map,
sample_rate,
channels,
buffer_size,
@ -59,15 +64,21 @@ impl RenderContext {
}
}
/// Get the duration of the buffer in seconds
pub fn buffer_duration(&self) -> f64 {
self.buffer_size as f64 / (self.sample_rate as f64 * self.channels as f64)
pub fn buffer_duration(&self) -> Seconds {
Seconds(self.buffer_size as f64 / (self.sample_rate as f64 * self.channels as f64))
}
/// Get the end time of the buffer
pub fn buffer_end(&self) -> f64 {
pub fn buffer_end(&self) -> Seconds {
self.playhead_seconds + self.buffer_duration()
}
pub fn playhead_beats(&self) -> Beats {
self.tempo_map.seconds_to_beats(self.playhead_seconds)
}
pub fn buffer_end_beats(&self) -> Beats {
self.tempo_map.seconds_to_beats(self.buffer_end())
}
}
/// Node in the track hierarchy - can be an audio track, MIDI track, or a metatrack
@ -170,14 +181,14 @@ pub struct Metatrack {
pub time_stretch: f32,
/// Pitch shift in semitones (for future implementation)
pub pitch_shift: f32,
/// Time offset in seconds (shift content forward/backward in time)
pub offset: f64,
/// Trim start: offset into the metatrack's internal content (seconds)
/// Time offset (shift content forward/backward in time)
pub offset: Seconds,
/// Trim start: offset into the metatrack's internal content
/// Children will see time starting from this point
pub trim_start: f64,
/// Trim end: offset into the metatrack's internal content (seconds)
pub trim_start: Seconds,
/// Trim end: offset into the metatrack's internal content
/// None means no end trim (play until content ends)
pub trim_end: Option<f64>,
pub trim_end: Option<Seconds>,
/// Automation lanes for this metatrack
pub automation_lanes: HashMap<AutomationLaneId, AutomationLane>,
next_automation_id: AutomationLaneId,
@ -231,8 +242,8 @@ impl Metatrack {
solo: false,
time_stretch: 1.0,
pitch_shift: 0.0,
offset: 0.0,
trim_start: 0.0,
offset: Seconds::ZERO,
trim_start: Seconds::ZERO,
trim_end: None,
automation_lanes: HashMap::new(),
next_automation_id: 0,
@ -493,7 +504,7 @@ impl Metatrack {
}
/// Evaluate automation at a specific time and return effective parameters
pub fn evaluate_automation_at_time(&self, time: f64) -> (f32, f32, f64) {
pub fn evaluate_automation_at_time(&self, time: Beats) -> (f32, f32, Seconds) {
let mut volume = self.volume;
let mut time_stretch = self.time_stretch;
let mut offset = self.offset;
@ -517,7 +528,7 @@ impl Metatrack {
}
ParameterId::TimeOffset => {
if let Some(automated_value) = lane.evaluate(time) {
offset = automated_value as f64;
offset = Seconds(automated_value as f64);
}
}
_ => {}
@ -561,7 +572,7 @@ impl Metatrack {
/// Check whether this metatrack should produce audio at the given parent time.
/// Returns false if the playhead is outside the trim window.
pub fn is_active_at_time(&self, parent_playhead: f64) -> bool {
pub fn is_active_at_time(&self, parent_playhead: Seconds) -> bool {
let local_time = (parent_playhead - self.offset) * self.time_stretch as f64;
if local_time < self.trim_start {
return false;
@ -580,7 +591,7 @@ impl Metatrack {
/// Time stretch affects how fast content plays: 0.5 = half speed, 2.0 = double speed
/// Offset shifts content forward/backward in time
/// Trim start offsets into the internal content
pub fn transform_context(&self, ctx: RenderContext) -> RenderContext {
pub fn transform_context<'a>(&self, ctx: RenderContext<'a>) -> RenderContext<'a> {
let mut transformed = ctx;
// Apply transformations in order:
@ -596,7 +607,7 @@ impl Metatrack {
let stretched = adjusted_playhead * self.time_stretch as f64;
// 3. Add trim_start so children see time starting from the trim point
// If trim_start=2.0, children start seeing time 2.0 when parent reaches offset
// If trim_start=2.0s, children start seeing time 2.0s when parent reaches offset
transformed.playhead_seconds = stretched + self.trim_start;
// Accumulate time stretch for nested metatracks
@ -772,13 +783,13 @@ impl MidiTrack {
// Send note-off for all 128 possible MIDI notes to silence the instrument
let mut note_offs = Vec::new();
for note in 0..128 {
note_offs.push(MidiEvent::note_off(0.0, 0, note, 0));
note_offs.push(MidiEvent::note_off(Beats::ZERO, 0, note, 0));
}
// Create a silent buffer to process the note-offs
let buffer_size = 512 * 2; // stereo
let mut silent_buffer = vec![0.0f32; buffer_size];
self.instrument_graph.process(&mut silent_buffer, &note_offs, 0.0);
self.instrument_graph.process(&mut silent_buffer, &note_offs, Beats::ZERO);
}
/// Queue a live MIDI event (from virtual keyboard or MIDI controller)
@ -805,17 +816,17 @@ impl MidiTrack {
let mut midi_events = Vec::new();
if !ctx.live_only {
let buffer_duration_seconds = output.len() as f64 / (ctx.sample_rate as f64 * ctx.channels as f64);
let buffer_end_seconds = ctx.playhead_seconds + buffer_duration_seconds;
let playhead_beats = ctx.playhead_beats();
let buffer_end_beats = ctx.buffer_end_beats();
// Collect MIDI events from all clip instances that overlap with current time range
// Collect MIDI events from all clip instances that overlap with current beat range
let mut currently_active = HashSet::new();
for instance in &self.clip_instances {
if instance.overlaps_range(ctx.playhead_seconds, buffer_end_seconds) {
if instance.overlaps_range(playhead_beats, buffer_end_beats) {
currently_active.insert(instance.id);
}
if let Some(clip) = midi_pool.get_clip(instance.clip_id) {
let events = instance.get_events_in_range(clip, ctx.playhead_seconds, buffer_end_seconds);
let events = instance.get_events_in_range(clip, playhead_beats, buffer_end_beats);
midi_events.extend(events);
}
}
@ -824,7 +835,7 @@ impl MidiTrack {
for prev_id in &self.prev_active_instances {
if !currently_active.contains(prev_id) {
for note in 0..128u8 {
midi_events.push(MidiEvent::note_off(ctx.playhead_seconds, 0, note, 0));
midi_events.push(MidiEvent::note_off(playhead_beats, 0, note, 0));
}
break;
}
@ -836,10 +847,10 @@ impl MidiTrack {
midi_events.extend(self.live_midi_queue.drain(..));
// Generate audio using instrument graph
self.instrument_graph.process(output, &midi_events, ctx.playhead_seconds);
self.instrument_graph.process(output, &midi_events, ctx.playhead_beats());
// Evaluate and apply automation (skip automation in live_only mode — no playhead to evaluate at)
let effective_volume = if ctx.live_only { self.volume } else { self.evaluate_automation_at_time(ctx.playhead_seconds) };
let effective_volume = if ctx.live_only { self.volume } else { self.evaluate_automation_at_time(ctx.playhead_beats()) };
// Apply track volume
for sample in output.iter_mut() {
@ -848,7 +859,7 @@ impl MidiTrack {
}
/// Evaluate automation at a specific time and return the effective volume
fn evaluate_automation_at_time(&self, time: f64) -> f32 {
fn evaluate_automation_at_time(&self, time: Beats) -> f32 {
let mut volume = self.volume;
// Check for volume automation
@ -1081,12 +1092,9 @@ impl AudioTrack {
&mut self,
output: &mut [f32],
pool: &AudioClipPool,
playhead_seconds: f64,
sample_rate: u32,
channels: u32,
ctx: RenderContext<'_>,
) -> usize {
let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64);
let buffer_end_seconds = playhead_seconds + buffer_duration_seconds;
let buffer_end = ctx.buffer_end();
// Split borrow: take clip_render_buffer out to avoid borrow conflict with &self methods
let mut clip_buffer = std::mem::take(&mut self.clip_render_buffer);
@ -1096,16 +1104,8 @@ impl AudioTrack {
// Render all active clip instances into the buffer
for clip in &self.clips {
// Check if clip overlaps with current buffer time range
if clip.external_start < buffer_end_seconds && clip.external_end() > playhead_seconds {
rendered += self.render_clip(
clip,
&mut clip_buffer,
pool,
playhead_seconds,
sample_rate,
channels,
);
if clip.external_start_secs(ctx.tempo_map) < buffer_end && clip.external_end_secs(ctx.tempo_map) > ctx.playhead_seconds {
rendered += self.render_clip(clip, &mut clip_buffer, pool, ctx);
}
}
@ -1123,13 +1123,13 @@ impl AudioTrack {
}
// Process through the effects graph (this will write to output buffer)
self.effects_graph.process(output, &[], playhead_seconds);
self.effects_graph.process(output, &[], ctx.playhead_beats());
// Put the buffer back for reuse next callback
self.clip_render_buffer = clip_buffer;
// Evaluate and apply automation
let effective_volume = self.evaluate_automation_at_time(playhead_seconds);
let effective_volume = self.evaluate_automation_at_time(ctx.playhead_beats());
// Apply track volume
for sample in output.iter_mut() {
@ -1140,7 +1140,7 @@ impl AudioTrack {
}
/// Evaluate automation at a specific time and return the effective volume
fn evaluate_automation_at_time(&self, time: f64) -> f32 {
fn evaluate_automation_at_time(&self, time: Beats) -> f32 {
let mut volume = self.volume;
// Check for volume automation
@ -1169,49 +1169,40 @@ impl AudioTrack {
clip: &AudioClipInstance,
output: &mut [f32],
pool: &AudioClipPool,
playhead_seconds: f64,
sample_rate: u32,
channels: u32,
ctx: RenderContext<'_>,
) -> usize {
let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64);
let buffer_end_seconds = playhead_seconds + buffer_duration_seconds;
let playhead = ctx.playhead_seconds;
let buffer_end = ctx.buffer_end();
let tempo_map = ctx.tempo_map;
let sample_rate = ctx.sample_rate;
let channels = ctx.channels;
// Determine the time range we need to render (intersection of buffer and clip external bounds)
let render_start_seconds = playhead_seconds.max(clip.external_start);
let render_end_seconds = buffer_end_seconds.min(clip.external_end());
let render_start = playhead.max(clip.external_start_secs(tempo_map));
let render_end = buffer_end.min(clip.external_end_secs(tempo_map));
// If no overlap, return early
if render_start_seconds >= render_end_seconds {
if render_start >= render_end {
return 0;
}
let internal_duration = clip.internal_duration();
if internal_duration <= 0.0 {
if internal_duration <= Seconds::ZERO {
return 0;
}
// Calculate combined gain
let combined_gain = clip.gain;
let mut total_rendered = 0;
// Process the render range sample by sample (or in chunks for efficiency)
// For looping clips, we need to handle wrap-around at the loop boundary
let samples_per_second = sample_rate as f64 * channels as f64;
// For now, render in a simpler way - iterate through the timeline range
// and use get_content_position for each sample position
let output_start_offset = ((render_start_seconds - playhead_seconds) * samples_per_second + 0.5) as usize;
let output_end_offset = ((render_end_seconds - playhead_seconds) * samples_per_second + 0.5) as usize;
let output_start_offset = ((render_start - playhead).0 * samples_per_second + 0.5) as usize;
let output_end_offset = ((render_end - playhead).0 * samples_per_second + 0.5) as usize;
if output_end_offset > output.len() || output_start_offset > output.len() {
return 0;
}
// If not looping, we can render in one chunk (more efficient)
if !clip.is_looping() {
// Simple case: no looping
let content_start = clip.get_content_position(render_start_seconds).unwrap_or(clip.internal_start);
if !clip.is_looping(tempo_map) {
let content_start = clip.get_content_position(render_start, tempo_map).unwrap_or(clip.internal_start);
let output_len = output.len();
let output_slice = &mut output[output_start_offset..output_end_offset.min(output_len)];
@ -1225,23 +1216,20 @@ impl AudioTrack {
clip.read_ahead.as_deref(),
);
} else {
// Looping case: need to handle wrap-around at loop boundaries
// Render in segments, one per loop iteration
let mut timeline_pos = render_start_seconds;
// Looping case: handle wrap-around at loop boundaries
let mut timeline_pos = render_start;
let mut output_offset = output_start_offset;
while timeline_pos < render_end_seconds && output_offset < output.len() {
// Calculate position within the loop
let relative_pos = timeline_pos - clip.external_start;
let loop_offset = relative_pos % internal_duration;
let content_pos = clip.internal_start + loop_offset;
while timeline_pos < render_end && output_offset < output.len() {
let relative_pos = timeline_pos - clip.external_start_secs(tempo_map);
let loop_offset = relative_pos.0 % internal_duration.0;
let content_pos = clip.internal_start + Seconds(loop_offset);
// Calculate how much we can render before hitting the loop boundary
let time_to_loop_end = internal_duration - loop_offset;
let time_to_render_end = render_end_seconds - timeline_pos;
let time_to_loop_end = Seconds(internal_duration.0 - loop_offset);
let time_to_render_end = render_end - timeline_pos;
let chunk_duration = time_to_loop_end.min(time_to_render_end);
let chunk_samples = (chunk_duration * samples_per_second) as usize;
let chunk_samples = (chunk_duration.0 * samples_per_second) as usize;
let chunk_samples = chunk_samples.min(output.len() - output_offset);
if chunk_samples == 0 {
@ -1262,7 +1250,7 @@ impl AudioTrack {
total_rendered += rendered;
output_offset += chunk_samples;
timeline_pos += chunk_duration;
timeline_pos = timeline_pos + chunk_duration;
}
}

View File

@ -6,6 +6,7 @@ use crate::audio::midi::MidiEvent;
use crate::audio::buffer_pool::BufferPoolStats;
use crate::audio::node_graph::nodes::LoopMode;
use crate::io::WaveformPeak;
use crate::time::{Beats, Seconds};
/// Commands sent from UI/control thread to audio thread
#[derive(Debug, Clone)]
@ -113,7 +114,7 @@ pub enum Command {
// Recording commands
/// Start recording on a track (track_id, start_time)
StartRecording(TrackId, f64),
StartRecording(TrackId, Beats),
/// Stop the current recording
StopRecording,
/// Pause the current recording
@ -123,7 +124,7 @@ pub enum Command {
// MIDI Recording commands
/// Start MIDI recording on a track (track_id, clip_id, start_time)
StartMidiRecording(TrackId, MidiClipId, f64),
StartMidiRecording(TrackId, MidiClipId, Beats),
/// Stop the current MIDI recording
StopMidiRecording,
@ -144,11 +145,6 @@ pub enum Command {
SetMetronomeEnabled(bool),
/// Set project tempo and time signature (bpm, (numerator, denominator))
SetTempo(f32, (u32, u32)),
/// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale
/// automation keyframe times to preserve beat positions.
/// (from_bpm, to_bpm, fps, midi_durations: Vec<(clip_id, new_duration_seconds)>)
ApplyBpmChange(f64, f64, f64, Vec<(MidiClipId, f64)>),
// Node graph commands
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
GraphAddNode(TrackId, String, f32, f32),
@ -290,7 +286,7 @@ pub enum AudioEvent {
/// Recording started (track_id, clip_id, sample_rate, channels)
RecordingStarted(TrackId, ClipId, u32, u32),
/// Recording progress update (clip_id, current_duration)
RecordingProgress(ClipId, f64),
RecordingProgress(ClipId, Seconds),
/// Recording stopped (clip_id, pool_index, waveform)
RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
/// Recording error (error_message)
@ -298,8 +294,8 @@ pub enum AudioEvent {
/// MIDI recording stopped (track_id, clip_id, note_count)
MidiRecordingStopped(TrackId, MidiClipId, usize),
/// MIDI recording progress (track_id, clip_id, duration, notes)
/// Notes format: (start_time, note, velocity, duration)
MidiRecordingProgress(TrackId, MidiClipId, f64, Vec<(f64, u8, u8, f64)>),
/// Notes format: (start_time, note, velocity, duration) — all times in beats
MidiRecordingProgress(TrackId, MidiClipId, Beats, Vec<(Beats, u8, u8, Beats)>),
/// Project has been reset
ProjectReset,
/// MIDI note started playing (note, velocity)

View File

@ -1,164 +1,69 @@
use crate::audio::midi::{MidiClip, MidiClipId, MidiEvent};
use crate::time::Beats;
use std::fs;
use std::path::Path;
/// Load a MIDI file and convert it to a MidiClip
/// Load a MIDI file and convert it to a MidiClip.
///
/// Event timestamps are stored as beat positions: `tick / ticks_per_beat`.
/// Tempo events in the MIDI file only affect wall-clock playback speed — not the
/// beat grid — so they are ignored here.
pub fn load_midi_file<P: AsRef<Path>>(
path: P,
clip_id: MidiClipId,
_sample_rate: u32,
) -> Result<MidiClip, String> {
// Read the MIDI file
let data = fs::read(path.as_ref()).map_err(|e| format!("Failed to read MIDI file: {}", e))?;
// Parse with midly
let smf = midly::Smf::parse(&data).map_err(|e| format!("Failed to parse MIDI file: {}", e))?;
// Convert timing to ticks per second
let ticks_per_beat = match smf.header.timing {
midly::Timing::Metrical(tpb) => tpb.as_int() as f64,
midly::Timing::Timecode(fps, subframe) => {
// For timecode, calculate equivalent ticks per second
// Timecode-based MIDI: treat subframes as ticks per beat
(fps.as_f32() * subframe as f32) as f64
}
};
// First pass: collect all events with their tick positions and tempo changes
#[derive(Debug)]
enum RawEvent {
Midi {
tick: u64,
channel: u8,
message: midly::MidiMessage,
},
Tempo {
tick: u64,
microseconds_per_beat: f64,
},
}
let mut events = Vec::new();
let mut max_tick = 0u64;
let mut raw_events = Vec::new();
let mut max_time_ticks = 0u64;
// Collect all events from all tracks with their absolute tick positions
for track in &smf.tracks {
let mut current_tick = 0u64;
for event in track {
current_tick += event.delta.as_int() as u64;
max_time_ticks = max_time_ticks.max(current_tick);
max_tick = max_tick.max(current_tick);
let timestamp = Beats(current_tick as f64 / ticks_per_beat);
match event.kind {
midly::TrackEventKind::Midi { channel, message } => {
raw_events.push(RawEvent::Midi {
tick: current_tick,
channel: channel.as_int(),
message,
});
}
midly::TrackEventKind::Meta(midly::MetaMessage::Tempo(tempo)) => {
raw_events.push(RawEvent::Tempo {
tick: current_tick,
microseconds_per_beat: tempo.as_int() as f64,
});
}
_ => {
// Ignore other meta events
}
}
}
}
// Sort all events by tick position
raw_events.sort_by_key(|e| match e {
RawEvent::Midi { tick, .. } => *tick,
RawEvent::Tempo { tick, .. } => *tick,
});
// Second pass: convert ticks to timestamps with proper tempo tracking
let mut events = Vec::new();
let mut microseconds_per_beat = 500000.0; // Default: 120 BPM
let mut last_tick = 0u64;
let mut accumulated_time = 0.0; // Time in seconds
for raw_event in raw_events {
match raw_event {
RawEvent::Tempo {
tick,
microseconds_per_beat: new_tempo,
} => {
// Update accumulated time up to this tempo change
let delta_ticks = tick - last_tick;
let delta_time = (delta_ticks as f64 / ticks_per_beat)
* (microseconds_per_beat / 1_000_000.0);
accumulated_time += delta_time;
last_tick = tick;
// Update tempo for future events
microseconds_per_beat = new_tempo;
}
RawEvent::Midi {
tick,
channel,
message,
} => {
// Calculate time for this event
let delta_ticks = tick - last_tick;
let delta_time = (delta_ticks as f64 / ticks_per_beat)
* (microseconds_per_beat / 1_000_000.0);
accumulated_time += delta_time;
last_tick = tick;
// Store timestamp in seconds (sample-rate independent)
let timestamp = accumulated_time;
match message {
midly::MidiMessage::NoteOn { key, vel } => {
let velocity = vel.as_int();
if velocity > 0 {
events.push(MidiEvent::note_on(
timestamp,
channel,
key.as_int(),
velocity,
));
} else {
events.push(MidiEvent::note_off(timestamp, channel, key.as_int(), 64));
let ch = channel.as_int();
match message {
midly::MidiMessage::NoteOn { key, vel } => {
let velocity = vel.as_int();
if velocity > 0 {
events.push(MidiEvent::note_on(timestamp, ch, key.as_int(), velocity));
} else {
events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), 64));
}
}
}
midly::MidiMessage::NoteOff { key, vel } => {
events.push(MidiEvent::note_off(
timestamp,
channel,
key.as_int(),
vel.as_int(),
));
}
midly::MidiMessage::Controller { controller, value } => {
let status = 0xB0 | channel;
events.push(MidiEvent::new(
timestamp,
status,
controller.as_int(),
value.as_int(),
));
}
_ => {
// Ignore other MIDI messages
midly::MidiMessage::NoteOff { key, vel } => {
events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), vel.as_int()));
}
midly::MidiMessage::Controller { controller, value } => {
let status = 0xB0 | ch;
events.push(MidiEvent::new(timestamp, status, controller.as_int(), value.as_int()));
}
_ => {}
}
}
_ => {} // Tempo and other meta events don't affect beat positions
}
}
}
// Calculate final clip duration
let final_delta_ticks = max_time_ticks - last_tick;
let final_delta_time =
(final_delta_ticks as f64 / ticks_per_beat) * (microseconds_per_beat / 1_000_000.0);
let duration_seconds = accumulated_time + final_delta_time;
// Create the MIDI clip (content only, positioning happens when creating instance)
let clip = MidiClip::new(clip_id, events, duration_seconds, "Imported MIDI".to_string());
let duration = Beats(max_tick as f64 / ticks_per_beat);
let clip = MidiClip::new(clip_id, events, duration, "Imported MIDI".to_string());
Ok(clip)
}

View File

@ -6,6 +6,8 @@
pub mod audio;
pub mod command;
pub mod time;
pub mod tempo_map;
pub mod dsp;
pub mod effects;
pub mod io;
@ -18,6 +20,8 @@ pub use audio::{
TrackNode,
};
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
pub use time::{Beats, Seconds};
pub use tempo_map::{TempoEntry, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
pub use command::{AudioEvent, Command, OscilloscopeData};
pub use command::types::AutomationKeyframeData;
pub use io::{load_midi_file, AudioFile, WaveformChunk, WaveformChunkKey, WaveformPeak, WavWriter};
@ -90,7 +94,7 @@ impl AudioSystem {
// Create input ringbuffer for recording (large buffer for audio samples)
// Buffer size: 10 seconds of audio at 48kHz stereo = 48000 * 2 * 10 = 960000 samples
let input_buffer_size = (sample_rate * channels * 10) as usize;
let (mut input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size);
let (input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size);
// Create mirror ringbuffer for streaming recorded audio to UI (live waveform)
let (mirror_tx, mirror_rx) = rtrb::RingBuffer::new(input_buffer_size);

View File

@ -0,0 +1,262 @@
//! TempoMap — beats ↔ seconds conversion with variable tempo support.
//!
//! Positions are stored in **beats** throughout the project; `TempoMap` converts
//! between beats and seconds at render / scheduling time.
//!
//! # Format
//! `entries` is a sorted `Vec<TempoEntry>` where each entry marks where a new
//! constant BPM segment begins. Step interpolation is used: BPM is constant
//! from `entry.beat` until the next entry. The first entry must always have
//! `beat == 0.0`.
//!
//! # Sequential-access optimisation
//! An `AtomicUsize` caches the index of the last segment visited by
//! `beats_to_seconds`. When calls are in ascending order (the common case when
//! walking events in order) the scan starts from the cached index instead of
//! the beginning, giving amortised O(1) behaviour.
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::time::{Beats, Seconds};
/// A single tempo segment: from `beat` onwards the tempo is `bpm`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TempoEntry {
/// Start of this tempo segment in beats (quarter-note beats).
pub beat: f64,
/// Tempo for this segment in beats per minute.
pub bpm: f64,
/// Cumulative seconds elapsed at the start of this segment.
/// **Derived** — not serialised; call [`TempoMap::rebuild_seconds`] after any
/// mutation or after deserialization.
#[serde(skip, default)]
pub seconds: f64,
}
/// A piecewise-constant tempo map used to convert between beats and seconds.
#[derive(Debug, Serialize, Deserialize)]
pub struct TempoMap {
/// Sorted list of tempo segments. Must always have at least one entry at beat 0.
pub entries: Vec<TempoEntry>,
/// Sequential-access cache: index of the last segment used by `beats_to_seconds`.
#[serde(skip, default)]
last_index: AtomicUsize,
}
impl Clone for TempoMap {
fn clone(&self) -> Self {
Self {
entries: self.entries.clone(),
last_index: AtomicUsize::new(self.last_index.load(Ordering::Relaxed)),
}
}
}
impl Default for TempoMap {
fn default() -> Self {
Self::constant(120.0)
}
}
impl TempoMap {
/// Create a constant-tempo map.
pub fn constant(bpm: f64) -> Self {
Self {
entries: vec![TempoEntry { beat: 0.0, bpm, seconds: 0.0 }],
last_index: AtomicUsize::new(0),
}
}
/// Rebuild the `seconds` field on every entry from scratch.
/// **Must be called** after any mutation (add/remove/reorder entry) and
/// after deserialization.
pub fn rebuild_seconds(&mut self) {
let mut cumulative = 0.0_f64;
for i in 0..self.entries.len() {
self.entries[i].seconds = cumulative;
if i + 1 < self.entries.len() {
let span_beats = self.entries[i + 1].beat - self.entries[i].beat;
cumulative += span_beats * 60.0 / self.entries[i].bpm;
}
}
self.last_index.store(0, Ordering::Relaxed);
}
/// Return the BPM active at `beat`.
pub fn bpm_at(&self, beat: Beats) -> f64 {
let idx = self.entries.partition_point(|e| e.beat <= beat.0).saturating_sub(1);
self.entries[idx.max(0)].bpm
}
/// Convert beats to seconds using the tempo map.
///
/// Uses the sequential cache: if `beat` is at or after the last cached
/// segment, the scan starts there instead of from the beginning.
pub fn beats_to_seconds(&self, beat: Beats) -> Seconds {
if beat.0 <= 0.0 {
return Seconds::ZERO;
}
let n = self.entries.len();
let cached = self.last_index.load(Ordering::Relaxed).min(n.saturating_sub(1));
let start = if beat.0 >= self.entries[cached].beat { cached } else { 0 };
let mut idx = start;
while idx + 1 < n && self.entries[idx + 1].beat <= beat.0 {
idx += 1;
}
self.last_index.store(idx, Ordering::Relaxed);
let entry = &self.entries[idx];
Seconds(entry.seconds + (beat.0 - entry.beat) * 60.0 / entry.bpm)
}
/// Convert seconds to beats using binary search on the cached `seconds` offsets.
pub fn seconds_to_beats(&self, seconds: Seconds) -> Beats {
if seconds.0 <= 0.0 {
return Beats::ZERO;
}
let n = self.entries.len();
let idx = self.entries.partition_point(|e| e.seconds <= seconds.0).saturating_sub(1);
let idx = idx.min(n - 1);
let entry = &self.entries[idx];
Beats(entry.beat + (seconds.0 - entry.seconds) * entry.bpm / 60.0)
}
/// Global BPM — the BPM of the first entry (at beat 0).
pub fn global_bpm(&self) -> f64 {
self.entries[0].bpm
}
/// Set the global BPM (first entry). Rebuilds seconds.
pub fn set_global_bpm(&mut self, bpm: f64) {
self.entries[0].bpm = bpm;
self.rebuild_seconds();
}
/// Convert local beats to parent time units (raw `f64`).
///
/// At the root level the result is absolute seconds. Inside a nested
/// group the result is the *parent group's* beats — the caller is
/// responsible for interpreting the units correctly.
///
/// This is the same arithmetic as `beats_to_seconds` but without the
/// `Seconds` newtype so it can be composed across multiple levels.
pub fn transform(&self, beat: f64) -> f64 {
if beat <= 0.0 {
return 0.0;
}
let n = self.entries.len();
let cached = self.last_index.load(Ordering::Relaxed).min(n.saturating_sub(1));
let start = if beat >= self.entries[cached].beat { cached } else { 0 };
let mut idx = start;
while idx + 1 < n && self.entries[idx + 1].beat <= beat {
idx += 1;
}
self.last_index.store(idx, Ordering::Relaxed);
let entry = &self.entries[idx];
entry.seconds + (beat - entry.beat) * 60.0 / entry.bpm
}
/// Inverse of [`transform`]: convert parent time units back to local beats.
pub fn inverse_transform(&self, parent_time: f64) -> f64 {
if parent_time <= 0.0 {
return 0.0;
}
let n = self.entries.len();
let idx = self.entries.partition_point(|e| e.seconds <= parent_time).saturating_sub(1);
let idx = idx.min(n - 1);
let entry = &self.entries[idx];
entry.beat + (parent_time - entry.seconds) * entry.bpm / 60.0
}
/// Build a `TempoMap` from a list of `(beat, bpm)` keyframes.
/// Always inserts a beat-0 entry using the first keyframe's BPM (or 120.0 if empty).
pub fn from_keyframes(keyframes: &[(f64, f64)]) -> Self {
if keyframes.is_empty() {
return Self::constant(120.0);
}
let mut entries: Vec<TempoEntry> = keyframes
.iter()
.map(|&(beat, bpm)| TempoEntry { beat, bpm, seconds: 0.0 })
.collect();
entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap());
if entries[0].beat > 0.0 {
entries.insert(0, TempoEntry { beat: 0.0, bpm: entries[0].bpm, seconds: 0.0 });
}
let mut map = Self { entries, last_index: AtomicUsize::new(0) };
map.rebuild_seconds();
map
}
}
/// Convert local beats through a stack of tempo maps to absolute seconds.
///
/// `stack[0]` is the outermost map (root/master); `stack[last]` is the
/// innermost (deepest group). Conversion applies maps from innermost to
/// outermost — each map's output is the input to the next outer map.
///
/// ```text
/// clip_beats → [stack[last]] → … → [stack[0]] → absolute seconds
/// ```
pub fn beats_to_seconds_stack(beat: f64, stack: &[&TempoMap]) -> f64 {
let mut t = beat;
for tm in stack.iter().rev() {
t = tm.transform(t);
}
t
}
/// Inverse of [`beats_to_seconds_stack`]: absolute seconds → local beats.
pub fn seconds_to_beats_stack(seconds: f64, stack: &[&TempoMap]) -> f64 {
let mut t = seconds;
for tm in stack.iter() {
t = tm.inverse_transform(t);
}
t
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_bpm_round_trip() {
let m = TempoMap::constant(120.0);
assert!((m.beats_to_seconds(Beats(2.0)).0 - 1.0).abs() < 1e-9);
assert!((m.seconds_to_beats(Seconds(1.0)).0 - 2.0).abs() < 1e-9);
}
#[test]
fn variable_tempo() {
let m = TempoMap::from_keyframes(&[(0.0, 120.0), (4.0, 60.0)]);
assert!((m.beats_to_seconds(Beats(4.0)).0 - 2.0).abs() < 1e-9);
assert!((m.beats_to_seconds(Beats(5.0)).0 - 3.0).abs() < 1e-9);
assert!((m.seconds_to_beats(Seconds(3.0)).0 - 5.0).abs() < 1e-9);
}
#[test]
fn stack_composition() {
// Root: 120 BPM (1 beat = 0.5s)
// Group: 60 BPM (1 local beat = 1 "parent beat" worth of time)
// Clip at local beat 2.0 → 2 parent beats → 1.0s absolute
let root = TempoMap::constant(120.0);
let group = TempoMap::constant(60.0);
let stack: Vec<&TempoMap> = vec![&root, &group];
let secs = beats_to_seconds_stack(2.0, &stack);
// group.transform(2.0) = 2.0*60/60 = 2.0 parent_beats
// root.transform(2.0) = 2.0*60/120 = 1.0s
assert!((secs - 1.0).abs() < 1e-9, "got {secs}");
let beats = seconds_to_beats_stack(1.0, &stack);
assert!((beats - 2.0).abs() < 1e-9, "got {beats}");
}
#[test]
fn sequential_cache() {
let m = TempoMap::constant(120.0);
for i in 0..10 {
let secs = m.beats_to_seconds(Beats(i as f64));
assert!((secs.0 - i as f64 * 0.5).abs() < 1e-9);
}
}
}

125
daw-backend/src/time.rs Normal file
View File

@ -0,0 +1,125 @@
/// Strongly-typed time units to prevent accidental beats/seconds confusion.
///
/// Convert between the two using `TempoMap::beats_to_seconds` / `TempoMap::seconds_to_beats`.
/// All internal scheduling and clip positions use `Beats`; only audio rendering
/// (sample offsets, file seeks) uses `Seconds`.
use serde::{Deserialize, Serialize};
use std::ops::{Add, AddAssign, Div, Mul, Neg, Rem, Sub, SubAssign};
/// A time position or duration expressed in **beats** (quarter-note beats).
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Beats(pub f64);
/// A time position or duration expressed in **seconds**.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Seconds(pub f64);
impl Beats {
pub const ZERO: Self = Self(0.0);
pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) }
pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) }
pub fn abs(self) -> Self { Self(self.0.abs()) }
pub fn ceil(self) -> Self { Self(self.0.ceil()) }
pub fn floor(self) -> Self { Self(self.0.floor()) }
pub fn beats_to_f64(self) -> f64 { self.0 }
}
impl Seconds {
pub const ZERO: Self = Self(0.0);
pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) }
pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) }
pub fn abs(self) -> Self { Self(self.0.abs()) }
pub fn seconds_to_f64(self) -> f64 { self.0 }
}
// --- Beats arithmetic ---
impl Add for Beats {
type Output = Self;
fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) }
}
impl Sub for Beats {
type Output = Self;
fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) }
}
impl Mul<f64> for Beats {
type Output = Self;
fn mul(self, rhs: f64) -> Self { Self(self.0 * rhs) }
}
impl Div<f64> for Beats {
type Output = Self;
fn div(self, rhs: f64) -> Self { Self(self.0 / rhs) }
}
/// Beats / Beats = dimensionless ratio (f64)
impl Div<Beats> for Beats {
type Output = f64;
fn div(self, rhs: Beats) -> f64 { self.0 / rhs.0 }
}
impl Rem for Beats {
type Output = Self;
fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) }
}
impl Neg for Beats {
type Output = Self;
fn neg(self) -> Self { Self(-self.0) }
}
impl AddAssign for Beats {
fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; }
}
impl SubAssign for Beats {
fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; }
}
// --- Seconds arithmetic ---
impl Add for Seconds {
type Output = Self;
fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) }
}
impl Sub for Seconds {
type Output = Self;
fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) }
}
impl Mul<f64> for Seconds {
type Output = Self;
fn mul(self, rhs: f64) -> Self { Self(self.0 * rhs) }
}
impl Div<f64> for Seconds {
type Output = Self;
fn div(self, rhs: f64) -> Self { Self(self.0 / rhs) }
}
/// Seconds / Seconds = dimensionless ratio (f64)
impl Div<Seconds> for Seconds {
type Output = f64;
fn div(self, rhs: Seconds) -> f64 { self.0 / rhs.0 }
}
impl Rem for Seconds {
type Output = Self;
fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) }
}
impl Neg for Seconds {
type Output = Self;
fn neg(self) -> Self { Self(-self.0) }
}
impl AddAssign for Seconds {
fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; }
}
impl SubAssign for Seconds {
fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; }
}
impl std::fmt::Display for Beats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::fmt::Display for Seconds {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

View File

@ -859,7 +859,7 @@ fn execute_command(
for event in &midi_clip.events {
let status = event.status & 0xF0;
let time_seconds = event.timestamp as f64 / sample_rate;
let time_seconds = event.timestamp.beats_to_f64() / sample_rate;
match status {
0x90 if event.data2 > 0 => {
@ -878,7 +878,7 @@ fn execute_command(
}
// Add to local UI state with note data
app.add_clip(track_id, clip_id, start_time, duration, file_path.to_string(), notes);
app.add_clip(track_id, clip_id, start_time, duration.beats_to_f64(), file_path.to_string(), notes);
app.next_clip_id += 1;
// Send to audio engine with the start_time (clip content is separate from timeline position)

View File

@ -79,8 +79,6 @@ impl Action for AddClipInstanceAction {
if let Some(valid_start) = adjusted_start {
// Update instance to use the valid position
self.clip_instance.timeline_start = valid_start;
let (bpm, fps) = (document.bpm, document.framerate);
self.clip_instance.sync_from_seconds(bpm, fps);
} else {
// No valid position found - reject the operation
return Err("Cannot add clip: no valid position found on layer (layer is full)".to_string());
@ -210,10 +208,10 @@ impl Action for AddClipInstanceAction {
let instance = daw_backend::MidiClipInstance::new(
0, // Instance ID will be assigned by backend
*midi_clip_id,
internal_start,
internal_end,
external_start,
external_duration,
daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end),
daw_backend::Beats(external_start),
daw_backend::Beats(external_duration),
);
// Send query to add instance and get instance ID

View File

@ -1,248 +1,49 @@
//! Change BPM action
//! Change BPM / Tempo action
//!
//! Atomically changes the document BPM and rescales all clip instance positions and
//! MIDI event timestamps so that beat positions are preserved (Measures mode behaviour).
//! Atomically swaps the document `TempoMap`, sending the new map to the engine.
//! All clip and note positions are stored in **beats** so no position rescaling
//! is required — only the TempoMap entry changes.
use crate::action::{Action, BackendContext};
use crate::clip::ClipInstance;
use crate::document::Document;
use crate::layer::AnyLayer;
use std::collections::HashMap;
use uuid::Uuid;
use crate::tempo_map::TempoMap;
/// Snapshot of all timing fields on a `ClipInstance`
/// Action that changes the global BPM by replacing the tempo map.
#[derive(Clone)]
struct TimingFields {
timeline_start: f64,
timeline_start_beats: f64,
timeline_start_frames: f64,
trim_start: f64,
trim_start_beats: f64,
trim_start_frames: f64,
trim_end: Option<f64>,
trim_end_beats: Option<f64>,
trim_end_frames: Option<f64>,
timeline_duration: Option<f64>,
timeline_duration_beats: Option<f64>,
timeline_duration_frames: Option<f64>,
}
impl TimingFields {
fn from_instance(ci: &ClipInstance) -> Self {
Self {
timeline_start: ci.timeline_start,
timeline_start_beats: ci.timeline_start_beats,
timeline_start_frames: ci.timeline_start_frames,
trim_start: ci.trim_start,
trim_start_beats: ci.trim_start_beats,
trim_start_frames: ci.trim_start_frames,
trim_end: ci.trim_end,
trim_end_beats: ci.trim_end_beats,
trim_end_frames: ci.trim_end_frames,
timeline_duration: ci.timeline_duration,
timeline_duration_beats: ci.timeline_duration_beats,
timeline_duration_frames: ci.timeline_duration_frames,
}
}
fn apply_to(&self, ci: &mut ClipInstance) {
ci.timeline_start = self.timeline_start;
ci.timeline_start_beats = self.timeline_start_beats;
ci.timeline_start_frames = self.timeline_start_frames;
ci.trim_start = self.trim_start;
ci.trim_start_beats = self.trim_start_beats;
ci.trim_start_frames = self.trim_start_frames;
ci.trim_end = self.trim_end;
ci.trim_end_beats = self.trim_end_beats;
ci.trim_end_frames = self.trim_end_frames;
ci.timeline_duration = self.timeline_duration;
ci.timeline_duration_beats = self.timeline_duration_beats;
ci.timeline_duration_frames = self.timeline_duration_frames;
}
}
#[derive(Clone)]
struct ClipTimingSnapshot {
layer_id: Uuid,
instance_id: Uuid,
old_fields: TimingFields,
new_fields: TimingFields,
}
#[derive(Clone)]
struct MidiClipSnapshot {
layer_id: Uuid,
midi_clip_id: u32,
clip_id: Uuid,
old_clip_duration: f64,
new_clip_duration: f64,
old_events: Vec<daw_backend::audio::midi::MidiEvent>,
new_events: Vec<daw_backend::audio::midi::MidiEvent>,
}
/// Action that atomically changes BPM and rescales all clip/note positions to preserve beats
pub struct ChangeBpmAction {
old_bpm: f64,
new_bpm: f64,
old_map: TempoMap,
new_map: TempoMap,
time_sig: (u32, u32),
clip_snapshots: Vec<ClipTimingSnapshot>,
midi_snapshots: Vec<MidiClipSnapshot>,
}
impl ChangeBpmAction {
/// Build the action, computing new positions for all clip instances and MIDI events.
///
/// `midi_event_cache` maps backend MIDI clip ID → current event list.
pub fn new(
old_bpm: f64,
new_bpm: f64,
document: &Document,
midi_event_cache: &HashMap<u32, Vec<daw_backend::audio::midi::MidiEvent>>,
) -> Self {
let fps = document.framerate;
let time_sig = (
document.time_signature.numerator,
document.time_signature.denominator,
);
let mut clip_snapshots: Vec<ClipTimingSnapshot> = Vec::new();
let mut midi_snapshots: Vec<MidiClipSnapshot> = Vec::new();
// Collect MIDI clip IDs we've already snapshotted (avoid duplicates)
let mut seen_midi_clips: std::collections::HashSet<u32> = std::collections::HashSet::new();
for layer in document.all_layers() {
let layer_id = layer.id();
let clip_instances: &[ClipInstance] = match layer {
AnyLayer::Vector(vl) => &vl.clip_instances,
AnyLayer::Audio(al) => &al.clip_instances,
AnyLayer::Video(vl) => &vl.clip_instances,
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
};
for ci in clip_instances {
let old_fields = TimingFields::from_instance(ci);
// Compute new fields: beats are canonical, recompute seconds + frames.
// Guard: if timeline_start_beats was never populated (clips added without
// sync_from_seconds), derive beats from seconds before applying.
let mut new_ci = ci.clone();
if new_ci.timeline_start_beats == 0.0 && new_ci.timeline_start.abs() > 1e-9 {
new_ci.sync_from_seconds(old_bpm, fps);
}
new_ci.apply_beats(new_bpm, fps);
let new_fields = TimingFields::from_instance(&new_ci);
clip_snapshots.push(ClipTimingSnapshot {
layer_id,
instance_id: ci.id,
old_fields,
new_fields,
});
// If this is a MIDI clip on an audio layer, collect MIDI events + rescale duration.
// Always snapshot the clip (even if empty) so clip.duration is rescaled.
if let AnyLayer::Audio(_) = layer {
if let Some(audio_clip) = document.get_audio_clip(&ci.clip_id) {
use crate::clip::AudioClipType;
if let AudioClipType::Midi { midi_clip_id } = &audio_clip.clip_type {
let midi_id = *midi_clip_id;
if !seen_midi_clips.contains(&midi_id) {
seen_midi_clips.insert(midi_id);
let old_clip_duration = audio_clip.duration;
let new_clip_duration = old_clip_duration * old_bpm / new_bpm;
// Use cached events if present; empty vec for clips with no events yet.
let old_events = midi_event_cache.get(&midi_id).cloned().unwrap_or_default();
let new_events: Vec<_> = old_events.iter().map(|ev| {
let mut e = ev.clone();
// Ensure beats are populated before using them as canonical.
// Events created before triple-rep (e.g. from recording)
// have timestamp_beats == 0.0 — sync from seconds first.
if e.timestamp_beats == 0.0 && e.timestamp.abs() > 1e-9 {
e.sync_from_seconds(old_bpm, fps);
}
e.apply_beats(new_bpm, fps);
e
}).collect();
midi_snapshots.push(MidiClipSnapshot {
layer_id,
midi_clip_id: midi_id,
clip_id: ci.clip_id,
old_clip_duration,
new_clip_duration,
old_events,
new_events,
});
}
}
}
}
}
}
Self {
old_bpm,
new_bpm,
time_sig,
clip_snapshots,
midi_snapshots,
}
/// Build the action from the current document state and a desired new BPM.
pub fn new(new_bpm: f64, document: &Document) -> Self {
let old_map = document.tempo_map().clone();
let mut new_map = old_map.clone();
new_map.set_global_bpm(new_bpm);
let time_sig = (document.time_signature.numerator, document.time_signature.denominator);
Self { old_map, new_map, time_sig }
}
/// Return the new MIDI event lists for each affected clip (for immediate cache update).
pub fn new_midi_events(&self) -> impl Iterator<Item = (u32, &Vec<daw_backend::audio::midi::MidiEvent>)> {
self.midi_snapshots.iter().map(|s| (s.midi_clip_id, &s.new_events))
/// Build from explicit old/new maps (used when the map already changed in-place,
/// e.g., after live drag preview; the caller provides start and end states).
pub fn from_maps(old_map: TempoMap, new_map: TempoMap, time_sig: (u32, u32)) -> Self {
Self { old_map, new_map, time_sig }
}
fn apply_clips(document: &mut Document, snapshots: &[ClipTimingSnapshot], use_new: bool) {
for snap in snapshots {
let fields = if use_new { &snap.new_fields } else { &snap.old_fields };
let layer = match document.get_layer_mut(&snap.layer_id) {
Some(l) => l,
None => continue,
};
let clip_instances = match layer {
AnyLayer::Vector(vl) => &mut vl.clip_instances,
AnyLayer::Audio(al) => &mut al.clip_instances,
AnyLayer::Video(vl) => &mut vl.clip_instances,
AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
};
if let Some(ci) = clip_instances.iter_mut().find(|ci| ci.id == snap.instance_id) {
fields.apply_to(ci);
}
}
}
fn apply_midi_durations(document: &mut Document, snapshots: &[MidiClipSnapshot], use_new: bool) {
for snap in snapshots {
if let Some(clip) = document.get_audio_clip_mut(&snap.clip_id) {
clip.duration = if use_new { snap.new_clip_duration } else { snap.old_clip_duration };
}
}
}
pub fn new_bpm(&self) -> f64 { self.new_map.global_bpm() }
pub fn old_bpm(&self) -> f64 { self.old_map.global_bpm() }
}
impl Action for ChangeBpmAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
document.bpm = self.new_bpm;
Self::apply_clips(document, &self.clip_snapshots, true);
Self::apply_midi_durations(document, &self.midi_snapshots, true);
*document.tempo_map_mut() = self.new_map.clone();
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
document.bpm = self.old_bpm;
Self::apply_clips(document, &self.clip_snapshots, false);
Self::apply_midi_durations(document, &self.midi_snapshots, false);
*document.tempo_map_mut() = self.old_map.clone();
Ok(())
}
@ -253,111 +54,26 @@ impl Action for ChangeBpmAction {
fn execute_backend(
&mut self,
backend: &mut BackendContext,
document: &Document,
_document: &Document,
) -> Result<(), String> {
let controller = match backend.audio_controller.as_mut() {
Some(c) => c,
None => return Ok(()),
};
// Update tempo
controller.set_tempo(self.new_bpm as f32, self.time_sig);
// Update MIDI clip events and positions
for snap in &self.midi_snapshots {
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
Some(&id) => id,
None => continue,
};
controller.update_midi_clip_events(track_id, snap.midi_clip_id, snap.new_events.clone());
}
// Move clip instances in the backend
for snap in &self.clip_snapshots {
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
Some(&id) => id,
None => continue,
};
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
match backend_id {
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
controller.move_clip(track_id, *audio_id, snap.new_fields.timeline_start);
}
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
controller.move_clip(track_id, *midi_id, snap.new_fields.timeline_start);
}
None => {} // Vector/video clips — no backend move needed
}
}
// Sync beat/frame representations, rescale MIDI clip durations, and rescale
// automation keyframe times in the backend
let fps = document.framerate;
let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter()
.map(|s| (s.midi_clip_id, s.new_clip_duration))
.collect();
controller.apply_bpm_change(self.old_bpm, self.new_bpm, fps, midi_durations);
controller.set_tempo(self.new_map.global_bpm() as f32, self.time_sig);
Ok(())
}
fn rollback_backend(
&mut self,
backend: &mut BackendContext,
document: &Document,
_document: &Document,
) -> Result<(), String> {
let controller = match backend.audio_controller.as_mut() {
Some(c) => c,
None => return Ok(()),
};
controller.set_tempo(self.old_bpm as f32, self.time_sig);
for snap in &self.midi_snapshots {
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
Some(&id) => id,
None => continue,
};
controller.update_midi_clip_events(track_id, snap.midi_clip_id, snap.old_events.clone());
}
for snap in &self.clip_snapshots {
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
Some(&id) => id,
None => continue,
};
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
match backend_id {
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
controller.move_clip(track_id, *audio_id, snap.old_fields.timeline_start);
}
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
controller.move_clip(track_id, *midi_id, snap.old_fields.timeline_start);
}
None => {}
}
}
// Sync beat/frame representations, restore MIDI clip durations, and restore
// automation keyframe times in the backend
let fps = document.framerate;
let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter()
.map(|s| (s.midi_clip_id, s.old_clip_duration))
.collect();
controller.apply_bpm_change(self.new_bpm, self.old_bpm, fps, midi_durations);
controller.set_tempo(self.old_map.global_bpm() as f32, self.time_sig);
Ok(())
}
fn all_midi_events_after_execute(&self) -> Vec<(u32, Vec<daw_backend::audio::midi::MidiEvent>)> {
self.midi_snapshots.iter()
.map(|s| (s.midi_clip_id, s.new_events.clone()))
.collect()
}
fn all_midi_events_after_rollback(&self) -> Vec<(u32, Vec<daw_backend::audio::midi::MidiEvent>)> {
self.midi_snapshots.iter()
.map(|s| (s.midi_clip_id, s.old_events.clone()))
.collect()
}
}

View File

@ -1,156 +1,33 @@
//! Change FPS action
//!
//! Atomically changes the document framerate and rescales all clip instance positions
//! so that frame positions are preserved (Frames mode behaviour).
//! Atomically changes the document framerate.
//! All clip positions are stored in **beats** so no position rescaling is
//! required — only the framerate field changes.
use crate::action::{Action, BackendContext};
use crate::clip::ClipInstance;
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
/// Snapshot of all timing fields on a `ClipInstance`
#[derive(Clone)]
struct TimingFields {
timeline_start: f64,
timeline_start_beats: f64,
timeline_start_frames: f64,
trim_start: f64,
trim_start_beats: f64,
trim_start_frames: f64,
trim_end: Option<f64>,
trim_end_beats: Option<f64>,
trim_end_frames: Option<f64>,
timeline_duration: Option<f64>,
timeline_duration_beats: Option<f64>,
timeline_duration_frames: Option<f64>,
}
impl TimingFields {
fn from_instance(ci: &ClipInstance) -> Self {
Self {
timeline_start: ci.timeline_start,
timeline_start_beats: ci.timeline_start_beats,
timeline_start_frames: ci.timeline_start_frames,
trim_start: ci.trim_start,
trim_start_beats: ci.trim_start_beats,
trim_start_frames: ci.trim_start_frames,
trim_end: ci.trim_end,
trim_end_beats: ci.trim_end_beats,
trim_end_frames: ci.trim_end_frames,
timeline_duration: ci.timeline_duration,
timeline_duration_beats: ci.timeline_duration_beats,
timeline_duration_frames: ci.timeline_duration_frames,
}
}
fn apply_to(&self, ci: &mut ClipInstance) {
ci.timeline_start = self.timeline_start;
ci.timeline_start_beats = self.timeline_start_beats;
ci.timeline_start_frames = self.timeline_start_frames;
ci.trim_start = self.trim_start;
ci.trim_start_beats = self.trim_start_beats;
ci.trim_start_frames = self.trim_start_frames;
ci.trim_end = self.trim_end;
ci.trim_end_beats = self.trim_end_beats;
ci.trim_end_frames = self.trim_end_frames;
ci.timeline_duration = self.timeline_duration;
ci.timeline_duration_beats = self.timeline_duration_beats;
ci.timeline_duration_frames = self.timeline_duration_frames;
}
}
#[derive(Clone)]
struct ClipTimingSnapshot {
layer_id: Uuid,
instance_id: Uuid,
old_fields: TimingFields,
new_fields: TimingFields,
}
/// Action that atomically changes framerate and rescales all clip positions to preserve frames
/// Action that changes the document framerate.
pub struct ChangeFpsAction {
old_fps: f64,
new_fps: f64,
clip_snapshots: Vec<ClipTimingSnapshot>,
}
impl ChangeFpsAction {
/// Build the action, computing new positions for all clip instances.
pub fn new(old_fps: f64, new_fps: f64, document: &Document) -> Self {
let bpm = document.bpm;
let mut clip_snapshots: Vec<ClipTimingSnapshot> = Vec::new();
for layer in document.all_layers() {
let layer_id = layer.id();
let clip_instances: &[ClipInstance] = match layer {
AnyLayer::Vector(vl) => &vl.clip_instances,
AnyLayer::Audio(al) => &al.clip_instances,
AnyLayer::Video(vl) => &vl.clip_instances,
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
};
for ci in clip_instances {
let old_fields = TimingFields::from_instance(ci);
// Compute new fields: frames are canonical, recompute seconds + beats
let mut new_ci = ci.clone();
new_ci.apply_frames(new_fps, bpm);
let new_fields = TimingFields::from_instance(&new_ci);
clip_snapshots.push(ClipTimingSnapshot {
layer_id,
instance_id: ci.id,
old_fields,
new_fields,
});
}
}
Self {
old_fps,
new_fps,
clip_snapshots,
}
}
fn apply_clips(document: &mut Document, snapshots: &[ClipTimingSnapshot], use_new: bool) {
for snap in snapshots {
let fields = if use_new { &snap.new_fields } else { &snap.old_fields };
let layer = match document.get_layer_mut(&snap.layer_id) {
Some(l) => l,
None => continue,
};
let clip_instances = match layer {
AnyLayer::Vector(vl) => &mut vl.clip_instances,
AnyLayer::Audio(al) => &mut al.clip_instances,
AnyLayer::Video(vl) => &mut vl.clip_instances,
AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
};
if let Some(ci) = clip_instances.iter_mut().find(|ci| ci.id == snap.instance_id) {
fields.apply_to(ci);
}
}
/// Build the action from old and new framerates.
pub fn new(old_fps: f64, new_fps: f64, _document: &Document) -> Self {
Self { old_fps, new_fps }
}
}
impl Action for ChangeFpsAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
document.framerate = self.new_fps;
Self::apply_clips(document, &self.clip_snapshots, true);
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
document.framerate = self.old_fps;
Self::apply_clips(document, &self.clip_snapshots, false);
Ok(())
}
@ -160,68 +37,18 @@ impl Action for ChangeFpsAction {
fn execute_backend(
&mut self,
backend: &mut BackendContext,
_backend: &mut BackendContext,
_document: &Document,
) -> Result<(), String> {
// FPS change does not affect audio timing — only move clips that changed position
let controller = match backend.audio_controller.as_mut() {
Some(c) => c,
None => return Ok(()),
};
for snap in &self.clip_snapshots {
if (snap.new_fields.timeline_start - snap.old_fields.timeline_start).abs() < 1e-9 {
continue; // No movement, skip
}
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
Some(&id) => id,
None => continue,
};
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
match backend_id {
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
controller.move_clip(track_id, *audio_id, snap.new_fields.timeline_start);
}
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
controller.move_clip(track_id, *midi_id, snap.new_fields.timeline_start);
}
None => {}
}
}
// FPS does not affect audio scheduling — nothing to do in the backend.
Ok(())
}
fn rollback_backend(
&mut self,
backend: &mut BackendContext,
_backend: &mut BackendContext,
_document: &Document,
) -> Result<(), String> {
let controller = match backend.audio_controller.as_mut() {
Some(c) => c,
None => return Ok(()),
};
for snap in &self.clip_snapshots {
if (snap.new_fields.timeline_start - snap.old_fields.timeline_start).abs() < 1e-9 {
continue;
}
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
Some(&id) => id,
None => continue,
};
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
match backend_id {
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
controller.move_clip(track_id, *audio_id, snap.old_fields.timeline_start);
}
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
controller.move_clip(track_id, *midi_id, snap.old_fields.timeline_start);
}
None => {}
}
}
Ok(())
}
}

View File

@ -119,9 +119,6 @@ impl Action for MoveClipInstancesAction {
// Store adjusted moves for rollback
self.layer_moves = adjusted_moves.clone();
let bpm = document.bpm;
let fps = document.framerate;
// Apply all adjusted moves
for (layer_id, moves) in &adjusted_moves {
let layer = document.get_layer_mut(layer_id)
@ -142,7 +139,6 @@ impl Action for MoveClipInstancesAction {
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
{
clip_instance.timeline_start = *new;
clip_instance.sync_from_seconds(bpm, fps);
}
}
}
@ -151,8 +147,6 @@ impl Action for MoveClipInstancesAction {
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let bpm = document.bpm;
let fps = document.framerate;
for (layer_id, moves) in &self.layer_moves {
let layer = document.get_layer_mut(layer_id)
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
@ -172,7 +166,6 @@ impl Action for MoveClipInstancesAction {
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
{
clip_instance.timeline_start = *old;
clip_instance.sync_from_seconds(bpm, fps);
}
}
}

View File

@ -177,10 +177,10 @@ impl Action for RemoveClipInstancesAction {
let midi_instance = daw_backend::MidiClipInstance::new(
0,
*midi_clip_id,
internal_start,
internal_end,
external_start,
external_duration,
daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end),
daw_backend::Beats(external_start),
daw_backend::Beats(external_duration),
);
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);

View File

@ -126,8 +126,8 @@ impl Action for SplitClipInstanceAction {
.get_clip_duration(&instance.clip_id)
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
// Calculate the effective duration and timeline end
let effective_duration = instance.effective_duration(clip_duration);
// Calculate the effective duration and timeline end (both in beats)
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
let timeline_end = instance.timeline_start + effective_duration;
// Validate: split_time must be strictly within the clip's timeline span
@ -179,7 +179,6 @@ impl Action for SplitClipInstanceAction {
}
self.new_instance_id = Some(right_instance.id);
right_instance.sync_from_seconds(document.bpm, document.framerate);
// Now modify the original (left) instance and add the new (right) instance
let layer_mut = document
@ -239,21 +238,6 @@ impl Action for SplitClipInstanceAction {
}
}
// Sync derived fields on the left (original) instance
let (bpm, fps) = (document.bpm, document.framerate);
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
let cis: &mut Vec<crate::clip::ClipInstance> = match layer {
AnyLayer::Vector(vl) => &mut vl.clip_instances,
AnyLayer::Audio(al) => &mut al.clip_instances,
AnyLayer::Video(vl) => &mut vl.clip_instances,
AnyLayer::Effect(el) => &mut el.clip_instances,
_ => return { self.executed = true; Ok(()) },
};
if let Some(inst) = cis.iter_mut().find(|ci| ci.id == self.instance_id) {
inst.sync_from_seconds(bpm, fps);
}
}
self.executed = true;
Ok(())
}
@ -406,10 +390,10 @@ impl Action for SplitClipInstanceAction {
let instance = daw_backend::MidiClipInstance::new(
0,
*midi_clip_id,
internal_start,
internal_end,
external_start,
external_duration,
daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end),
daw_backend::Beats(external_start),
daw_backend::Beats(external_duration),
);
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);

View File

@ -101,7 +101,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
};
};
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
let member_old_trim = instance.trim_start;
@ -138,7 +138,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
};
};
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
let member_old_trim_end = instance.trim_end;
@ -182,7 +182,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
};
};
let instance = clip_instances.iter()
.find(|ci| &ci.id == instance_id)
@ -260,9 +260,6 @@ impl Action for TrimClipInstancesAction {
// Store clamped trims for rollback
self.layer_trims = clamped_trims.clone();
let bpm = document.bpm;
let fps = document.framerate;
// Apply all clamped trims
for (layer_id, trims) in &clamped_trims {
let layer = match document.get_layer_mut(layer_id) {
@ -297,7 +294,6 @@ impl Action for TrimClipInstancesAction {
clip_instance.trim_end = new.trim_value;
}
}
clip_instance.sync_from_seconds(bpm, fps);
}
}
}
@ -305,8 +301,6 @@ impl Action for TrimClipInstancesAction {
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let bpm = document.bpm;
let fps = document.framerate;
for (layer_id, trims) in &self.layer_trims {
let layer = match document.get_layer_mut(layer_id) {
Some(l) => l,
@ -340,7 +334,6 @@ impl Action for TrimClipInstancesAction {
clip_instance.trim_end = old.trim_value;
}
}
clip_instance.sync_from_seconds(bpm, fps);
}
}
}

View File

@ -1,6 +1,7 @@
//! Beat/measure ↔ seconds conversion utilities
use crate::document::TimeSignature;
use crate::tempo_map::TempoMap;
/// Position expressed as measure, beat, tick
#[derive(Debug, Clone, Copy)]
@ -11,9 +12,8 @@ pub struct MeasurePosition {
}
/// Convert a time in seconds to a measure position
pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> MeasurePosition {
let beats_per_second = bpm / 60.0;
let total_beats = (time * beats_per_second).max(0.0);
pub fn time_to_measure(time: f64, tempo_map: &TempoMap, time_sig: &TimeSignature) -> MeasurePosition {
let total_beats = tempo_map.inverse_transform(time).max(0.0);
let beats_per_measure = time_sig.numerator as f64;
let measure = (total_beats / beats_per_measure).floor() as u32 + 1;
@ -24,21 +24,20 @@ pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> Measure
}
/// Convert a measure position to seconds
pub fn measure_to_time(pos: MeasurePosition, bpm: f64, time_sig: &TimeSignature) -> f64 {
pub fn measure_to_time(pos: MeasurePosition, tempo_map: &TempoMap, time_sig: &TimeSignature) -> f64 {
let beats_per_measure = time_sig.numerator as f64;
let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure
+ (pos.beat as f64 - 1.0)
+ (pos.tick as f64 / 1000.0);
let beats_per_second = bpm / 60.0;
total_beats / beats_per_second
tempo_map.transform(total_beats)
}
/// Get the duration of one beat in seconds
pub fn beat_duration(bpm: f64) -> f64 {
60.0 / bpm
/// Get the duration of one beat in seconds at the given beat position
pub fn beat_duration(beat: f64, tempo_map: &TempoMap) -> f64 {
60.0 / tempo_map.bpm_at(daw_backend::Beats(beat))
}
/// Get the duration of one measure in seconds
pub fn measure_duration(bpm: f64, time_sig: &TimeSignature) -> f64 {
beat_duration(bpm) * time_sig.numerator as f64
/// Get the duration of one measure in seconds at the given beat position
pub fn measure_duration(beat: f64, tempo_map: &TempoMap, time_sig: &TimeSignature) -> f64 {
beat_duration(beat, tempo_map) * time_sig.numerator as f64
}

View File

@ -98,15 +98,20 @@ impl VectorClip {
///
/// The `clip_duration_fn` resolves referenced clip durations for non-vector layers.
/// Falls back to the stored `duration` field if no content exists.
pub fn content_duration(&self, framerate: f64) -> f64 {
self.content_duration_with(framerate, |_| None)
pub fn content_duration(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
self.content_duration_with(framerate, tempo_map, |_| None)
}
/// Like `content_duration`, but with a closure that resolves clip durations
/// for audio/video/effect clip instances inside this movie clip.
pub fn content_duration_with(&self, framerate: f64, clip_duration_fn: impl Fn(&Uuid) -> Option<f64>) -> f64 {
///
/// `clip_duration_fn` returns clip content duration **in seconds**.
/// Result is in **seconds**.
pub fn content_duration_with(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap, clip_duration_fn: impl Fn(&Uuid) -> Option<f64>) -> f64 {
let frame_duration = 1.0 / framerate;
let mut last_time: Option<f64> = None;
// Work in beats, convert to seconds at the end.
let mut last_beats: Option<f64> = None;
let mut last_secs: Option<f64> = None;
for layer_node in self.layers.iter() {
// Check clip instances on ALL layer types (vector, audio, video, effect)
@ -119,33 +124,38 @@ impl VectorClip {
AnyLayer::Raster(_) => &[],
};
for ci in clip_instances {
let end = if let Some(td) = ci.timeline_duration {
ci.timeline_start + td
// Compute end position of this clip instance in beats
let end_beats = if let Some(td_beats) = ci.timeline_duration {
ci.timeline_start + td_beats
} else if let Some(te) = ci.trim_end {
ci.timeline_start + (te - ci.trim_start).max(0.0)
} else if let Some(clip_dur) = clip_duration_fn(&ci.clip_id) {
ci.timeline_start + (clip_dur - ci.trim_start).max(0.0)
let secs = (te - ci.trim_start).max(0.0);
ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start
} else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
let secs = (clip_dur_secs - ci.trim_start).max(0.0);
ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start
} else {
continue;
};
last_time = Some(match last_time {
Some(t) => t.max(end),
None => end,
});
last_beats = Some(last_beats.map_or(end_beats, |t: f64| t.max(end_beats)));
}
// Also check vector layer keyframes
// Vector layer keyframes are in seconds
if let AnyLayer::Vector(vector_layer) = &layer_node.data {
if let Some(last_kf) = vector_layer.keyframes.last() {
last_time = Some(match last_time {
Some(t) => t.max(last_kf.time),
None => last_kf.time,
});
last_secs = Some(last_secs.map_or(last_kf.time, |t: f64| t.max(last_kf.time)));
}
}
}
match last_time {
let from_clips = last_beats.map(|b| tempo_map.transform(b));
let combined = match (from_clips, last_secs) {
(Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
match combined {
Some(t) => t + frame_duration,
None => self.duration,
}
@ -186,9 +196,10 @@ impl VectorClip {
// Handle nested clip instances recursively
for clip_instance in &vector_layer.clip_instances {
// Convert parent clip time to nested clip local time
// Apply timeline offset and playback speed, then add trim offset
let nested_clip_time = ((clip_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
// Convert parent clip time (seconds) to nested clip local time (seconds).
// timeline_start is in beats; convert to seconds using document BPM.
let start_secs = document.tempo_map().transform(clip_instance.timeline_start);
let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
// Look up the nested clip definition
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
@ -485,12 +496,12 @@ impl AudioClip {
pub fn new_midi(
name: impl Into<String>,
midi_clip_id: u32,
duration: f64,
duration: daw_backend::Beats,
) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
duration,
duration: duration.beats_to_f64(),
clip_type: AudioClipType::Midi { midi_clip_id },
folder_id: None,
}
@ -589,6 +600,12 @@ impl AnyClip {
/// - Timeline placement (when this instance appears on the parent layer's timeline)
/// - Trimming (trim_start, trim_end within the clip's internal content)
/// - Playback speed (time remapping)
///
/// ## Coordinate systems
/// - `timeline_start` / `timeline_duration` are in **beats** (quarter-note beats).
/// Use [`crate::tempo_map::TempoMap::beats_to_seconds`] to convert to seconds.
/// - `trim_start` / `trim_end` are in **seconds** (audio/video file seek offsets;
/// not affected by BPM changes).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ClipInstance {
/// Unique identifier for this instance
@ -606,52 +623,25 @@ pub struct ClipInstance {
/// Optional name for this instance
pub name: Option<String>,
/// When this instance starts on the timeline (in seconds, relative to parent layer)
/// This is the external positioning - where the instance appears on the timeline
/// Default: 0.0 (start at beginning of layer)
/// When this instance starts on the timeline, in **beats**.
/// Default: 0.0
pub timeline_start: f64,
/// timeline_start in beats (quarter-note beats); derived from timeline_start
#[serde(default)]
pub timeline_start_beats: f64,
/// timeline_start in frames; derived from timeline_start
#[serde(default)]
pub timeline_start_frames: f64,
/// How long this instance appears on the timeline (in seconds)
/// If timeline_duration > (trim_end - trim_start), the trimmed content will loop
/// How long this instance appears on the timeline, in **beats**.
/// If set and longer than the trimmed content, the content will loop.
/// Default: None (use trimmed clip duration, no looping)
pub timeline_duration: Option<f64>,
/// timeline_duration in beats; derived from timeline_duration
#[serde(default)]
pub timeline_duration_beats: Option<f64>,
/// timeline_duration in frames; derived from timeline_duration
#[serde(default)]
pub timeline_duration_frames: Option<f64>,
/// Trim start: offset into the clip's internal content (in seconds)
/// Allows trimming the beginning of the clip
/// - For audio: offset into the audio file
/// - For video: offset into the video file
/// - For vector: offset into the animation timeline
/// Default: 0.0 (start at beginning of clip)
/// Trim start: offset into the clip's internal content, in **seconds**.
/// - For audio: byte-offset into the audio file
/// - For video: seek position in the video file
/// - For vector: time offset into the animation
/// Default: 0.0
pub trim_start: f64,
/// trim_start in beats; derived from trim_start
#[serde(default)]
pub trim_start_beats: f64,
/// trim_start in frames; derived from trim_start
#[serde(default)]
pub trim_start_frames: f64,
/// Trim end: offset into the clip's internal content (in seconds)
/// Allows trimming the end of the clip
/// Trim end: offset into the clip's internal content, in **seconds**.
/// Default: None (use full clip duration)
pub trim_end: Option<f64>,
/// trim_end in beats; derived from trim_end
#[serde(default)]
pub trim_end_beats: Option<f64>,
/// trim_end in frames; derived from trim_end
#[serde(default)]
pub trim_end_frames: Option<f64>,
/// Playback speed multiplier
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
@ -663,7 +653,7 @@ pub struct ClipInstance {
/// Default: 1.0
pub gain: f32,
/// How far (in seconds) the looped content extends before timeline_start.
/// How far (in beats) the looped content extends before timeline_start.
/// When set, loop iterations are drawn/played before the content start.
/// Default: None (no pre-loop)
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -720,17 +710,9 @@ impl ClipInstance {
opacity: 1.0,
name: None,
timeline_start: 0.0,
timeline_start_beats: 0.0,
timeline_start_frames: 0.0,
timeline_duration: None,
timeline_duration_beats: None,
timeline_duration_frames: None,
trim_start: 0.0,
trim_start_beats: 0.0,
trim_start_frames: 0.0,
trim_end: None,
trim_end_beats: None,
trim_end_frames: None,
playback_speed: 1.0,
gain: 1.0,
loop_before: None,
@ -746,71 +728,15 @@ impl ClipInstance {
opacity: 1.0,
name: None,
timeline_start: 0.0,
timeline_start_beats: 0.0,
timeline_start_frames: 0.0,
timeline_duration: None,
timeline_duration_beats: None,
timeline_duration_frames: None,
trim_start: 0.0,
trim_start_beats: 0.0,
trim_start_frames: 0.0,
trim_end: None,
trim_end_beats: None,
trim_end_frames: None,
playback_speed: 1.0,
gain: 1.0,
loop_before: None,
}
}
/// Sync beats and frames from the seconds fields (call after any seconds-based write).
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
self.timeline_start_beats = self.timeline_start * bpm / 60.0;
self.timeline_start_frames = self.timeline_start * fps;
self.trim_start_beats = self.trim_start * bpm / 60.0;
self.trim_start_frames = self.trim_start * fps;
self.trim_end_beats = self.trim_end.map(|v| v * bpm / 60.0);
self.trim_end_frames = self.trim_end.map(|v| v * fps);
self.timeline_duration_beats = self.timeline_duration.map(|v| v * bpm / 60.0);
self.timeline_duration_frames = self.timeline_duration.map(|v| v * fps);
}
/// Recompute seconds and frames from beats (call when BPM changes in Measures mode).
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
self.timeline_start = self.timeline_start_beats * 60.0 / bpm;
self.timeline_start_frames = self.timeline_start * fps;
self.trim_start = self.trim_start_beats * 60.0 / bpm;
self.trim_start_frames = self.trim_start * fps;
if let Some(b) = self.trim_end_beats {
let s = b * 60.0 / bpm;
self.trim_end = Some(s);
self.trim_end_frames = Some(s * fps);
}
if let Some(b) = self.timeline_duration_beats {
let s = b * 60.0 / bpm;
self.timeline_duration = Some(s);
self.timeline_duration_frames = Some(s * fps);
}
}
/// Recompute seconds and beats from frames (call when FPS changes in Frames mode).
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
self.timeline_start = self.timeline_start_frames / fps;
self.timeline_start_beats = self.timeline_start * bpm / 60.0;
self.trim_start = self.trim_start_frames / fps;
self.trim_start_beats = self.trim_start * bpm / 60.0;
if let Some(f) = self.trim_end_frames {
let s = f / fps;
self.trim_end = Some(s);
self.trim_end_beats = Some(s * bpm / 60.0);
}
if let Some(f) = self.timeline_duration_frames {
let s = f / fps;
self.timeline_duration = Some(s);
self.timeline_duration_beats = Some(s * bpm / 60.0);
}
}
/// Set the transform
pub fn with_transform(mut self, transform: Transform) -> Self {
self.transform = transform;
@ -861,83 +787,83 @@ impl ClipInstance {
self
}
/// Set explicit timeline duration by setting trim_end
///
/// For effect instances, this effectively sets the duration since
/// effects have infinite internal duration (trim_start defaults to 0).
pub fn with_timeline_duration(mut self, duration: f64) -> Self {
self.trim_end = Some(self.trim_start + duration);
/// Set explicit timeline duration (in beats) by directly setting `timeline_duration`.
pub fn with_timeline_duration(mut self, duration_beats: f64) -> Self {
self.timeline_duration = Some(duration_beats);
self
}
/// Get the effective duration of this instance (accounting for trimming and looping)
/// If timeline_duration is set, returns that (enabling content looping)
/// Otherwise returns the trimmed content duration
pub fn effective_duration(&self, clip_duration: f64) -> f64 {
// If timeline_duration is explicitly set, use that (for looping)
if let Some(timeline_dur) = self.timeline_duration {
return timeline_dur;
}
// Otherwise, return the trimmed content duration
let end = self.trim_end.unwrap_or(clip_duration);
/// Content window size in seconds: `trim_end - trim_start`.
/// Used for internal looping calculations.
pub fn content_window_secs(&self, clip_duration_secs: f64) -> f64 {
let end = self.trim_end.unwrap_or(clip_duration_secs);
(end - self.trim_start).max(0.0)
}
/// Get the effective start position on the timeline, accounting for loop_before.
/// This is the left edge of the clip's visual extent.
/// How long this instance appears on the timeline, in **beats**.
///
/// If `timeline_duration` is set, returns that (enabling content looping).
/// Otherwise converts the content window from seconds to beats using the tempo map.
pub fn effective_duration_beats(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
if let Some(td) = self.timeline_duration {
return td;
}
let window_secs = self.content_window_secs(clip_duration_secs);
let start_secs = tempo_map.transform(self.timeline_start);
tempo_map.inverse_transform(start_secs + window_secs) - self.timeline_start
}
/// Left edge of the clip's visual extent on the timeline, in **beats**.
pub fn effective_start(&self) -> f64 {
self.timeline_start - self.loop_before.unwrap_or(0.0)
}
/// Get the total visual duration including both loop_before and effective_duration.
pub fn total_duration(&self, clip_duration: f64) -> f64 {
self.loop_before.unwrap_or(0.0) + self.effective_duration(clip_duration)
/// Total visual duration (loop_before + effective_duration), in **beats**.
pub fn total_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
self.loop_before.unwrap_or(0.0) + self.effective_duration_beats(clip_duration_secs, tempo_map)
}
/// Remap timeline time to clip content time
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
///
/// Takes a global timeline time and returns the corresponding time within this
/// clip's content, accounting for:
/// - Instance position (timeline_start)
/// - Playback speed
/// - Trimming (trim_start, trim_end)
/// - Looping (if timeline_duration > content window)
///
/// Returns None if the clip instance is not active at the given timeline time.
pub fn remap_time(&self, timeline_time: f64, clip_duration: f64) -> Option<f64> {
// Check if clip instance is active at this time
let instance_end = self.timeline_start + self.effective_duration(clip_duration);
if timeline_time < self.timeline_start || timeline_time >= instance_end {
/// Returns `None` if the clip instance is not active at `time_secs`.
pub fn remap_time_secs(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option<f64> {
let start_secs = tempo_map.transform(self.timeline_start);
let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map);
let end_secs = tempo_map.transform(self.timeline_start + dur_beats);
if time_secs < start_secs || time_secs >= end_secs {
return None;
}
// Calculate relative time within the instance (0.0 = start of instance)
let relative_time = timeline_time - self.timeline_start;
let relative_secs = time_secs - start_secs;
let content_time = relative_secs * self.playback_speed;
let content_window = self.content_window_secs(clip_duration_secs);
// Account for playback speed
let content_time = relative_time * self.playback_speed;
// Get the content window size (the portion of clip we're sampling)
let trim_end = self.trim_end.unwrap_or(clip_duration);
let content_window = (trim_end - self.trim_start).max(0.0);
// If content_window is zero, can't sample anything
if content_window == 0.0 {
return Some(self.trim_start);
}
// Apply looping if content exceeds the window
let looped_time = if content_time > content_window {
content_time % content_window
} else {
content_time
};
// Add trim_start offset to get final clip time
Some(self.trim_start + looped_time)
}
/// Alias for `remap_time_secs`.
#[inline]
pub fn remap_time(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option<f64> {
self.remap_time_secs(time_secs, clip_duration_secs, tempo_map)
}
/// Alias for `effective_duration_beats`.
#[inline]
pub fn effective_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
self.effective_duration_beats(clip_duration_secs, tempo_map)
}
/// Convert to affine transform
pub fn to_affine(&self) -> vello::kurbo::Affine {
self.transform.to_affine()

View File

@ -6,10 +6,11 @@
use crate::asset_folder::AssetFolderTree;
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
use crate::effect::EffectDefinition;
use crate::layer::AnyLayer;
use crate::layer::{AnyLayer, GroupLayer};
use crate::script::ScriptDefinition;
use crate::layout::LayoutNode;
use crate::shape::ShapeColor;
use crate::tempo_map::TempoMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
@ -131,6 +132,8 @@ impl Default for TimeSignature {
}
}
// Keep for backward serde compat (old files may have a `bpm` field); no longer used.
#[allow(dead_code)]
fn default_bpm() -> f64 { 120.0 }
/// How time is displayed in the timeline
@ -173,14 +176,16 @@ pub struct Document {
/// Framerate (frames per second)
pub framerate: f64,
/// Tempo in beats per minute
#[serde(default = "default_bpm")]
pub bpm: f64,
/// Time signature
#[serde(default)]
pub time_signature: TimeSignature,
/// Master track (master bus + tempo automation lane).
/// Stored separately from the root layer tree; shown in timeline when
/// `show_master_track` is enabled in the editor state.
#[serde(default)]
pub master_layer: GroupLayer,
/// Duration in seconds
pub duration: f64,
@ -266,8 +271,12 @@ impl Default for Document {
width: 1920.0,
height: 1080.0,
framerate: 60.0,
bpm: 120.0,
time_signature: TimeSignature::default(),
master_layer: {
let mut ml = GroupLayer::new_master(120.0);
ml.layer.id = uuid::Uuid::new_v4();
ml
},
duration: 10.0,
root: GraphicsObject::default(),
vector_clips: HashMap::new(),
@ -311,6 +320,28 @@ impl Document {
}
}
/// Reference to the document's tempo map (stored on the master layer).
pub fn tempo_map(&self) -> &TempoMap {
self.master_layer.tempo_map.as_ref()
.expect("master_layer always has a tempo_map")
}
/// Mutable reference to the document's tempo map.
pub fn tempo_map_mut(&mut self) -> &mut TempoMap {
self.master_layer.tempo_map.as_mut()
.expect("master_layer always has a tempo_map")
}
/// Convenience accessor for the global BPM (first entry of the tempo map).
pub fn bpm(&self) -> f64 {
self.tempo_map().global_bpm()
}
/// Set the global BPM. Rebuilds the tempo map's seconds cache.
pub fn set_bpm(&mut self, bpm: f64) {
self.tempo_map_mut().set_global_bpm(bpm);
}
/// Rebuild the layer→clip reverse lookup map from all vector clips.
/// Call after deserialization or bulk clip modifications.
pub fn rebuild_layer_to_clip_map(&mut self) {
@ -619,56 +650,6 @@ impl Document {
layers
}
/// Migrate old documents: compute beats/frames from seconds for any ClipInstance whose
/// derived fields are still zero (i.e., documents saved before triple-representation).
/// Call once after loading a document.
pub fn sync_all_clip_positions(&mut self) {
let bpm = self.bpm;
let fps = self.framerate;
fn sync_list(list: &mut [crate::layer::AnyLayer], bpm: f64, fps: f64) {
for layer in list.iter_mut() {
match layer {
crate::layer::AnyLayer::Vector(vl) => {
for ci in &mut vl.clip_instances {
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
}
}
crate::layer::AnyLayer::Audio(al) => {
for ci in &mut al.clip_instances {
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
}
}
crate::layer::AnyLayer::Video(vl) => {
for ci in &mut vl.clip_instances {
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
}
}
crate::layer::AnyLayer::Effect(el) => {
for ci in &mut el.clip_instances {
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
}
}
crate::layer::AnyLayer::Group(g) => {
sync_list(&mut g.children, bpm, fps);
}
crate::layer::AnyLayer::Raster(_) => {}
}
}
}
sync_list(&mut self.root.children, bpm, fps);
for clip in self.vector_clips.values_mut() {
for node in &mut clip.layers.roots {
if let crate::layer::AnyLayer::Vector(vl) = &mut node.data {
for ci in &mut vl.clip_instances {
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
}
}
}
}
}
// === CLIP LIBRARY METHODS ===
/// Add a vector clip to the library
@ -866,11 +847,12 @@ impl Document {
if clip.is_group {
Some(clip.duration)
} else {
Some(clip.content_duration_with(self.framerate, |id| {
let tempo_map = self.tempo_map();
Some(clip.content_duration_with(self.framerate, tempo_map, |id| {
// Resolve nested clip durations (audio, video, other vector clips)
if let Some(vc) = self.vector_clips.get(id) {
// Avoid deep recursion — use stored duration for nested vector clips
Some(vc.content_duration(self.framerate))
Some(vc.content_duration(self.framerate, tempo_map))
} else if let Some(ac) = self.audio_clips.get(id) {
Some(ac.duration)
} else if let Some(vc) = self.video_clips.get(id) {
@ -961,7 +943,7 @@ impl Document {
};
let instance_start = instance.effective_start();
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration);
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map());
// Check overlap: start_a < end_b AND start_b < end_a
if start_time < instance_end && instance_start < end_time {
@ -1019,7 +1001,7 @@ impl Document {
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
let inst_start = instance.effective_start();
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur);
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
occupied_ranges.push((inst_start, inst_end, instance.id));
}
}
@ -1114,7 +1096,7 @@ impl Document {
}
if let Some(dur) = self.get_clip_duration(&inst.clip_id) {
let start = inst.effective_start();
let end = inst.timeline_start + inst.effective_duration(dur);
let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
non_group.push((start, end));
}
}
@ -1184,7 +1166,7 @@ impl Document {
// Calculate other clip's extent (accounting for loop_before)
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
let other_end = other.timeline_start + other.effective_duration(clip_duration);
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
// If this clip is to the left and closer than current nearest
if other_end <= current_timeline_start && other_end > nearest_end {
@ -1280,7 +1262,7 @@ impl Document {
}
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
let other_end = other.timeline_start + other.effective_duration(clip_duration);
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
if other_end <= current_effective_start && other_end > nearest_end {
nearest_end = other_end;

View File

@ -142,16 +142,18 @@ impl EffectLayer {
self.clip_instances.iter_mut().find(|e| &e.id == id)
}
/// Get all clip instances (effects) that are active at a given time
/// Get all clip instances (effects) that are active at a given time.
///
/// Uses `EFFECT_DURATION` to calculate effective duration for each instance.
pub fn active_clip_instances_at(&self, time: f64) -> Vec<&ClipInstance> {
/// `time_secs` is the playback time in seconds; `bpm` is used to convert
/// `timeline_start` (beats) to seconds for comparison.
pub fn active_clip_instances_at(&self, time_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Vec<&ClipInstance> {
use crate::effect::EFFECT_DURATION;
let time_beats = tempo_map.inverse_transform(time_secs);
self.clip_instances
.iter()
.filter(|e| {
let end = e.timeline_start + e.effective_duration(EFFECT_DURATION);
time >= e.timeline_start && time < end
let end = e.timeline_start + e.effective_duration(EFFECT_DURATION, tempo_map);
time_beats >= e.timeline_start && time_beats < end
})
.collect()
}
@ -239,13 +241,13 @@ mod tests {
layer.add_clip_instance(effect2);
// At time 2: only effect1 active
assert_eq!(layer.active_clip_instances_at(2.0).len(), 1);
assert_eq!(layer.active_clip_instances_at(2.0, 60.0).len(), 1);
// At time 4: both effects active
assert_eq!(layer.active_clip_instances_at(4.0).len(), 2);
assert_eq!(layer.active_clip_instances_at(4.0, 60.0).len(), 2);
// At time 7: only effect2 active
assert_eq!(layer.active_clip_instances_at(7.0).len(), 1);
assert_eq!(layer.active_clip_instances_at(7.0, 60.0).len(), 1);
}
#[test]

View File

@ -76,6 +76,9 @@ pub struct AudioExportSettings {
/// End time in seconds
pub end_time: f64,
/// Project BPM (for beat-position scheduling during export)
pub bpm: f64,
}
impl Default for AudioExportSettings {
@ -88,6 +91,7 @@ impl Default for AudioExportSettings {
bitrate_kbps: 320,
start_time: 0.0,
end_time: 60.0,
bpm: 120.0,
}
}
}

View File

@ -491,6 +491,8 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
// 5. Extract document and audio backend state
let step5_start = std::time::Instant::now();
let mut document = beam_project.ui_state;
// Rebuild derived seconds cache on all TempoMap entries after deserialization.
document.tempo_map_mut().rebuild_seconds();
let mut audio_project = beam_project.audio_backend.project;
let audio_pool_entries = beam_project.audio_backend.audio_pool_entries;
let layer_to_track_map = beam_project.audio_backend.layer_to_track_map;

View File

@ -256,15 +256,20 @@ pub fn hit_test_clip_instances(
parent_transform: Affine,
timeline_time: f64,
) -> Option<Uuid> {
let tempo_map = document.tempo_map();
for clip_instance in clip_instances.iter().rev() {
// Check time bounds: skip clip instances not active at this time
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration);
if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end {
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
let timeline_beats = tempo_map.inverse_transform(timeline_time);
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
continue;
}
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
// clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds)
let start_secs = tempo_map.transform(clip_instance.timeline_start);
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
vector_clip.calculate_content_bounds(document, clip_time)
@ -294,16 +299,20 @@ pub fn hit_test_clip_instances_in_rect(
timeline_time: f64,
) -> Vec<Uuid> {
let mut hits = Vec::new();
let tempo_map = document.tempo_map();
for clip_instance in clip_instances {
// Check time bounds: skip clip instances not active at this time
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration);
if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end {
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
let timeline_beats = tempo_map.inverse_transform(timeline_time);
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
continue;
}
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
let start_secs = tempo_map.transform(clip_instance.timeline_start);
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
vector_clip.calculate_content_bounds(document, clip_time)

View File

@ -3,6 +3,7 @@
//! Layers organize objects and shapes, and contain animation data.
use crate::animation::AnimationData;
use crate::tempo_map::TempoMap;
use crate::clip::ClipInstance;
use crate::vector_graph::VectorGraph;
use crate::effect_layer::EffectLayer;
@ -720,6 +721,12 @@ pub struct GroupLayer {
/// Whether the group is expanded in the timeline
#[serde(default = "default_true")]
pub expanded: bool,
/// Optional tempo map for this group. When `Some`, the group's children use
/// this tempo map for beat-to-parent-time conversion. The master group always
/// has `Some`; regular groups default to `None` (inherit parent tempo).
#[serde(default)]
pub tempo_map: Option<TempoMap>,
}
fn default_true() -> bool {
@ -746,6 +753,12 @@ impl LayerTrait for GroupLayer {
fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; }
}
impl Default for GroupLayer {
fn default() -> Self {
Self::new("Group")
}
}
impl GroupLayer {
/// Create a new group layer
pub fn new(name: impl Into<String>) -> Self {
@ -753,6 +766,21 @@ impl GroupLayer {
layer: Layer::new(LayerType::Group, name),
children: Vec::new(),
expanded: true,
tempo_map: None,
}
}
/// Create a master group layer with a constant tempo.
pub fn new_master(bpm: f64) -> Self {
Self {
layer: Layer::with_id(
uuid::Uuid::nil(), // replaced by Document::new
LayerType::Group,
"Master",
),
children: Vec::new(),
expanded: true,
tempo_map: Some(TempoMap::constant(bpm)),
}
}
@ -773,7 +801,7 @@ impl GroupLayer {
AnyLayer::Effect(l) => &l.clip_instances,
AnyLayer::Group(_) => &[], // no nested groups
AnyLayer::Raster(_) => &[], // raster layers have no clip instances
};
};
for ci in instances {
result.push((child_id, ci));
}

View File

@ -2,6 +2,7 @@
// Shared data structures and types
pub mod beat_time;
pub mod tempo_map;
pub mod gpu;
pub mod layout;
pub mod pane;

View File

@ -391,9 +391,10 @@ pub fn render_layer_isolated(
let mut video_mgr = video_manager.lock().unwrap();
let mut instances = Vec::new();
let tempo_map = document.tempo_map();
for clip_instance in &video_layer.clip_instances {
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else { continue };
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else { continue };
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time) else { continue };
// Evaluate animated transform properties.
@ -458,8 +459,9 @@ pub fn render_layer_isolated(
AnyLayer::Effect(effect_layer) => {
// Effect layers are processed during compositing, not rendered to scene
// Return early with a dedicated effect layer type
let tempo_map = document.tempo_map();
let active_effects: Vec<ClipInstance> = effect_layer
.active_clip_instances_at(time)
.active_clip_instances_at(time, tempo_map)
.into_iter()
.cloned()
.collect();
@ -728,16 +730,19 @@ fn render_clip_instance(
};
// Remap timeline time to clip's internal time
let tempo_map = document.tempo_map();
let clip_time = if vector_clip.is_group {
// Groups are static — visible from timeline_start to the next keyframe boundary
let end = group_end_time.unwrap_or(clip_instance.timeline_start);
if time < clip_instance.timeline_start || time >= end {
// Groups are static — visible from timeline_start to the next keyframe boundary.
// timeline_start is in beats; group_end_time is in seconds (render time).
let start_secs = tempo_map.transform(clip_instance.timeline_start);
let end = group_end_time.unwrap_or(start_secs);
if time < start_secs || time >= end {
return;
}
0.0
} else {
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration);
let Some(t) = clip_instance.remap_time(time, clip_dur) else {
let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else {
return; // Clip instance not active at this time
};
t
@ -889,7 +894,8 @@ fn render_video_layer(
};
// Remap timeline time to clip's internal time
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else {
let tempo_map = document.tempo_map();
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else {
continue; // Clip instance not active at this time
};
@ -1553,13 +1559,15 @@ fn render_clip_instance_cpu(
) {
let Some(vector_clip) = document.vector_clips.get(&clip_instance.clip_id) else { return };
let tempo_map = document.tempo_map();
let clip_time = if vector_clip.is_group {
let end = group_end_time.unwrap_or(clip_instance.timeline_start);
if time < clip_instance.timeline_start || time >= end { return; }
let start_secs = tempo_map.transform(clip_instance.timeline_start);
let end = group_end_time.unwrap_or(start_secs);
if time < start_secs || time >= end { return; }
0.0
} else {
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration);
let Some(t) = clip_instance.remap_time(time, clip_dur) else { return };
let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else { return };
t
};

View File

@ -0,0 +1 @@
pub use daw_backend::tempo_map::{TempoEntry, TempoMap};

View File

@ -69,8 +69,9 @@ fn export_audio_daw_backend<P: AsRef<Path>>(
channels: settings.channels,
bit_depth: settings.bit_depth,
mp3_bitrate: 320, // Not used for WAV/FLAC
start_time: settings.start_time,
end_time: settings.end_time,
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
};
// Use the existing DAW backend export function
@ -105,8 +106,9 @@ fn export_audio_ffmpeg_mp3<P: AsRef<Path>>(
channels: settings.channels,
bit_depth: 16, // Unused
mp3_bitrate: settings.bitrate_kbps,
start_time: settings.start_time,
end_time: settings.end_time,
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
};
// Step 1: Render audio to memory
@ -294,8 +296,9 @@ fn export_audio_ffmpeg_aac<P: AsRef<Path>>(
channels: settings.channels,
bit_depth: 16, // Unused
mp3_bitrate: settings.bitrate_kbps,
start_time: settings.start_time,
end_time: settings.end_time,
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
};
// Step 1: Render audio to memory

View File

@ -696,8 +696,9 @@ impl ExportOrchestrator {
channels: settings.channels,
bit_depth: settings.bit_depth,
mp3_bitrate: settings.bitrate_kbps,
start_time: settings.start_time,
end_time: settings.end_time,
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
};
// Use DAW backend export for all formats

View File

@ -882,7 +882,7 @@ fn composite_document_to_hdr(
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def,
effect_instance.timeline_start,
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, document.tempo_map()),
);
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {

View File

@ -1844,7 +1844,7 @@ impl EditorApp {
let mut result = Vec::new();
for instance in clip_instances {
if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) {
let effective_duration = instance.effective_duration(clip_duration);
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
let timeline_end = instance.timeline_start + effective_duration;
const EPSILON: f64 = 0.001;
@ -2698,7 +2698,7 @@ impl EditorApp {
let mut duplicate = original.clone();
duplicate.id = uuid::Uuid::new_v4();
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(1.0);
let effective_duration = original.effective_duration(clip_duration);
let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
duplicate.timeline_start = original.timeline_start + effective_duration;
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
duplicate.clip_id = *new_clip_def_id;
@ -3771,7 +3771,7 @@ impl EditorApp {
// Sync BPM/time signature to metronome
let doc = self.action_executor.document();
controller.set_tempo(
doc.bpm as f32,
doc.bpm() as f32,
(doc.time_signature.numerator, doc.time_signature.denominator),
);
}
@ -3834,9 +3834,6 @@ impl EditorApp {
// Rebuild MIDI event cache for all MIDI clips (needed for timeline/piano roll rendering)
let step8_start = std::time::Instant::now();
// Migrate old documents: compute beats/frames derived fields
self.action_executor.document_mut().sync_all_clip_positions();
self.midi_event_cache.clear();
let midi_clip_ids: Vec<u32> = self.action_executor.document()
.audio_clips.values()
@ -3859,19 +3856,6 @@ impl EditorApp {
}
eprintln!("📊 [APPLY] Step 8: Rebuilt MIDI event cache for {} clips in {:.2}ms", midi_fetched, step8_start.elapsed().as_secs_f64() * 1000.0);
// Sync beats/frames derived fields on MIDI events (migration for old documents)
{
let bpm = self.action_executor.document().bpm;
let fps = self.action_executor.document().framerate;
for events in self.midi_event_cache.values_mut() {
for ev in events.iter_mut() {
if ev.timestamp_beats == 0.0 && ev.timestamp.abs() > 1e-9 {
ev.sync_from_seconds(bpm, fps);
}
}
}
}
// Reset playback state
self.playback_time = 0.0;
self.is_playing = false;
@ -4002,15 +3986,11 @@ impl EditorApp {
/// Rebuild a MIDI event cache entry from backend note format.
/// Called after undo/redo to keep the cache consistent with the backend.
fn rebuild_midi_cache_entry(&mut self, clip_id: u32, notes: &[(f64, u8, u8, f64)]) {
let bpm = self.action_executor.document().bpm;
let fps = self.action_executor.document().framerate;
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(notes.len() * 2);
for &(start_time, note, velocity, duration) in notes {
let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity);
on.sync_from_seconds(bpm, fps);
let on = daw_backend::audio::midi::MidiEvent::note_on(daw_backend::Beats(start_time), 0, note, velocity);
events.push(on);
let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + duration, 0, note, 0);
off.sync_from_seconds(bpm, fps);
let off = daw_backend::audio::midi::MidiEvent::note_off(daw_backend::Beats(start_time + duration), 0, note, 0);
events.push(off);
}
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
@ -4048,14 +4028,14 @@ impl EditorApp {
let frontend_clip_id = self.action_executor.document_mut().add_audio_clip(clip);
println!("Imported MIDI '{}' ({:.1}s, {} total events) - Frontend ID: {}, Backend ID: {}",
name, duration, event_count, frontend_clip_id, backend_clip_id);
name, duration.beats_to_f64(), event_count, frontend_clip_id, backend_clip_id);
Some(ImportedAssetInfo {
clip_id: frontend_clip_id,
clip_type: panes::DragClipType::AudioMidi,
name,
dimensions: None,
duration,
duration: duration.beats_to_f64(),
linked_audio_clip_id: None,
})
} else {
@ -5032,7 +5012,7 @@ impl eframe::App for EditorApp {
if let Some(doc_clip_id) = doc_clip_id {
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
if clip.is_recording() {
clip.duration = duration;
clip.duration = duration.seconds_to_f64();
}
}
}
@ -5202,24 +5182,18 @@ impl eframe::App for EditorApp {
}
// Update the clip's duration so the timeline bar grows
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
clip.duration = duration;
clip.duration = duration.beats_to_f64();
}
}
}
// Update midi_event_cache with notes captured so far
// (inlined to avoid conflicting &mut self borrow)
// Update midi_event_cache with notes captured so far.
// Notes from get_notes_with_active() are already in beats.
{
let bpm = self.action_executor.document().bpm;
let fps = self.action_executor.document().framerate;
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(notes.len() * 2);
for &(start_time, note, velocity, dur) in &notes {
let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity);
on.sync_from_seconds(bpm, fps);
events.push(on);
let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + dur, 0, note, 0);
off.sync_from_seconds(bpm, fps);
events.push(off);
for &(start_beats, note, velocity, dur_beats) in &notes {
events.push(daw_backend::audio::midi::MidiEvent::note_on(start_beats, 0, note, velocity));
events.push(daw_backend::audio::midi::MidiEvent::note_off(start_beats + dur_beats, 0, note, 0));
}
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
self.midi_event_cache.insert(clip_id, events);
@ -5236,13 +5210,7 @@ impl eframe::App for EditorApp {
match controller.query_midi_clip(track_id, clip_id) {
Ok(midi_clip_data) => {
drop(controller);
let bpm = self.action_executor.document().bpm;
let fps = self.action_executor.document().framerate;
let mut final_events = midi_clip_data.events.clone();
for ev in &mut final_events {
ev.sync_from_seconds(bpm, fps);
}
self.midi_event_cache.insert(clip_id, final_events);
self.midi_event_cache.insert(clip_id, midi_clip_data.events.clone());
// Update document clip with final duration and name
let doc_clip_id = self.action_executor.document()

View File

@ -391,10 +391,10 @@ fn generate_midi_thumbnail(
// Draw note events
for event in events {
if !event.is_note_on() || event.timestamp > preview_duration {
if !event.is_note_on() || event.timestamp.beats_to_f64() > preview_duration {
continue;
}
let (timestamp, note_number) = (event.timestamp, event.data1);
let (timestamp, note_number) = (event.timestamp.beats_to_f64(), event.data1);
let x = ((timestamp / preview_duration) * size as f64) as usize;

View File

@ -1147,7 +1147,7 @@ impl InfopanelPane {
let clip_dur = document.get_clip_duration(&ci.clip_id)
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start);
let total_dur = ci.total_duration(clip_dur);
let total_dur = ci.total_duration(clip_dur, document.tempo_map());
ui.horizontal(|ui| {
ui.label("Duration:");
ui.label(format!("{:.2}s", total_dur));
@ -1215,10 +1215,10 @@ impl InfopanelPane {
let mut pending: std::collections::HashMap<u8, (f64, u8)> = std::collections::HashMap::new();
for event in events {
if event.is_note_on() {
pending.insert(event.data1, (event.timestamp, event.data2));
pending.insert(event.data1, (event.timestamp.beats_to_f64(), event.data2));
} else if event.is_note_off() {
if let Some((start, v)) = pending.remove(&event.data1) {
notes.push((start, event.data1, v, event.timestamp - start));
notes.push((start, event.data1, v, event.timestamp.beats_to_f64() - start));
}
}
}

View File

@ -255,10 +255,10 @@ impl PianoRollPane {
for event in events {
let channel = event.status & 0x0F;
if event.is_note_on() {
active.insert(event.data1, (event.timestamp, event.data2, channel));
active.insert(event.data1, (event.timestamp.beats_to_f64(), event.data2, channel));
} else if event.is_note_off() {
if let Some((start, vel, ch)) = active.remove(&event.data1) {
let duration = (event.timestamp - start).max(MIN_NOTE_DURATION);
let duration = (event.timestamp.beats_to_f64() - start).max(MIN_NOTE_DURATION);
notes.push(ResolvedNote {
note: event.data1,
channel: ch,
@ -292,13 +292,13 @@ impl PianoRollPane {
// ── Ruler interval calculation ───────────────────────────────────────
fn ruler_interval(&self, bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 {
fn ruler_interval(&self, tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 {
let min_pixel_gap = 80.0;
let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64;
// Use beat-aligned intervals
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
let measure_dur = lightningbeam_core::beat_time::measure_duration(0.0, tempo_map, time_sig);
let beat_intervals = [
beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0,
measure_dur, measure_dur * 2.0, measure_dur * 4.0,
@ -315,8 +315,8 @@ impl PianoRollPane {
} // end impl PianoRollPane (snap helpers follow as free functions)
fn snap_to_value(t: f64, snap: SnapValue, bpm: f64) -> f64 {
let beat = 60.0 / bpm;
fn snap_to_value(t: f64, snap: SnapValue, tempo_map: &daw_backend::TempoMap) -> f64 {
let beat = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
match snap {
SnapValue::None => t,
SnapValue::Whole => round_to_grid(t, beat * 4.0),
@ -347,7 +347,7 @@ fn snap_swing(t: f64, cell: f64, ratio: f64) -> f64 {
*cands.iter().min_by(|&&a, &&b| (a - t).abs().partial_cmp(&(b - t).abs()).unwrap()).unwrap()
}
fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue {
fn detect_snap(notes: &[&ResolvedNote], tempo_map: &daw_backend::TempoMap) -> SnapValue {
const EPS: f64 = 0.002;
if notes.is_empty() { return SnapValue::None; }
let order = [
@ -359,7 +359,7 @@ fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue {
SnapValue::SixteenthTriplet, SnapValue::ThirtySecondTriplet,
];
for &sv in &order {
if notes.iter().all(|n| (snap_to_value(n.start_time, sv, bpm) - n.start_time).abs() < EPS) {
if notes.iter().all(|n| (snap_to_value(n.start_time, sv, tempo_map) - n.start_time).abs() < EPS) {
return sv;
}
}
@ -425,7 +425,7 @@ impl PianoRollPane {
let clip_doc_id = clip_doc_id; // doc-side AudioClip uuid
let duration = mc.external_duration;
let instance_uuid = Uuid::nil(); // no doc-side instance uuid yet
clip_data.push((mc.clip_id, mc.external_start, mc.internal_start, duration, instance_uuid));
clip_data.push((mc.clip_id, mc.external_start.beats_to_f64(), mc.internal_start.beats_to_f64(), duration.beats_to_f64(), instance_uuid));
let _ = clip_doc_id; // used above for the if-let pattern
}
}
@ -435,7 +435,7 @@ impl PianoRollPane {
for instance in &audio_layer.clip_instances {
if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type {
let duration = instance.effective_duration(clip.duration);
let duration = instance.effective_duration(clip.duration, document.tempo_map());
clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id));
}
}
@ -456,8 +456,8 @@ impl PianoRollPane {
self.snap_user_changed = false;
if self.snap_value != SnapValue::None && !self.selected_note_indices.is_empty() {
if let Some(clip_id) = self.selected_clip_id {
let bpm = shared.action_executor.document().bpm;
self.quantize_selected_notes(clip_id, bpm, shared);
let tempo_map = shared.action_executor.document().tempo_map().clone();
self.quantize_selected_notes(clip_id, &tempo_map, shared);
}
}
}
@ -484,11 +484,11 @@ impl PianoRollPane {
// Render grid (clipped to grid area)
let grid_painter = ui.painter_at(grid_rect);
let (grid_bpm, grid_time_sig) = {
let (grid_tempo_map, grid_time_sig) = {
let doc = shared.action_executor.document();
(doc.bpm, doc.time_signature.clone())
(doc.tempo_map().clone(), doc.time_signature.clone())
};
self.render_grid(&grid_painter, grid_rect, grid_bpm, &grid_time_sig);
self.render_grid(&grid_painter, grid_rect, &grid_tempo_map, &grid_time_sig);
// Render clip boundaries and notes
for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data {
@ -621,7 +621,7 @@ impl PianoRollPane {
}
fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect,
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) {
// Horizontal lines (note separators)
for note in MIN_NOTE..=MAX_NOTE {
let y = self.note_to_y(note, grid_rect);
@ -648,9 +648,9 @@ impl PianoRollPane {
}
// Vertical lines (beat-aligned time grid)
let interval = self.ruler_interval(bpm, time_sig);
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
let interval = self.ruler_interval(tempo_map, time_sig);
let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
let measure_dur = lightningbeam_core::beat_time::measure_duration(0.0, tempo_map, time_sig);
let start = (self.viewport_start_time / interval).floor() as i64;
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
@ -675,7 +675,7 @@ impl PianoRollPane {
// Labels at measure boundaries
if is_measure && x > grid_rect.min.x + 20.0 {
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
let pos = lightningbeam_core::beat_time::time_to_measure(time, tempo_map, time_sig);
painter.text(
pos2(x + 2.0, grid_rect.min.y + 2.0),
Align2::LEFT_TOP,
@ -685,7 +685,7 @@ impl PianoRollPane {
);
} else if is_beat && !is_measure && x > grid_rect.min.x + 20.0
&& beat_dur as f32 * self.pixels_per_second > 40.0 {
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
let pos = lightningbeam_core::beat_time::time_to_measure(time, tempo_map, time_sig);
painter.text(
pos2(x + 2.0, grid_rect.min.y + 2.0),
Align2::LEFT_TOP,
@ -708,8 +708,8 @@ impl PianoRollPane {
) -> f32 {
let mut peak = 0.0f32;
for ev in events {
if ev.timestamp > note_end + 0.01 { break; }
if ev.timestamp >= note_start - 0.01
if ev.timestamp.beats_to_f64() > note_end + 0.01 { break; }
if ev.timestamp.beats_to_f64() >= note_start - 0.01
&& (ev.status & 0xF0) == 0xE0
&& (ev.status & 0x0F) == channel
{
@ -770,7 +770,7 @@ impl PianoRollPane {
};
let timestamp = note_start + t * note_duration;
let (lsb, msb) = encode_bend(normalized);
events.push(MidiEvent::new(timestamp, 0xE0 | channel, lsb, msb));
events.push(MidiEvent::new(daw_backend::Beats(timestamp), 0xE0 | channel, lsb, msb));
}
events
}
@ -794,11 +794,11 @@ impl PianoRollPane {
let ch = ev.status & 0x0F;
let msg = ev.status & 0xF0;
if msg == 0x90 && ev.data2 > 0 {
active.insert((ev.data1, ch), ev.timestamp);
active.insert((ev.data1, ch), ev.timestamp.beats_to_f64());
} else if msg == 0x80 || (msg == 0x90 && ev.data2 == 0) {
if let Some(start) = active.remove(&(ev.data1, ch)) {
// Overlaps target range and is NOT the note we're assigning
if start < note_end && ev.timestamp > note_start
if start < note_end && ev.timestamp.beats_to_f64() > note_start
&& !(ev.data1 == note_pitch && ch == current_channel)
{
used[ch as usize] = true;
@ -828,9 +828,9 @@ impl PianoRollPane {
fn find_cc1_for_note(events: &[daw_backend::audio::midi::MidiEvent], note_start: f64, note_end: f64, channel: u8) -> u8 {
let mut cc1 = 0u8;
for ev in events {
if ev.timestamp > note_end { break; }
if ev.timestamp.beats_to_f64() > note_end { break; }
if (ev.status & 0xF0) == 0xB0 && (ev.status & 0x0F) == channel && ev.data1 == 1 {
if ev.timestamp <= note_start {
if ev.timestamp.beats_to_f64() <= note_start {
cc1 = ev.data2;
}
}
@ -950,7 +950,7 @@ impl PianoRollPane {
let ts = note.start_time + t as f64 * note.duration;
let mut existing_norm = 0.0f32;
for ev in events {
if ev.timestamp > ts { break; }
if ev.timestamp.beats_to_f64() > ts { break; }
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == drag_ch {
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
existing_norm = (raw - 8192) as f32 / 8192.0;
@ -991,7 +991,7 @@ impl PianoRollPane {
// Find last pitch bend event at or before ts
let mut bend_norm = 0.0f32;
for ev in events {
if ev.timestamp > ts { break; }
if ev.timestamp.beats_to_f64() > ts { break; }
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == note.channel {
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
bend_norm = (raw - 8192) as f32 / 8192.0;
@ -1037,9 +1037,9 @@ impl PianoRollPane {
}
fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect,
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) {
// Collect visible time grid positions
let interval = self.ruler_interval(bpm, time_sig);
let interval = self.ruler_interval(tempo_map, time_sig);
let start = (self.viewport_start_time / interval).floor() as i64;
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
let end = (end_time / interval).ceil() as i64;
@ -1374,10 +1374,10 @@ impl PianoRollPane {
if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) {
let clip_start = selected_clip.1;
let trim_start = selected_clip.2;
let bpm = shared.action_executor.document().bpm;
let tempo_map = shared.action_executor.document().tempo_map();
let clip_local_time = snap_to_value(
(time - clip_start).max(0.0) + trim_start,
self.snap_value, bpm,
self.snap_value, tempo_map,
);
self.creating_note = Some(TempNote {
note,
@ -1395,8 +1395,8 @@ impl PianoRollPane {
self.selection_rect = Some((pos, pos));
self.drag_mode = Some(DragMode::SelectRect);
let bpm = shared.action_executor.document().bpm;
let seek_time = snap_to_value(time.max(0.0), self.snap_value, bpm);
let tempo_map = shared.action_executor.document().tempo_map();
let seek_time = snap_to_value(time.max(0.0), self.snap_value, tempo_map);
*shared.playback_time = seek_time;
if let Some(ctrl) = shared.audio_controller.as_ref() {
if let Ok(mut c) = ctrl.lock() { c.seek(seek_time); }
@ -1538,7 +1538,7 @@ impl PianoRollPane {
let ts = note_start + t * note_duration;
let mut bend = 0.0f32;
for ev in &new_events {
if ev.timestamp > ts { break; }
if ev.timestamp.beats_to_f64() > ts { break; }
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel {
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
bend = (raw - 8192) as f32 / 8192.0;
@ -1550,7 +1550,8 @@ impl PianoRollPane {
// Remove old bend events in range before writing combined
new_events.retain(|ev| {
let is_bend = (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel;
let in_range = ev.timestamp >= note_start - 0.001 && ev.timestamp <= note_start + note_duration + 0.01;
let ts = ev.timestamp.beats_to_f64();
let in_range = ts >= note_start - 0.001 && ts <= note_start + note_duration + 0.01;
!(is_bend && in_range)
});
@ -1568,20 +1569,15 @@ impl PianoRollPane {
let combined = (existing_norm[i] + zone_norm).clamp(-1.0, 1.0);
let (lsb, msb) = encode_bend(combined);
let ts = note_start + i as f64 / num_steps as f64 * note_duration;
new_events.push(daw_backend::audio::midi::MidiEvent::new(ts, 0xE0 | target_channel, lsb, msb));
new_events.push(daw_backend::audio::midi::MidiEvent::new(daw_backend::Beats(ts), 0xE0 | target_channel, lsb, msb));
}
// For End zone: reset just after note ends so it doesn't bleed into next note
if zone == PitchBendZone::End {
let (lsb, msb) = encode_bend(0.0);
new_events.push(daw_backend::audio::midi::MidiEvent::new(note_start + note_duration + 0.005, 0xE0 | target_channel, lsb, msb));
new_events.push(daw_backend::audio::midi::MidiEvent::new(daw_backend::Beats(note_start + note_duration + 0.005), 0xE0 | target_channel, lsb, msb));
}
new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal));
{
let doc = shared.action_executor.document();
let bpm = doc.bpm; let fps = doc.framerate;
for ev in &mut new_events { ev.sync_from_seconds(bpm, fps); }
}
self.push_events_action("Set pitch bend", clip_id, old_events, new_events.clone(), shared);
shared.midi_event_cache.insert(clip_id, new_events);
}
@ -1737,8 +1733,8 @@ impl PianoRollPane {
fn update_cache_from_resolved(clip_id: u32, resolved: &[ResolvedNote], shared: &mut SharedPaneState) {
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(resolved.len() * 2);
for n in resolved {
events.push(daw_backend::audio::midi::MidiEvent::note_on(n.start_time, 0, n.note, n.velocity));
events.push(daw_backend::audio::midi::MidiEvent::note_off(n.start_time + n.duration, 0, n.note, 0));
events.push(daw_backend::audio::midi::MidiEvent::note_on(daw_backend::Beats(n.start_time), 0, n.note, n.velocity));
events.push(daw_backend::audio::midi::MidiEvent::note_off(daw_backend::Beats(n.start_time + n.duration), 0, n.note, 0));
}
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
shared.midi_event_cache.insert(clip_id, events);
@ -1787,12 +1783,12 @@ impl PianoRollPane {
let resolved = Self::resolve_notes(events);
let old_notes = Self::notes_to_backend_format(&resolved);
let bpm = shared.action_executor.document().bpm;
let tempo_map = shared.action_executor.document().tempo_map();
let mut new_resolved = resolved.clone();
for &idx in &self.selected_note_indices {
if idx < new_resolved.len() {
let raw_time = (new_resolved[idx].start_time + dt).max(0.0);
new_resolved[idx].start_time = snap_to_value(raw_time, self.snap_value, bpm);
new_resolved[idx].start_time = snap_to_value(raw_time, self.snap_value, tempo_map);
new_resolved[idx].note = (new_resolved[idx].note as i32 + dn).clamp(0, 127) as u8;
}
}
@ -1968,7 +1964,7 @@ impl PianoRollPane {
shared.pending_actions.push(Box::new(action));
}
fn quantize_selected_notes(&mut self, clip_id: u32, bpm: f64, shared: &mut SharedPaneState) {
fn quantize_selected_notes(&mut self, clip_id: u32, tempo_map: &daw_backend::TempoMap, shared: &mut SharedPaneState) {
let events = match shared.midi_event_cache.get(&clip_id) { Some(e) => e, None => return };
let resolved = Self::resolve_notes(events);
let old_notes = Self::notes_to_backend_format(&resolved);
@ -1976,7 +1972,7 @@ impl PianoRollPane {
for &idx in &self.selected_note_indices {
if idx < new_resolved.len() {
new_resolved[idx].start_time =
snap_to_value(new_resolved[idx].start_time, self.snap_value, bpm).max(0.0);
snap_to_value(new_resolved[idx].start_time, self.snap_value, tempo_map).max(0.0);
}
}
let new_notes = Self::notes_to_backend_format(&new_resolved);
@ -2075,11 +2071,11 @@ impl PianoRollPane {
// Dot grid background (visible where the spectrogram doesn't draw)
let grid_painter = ui.painter_at(view_rect);
{
let (dot_bpm, dot_ts) = {
let (dot_tempo_map, dot_ts) = {
let doc = shared.action_executor.document();
(doc.bpm, doc.time_signature.clone())
(doc.tempo_map().clone(), doc.time_signature.clone())
};
self.render_dot_grid(&grid_painter, view_rect, dot_bpm, &dot_ts);
self.render_dot_grid(&grid_painter, view_rect, &dot_tempo_map, &dot_ts);
}
// Find audio pool index for the active layer's clips
@ -2323,7 +2319,7 @@ impl PaneRenderer for PianoRollPane {
for ev in cached.iter_mut() {
if ev.is_note_on() && ev.data1 == sn.note
&& (ev.status & 0x0F) == sn.channel
&& (ev.timestamp - sn.start_time).abs() < 1e-6
&& (ev.timestamp.beats_to_f64() - sn.start_time).abs() < 1e-6
{
ev.data2 = new_vel;
}
@ -2360,21 +2356,16 @@ impl PaneRenderer for PianoRollPane {
let is_cc1 = (ev.status & 0xF0) == 0xB0
&& (ev.status & 0x0F) == sn.channel
&& ev.data1 == 1;
let at_start = (ev.timestamp - sn.start_time).abs() < 0.001;
let at_start = (ev.timestamp.beats_to_f64() - sn.start_time).abs() < 0.001;
!(is_cc1 && at_start)
});
if new_cc1 > 0 {
new_events.push(daw_backend::audio::midi::MidiEvent::new(
sn.start_time, 0xB0 | sn.channel, 1, new_cc1,
daw_backend::Beats(sn.start_time), 0xB0 | sn.channel, 1, new_cc1,
));
}
}
new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal));
{
let doc = shared.action_executor.document();
let bpm = doc.bpm; let fps = doc.framerate;
for ev in &mut new_events { ev.sync_from_seconds(bpm, fps); }
}
self.push_events_action("Set modulation", clip_id, old_events, new_events.clone(), shared);
shared.midi_event_cache.insert(clip_id, new_events);
}
@ -2416,7 +2407,7 @@ impl PaneRenderer for PianoRollPane {
// Snap-to dropdown — only in Measures mode
let doc = shared.action_executor.document();
let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures;
let bpm = doc.bpm;
let tempo_map = doc.tempo_map();
drop(doc);
if is_measures {
@ -2429,7 +2420,7 @@ impl PaneRenderer for PianoRollPane {
let sel: Vec<&ResolvedNote> = self.selected_note_indices.iter()
.filter_map(|&i| resolved.get(i))
.collect();
self.snap_value = detect_snap(&sel, bpm);
self.snap_value = detect_snap(&sel, tempo_map);
}
}
}

View File

@ -1438,7 +1438,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def,
effect_instance.timeline_start,
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, self.ctx.document.tempo_map()),
);
// Acquire temp buffer for effect output (HDR format)
@ -1793,7 +1793,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
// Skip clip instances not active at current time
let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur);
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur, self.ctx.document.tempo_map());
if self.ctx.playback_time < clip_instance.timeline_start || self.ctx.playback_time >= instance_end {
continue;
}
@ -2214,7 +2214,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Find clip instance visible at playback time
let visible_clip = video_layer.clip_instances.iter().find(|inst| {
let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(0.0);
let effective_duration = inst.effective_duration(clip_duration);
let effective_duration = inst.effective_duration(clip_duration, self.ctx.document.tempo_map());
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
});
@ -10287,7 +10287,7 @@ impl StagePane {
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
video_layer.clip_instances.iter().find(|inst| {
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(0.0);
let effective_duration = inst.effective_duration(clip_duration);
let effective_duration = inst.effective_duration(clip_duration, document.tempo_map());
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
}).map(|inst| inst.id)
} else {

View File

@ -74,10 +74,11 @@ fn compute_clip_stacking(
return vec![(0, 1); clip_instances.len()];
}
let tempo_map = document.tempo_map();
let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0);
let start = ci.effective_start();
let end = start + ci.total_duration(clip_dur);
let end = start + ci.total_duration(clip_dur, tempo_map);
(start, end)
}).collect();
@ -510,20 +511,14 @@ fn build_audio_clip_cache(
.unwrap_or_else(|| audio_backend_uuid(ac.id));
let mut ci = ClipInstance::new(clip_id);
ci.id = instance_id;
ci.timeline_start = ac.external_start;
ci.timeline_start_beats = ac.external_start_beats;
ci.timeline_start_frames = ac.external_start_frames;
ci.trim_start = ac.internal_start;
ci.trim_start_beats = ac.internal_start_beats;
ci.trim_start_frames = ac.internal_start_frames;
ci.trim_end = Some(ac.internal_end);
ci.trim_end_beats = Some(ac.internal_end_beats);
ci.trim_end_frames = Some(ac.internal_end_frames);
let internal_dur = ac.internal_end - ac.internal_start;
if (ac.external_duration - internal_dur).abs() > 1e-9 {
ci.timeline_duration = Some(ac.external_duration);
ci.timeline_duration_beats = Some(ac.external_duration_beats);
ci.timeline_duration_frames = Some(ac.external_duration_frames);
ci.timeline_start = ac.external_start.beats_to_f64();
ci.trim_start = ac.internal_start.seconds_to_f64();
ci.trim_end = Some(ac.internal_end.seconds_to_f64());
let internal_dur_secs = (ac.internal_end - ac.internal_start).seconds_to_f64();
let tempo_map = document.tempo_map();
let external_dur_secs = tempo_map.transform(ac.external_duration.beats_to_f64());
if (external_dur_secs - internal_dur_secs).abs() > 1e-9 {
ci.timeline_duration = Some(ac.external_duration.beats_to_f64());
}
ci.gain = ac.gain;
instances.push(ci);
@ -540,21 +535,12 @@ fn build_audio_clip_cache(
.unwrap_or_else(|| midi_backend_uuid(mc.id));
let mut ci = ClipInstance::new(clip_id);
ci.id = instance_id;
ci.timeline_start = mc.external_start;
ci.timeline_start_beats = mc.external_start_beats;
ci.timeline_start_frames = mc.external_start_frames;
ci.trim_start = mc.internal_start;
ci.trim_start_beats = mc.internal_start_beats;
ci.trim_start_frames = mc.internal_start_frames;
ci.trim_end = Some(mc.internal_end);
ci.trim_end_beats = Some(mc.internal_end_beats);
ci.trim_end_frames = Some(mc.internal_end_frames);
let internal_dur = mc.internal_end - mc.internal_start;
if (mc.external_duration - internal_dur).abs() > 1e-9 {
ci.timeline_duration = Some(mc.external_duration);
ci.timeline_duration_beats = Some(mc.external_duration_beats);
ci.timeline_duration_frames = Some(mc.external_duration_frames);
}
ci.timeline_start = mc.external_start.beats_to_f64();
ci.trim_start = mc.internal_start.beats_to_f64();
ci.trim_end = Some(mc.internal_end.beats_to_f64());
// Always set timeline_duration for MIDI clips: duration is in beats, so we
// must bypass the content_window_secs * bpm/60 formula (which expects seconds).
ci.timeline_duration = Some(mc.external_duration.beats_to_f64());
instances.push(ci);
}
}
@ -955,12 +941,19 @@ impl TimelinePane {
&& *shared.metronome_enabled
&& self.time_display_format == TimelineMode::Measures
{
let (bpm, beats_per_measure) = {
let (tempo_map, beats_per_measure) = {
let doc = shared.action_executor.document();
(doc.bpm, doc.time_signature.numerator as f64)
(doc.tempo_map().clone(), doc.time_signature.numerator as f64)
};
// Convert start_time to beats, subtract one measure, convert back to seconds
let start_beat = tempo_map.inverse_transform(start_time);
let count_in_start_beat = start_beat - beats_per_measure;
let seek_to = if count_in_start_beat >= 0.0 {
tempo_map.transform(count_in_start_beat)
} else {
// Before beat 0: extrapolate using initial BPM
tempo_map.transform(0.0) - count_in_start_beat.abs() * (60.0 / tempo_map.global_bpm())
};
let count_in_duration = beats_per_measure * (60.0 / bpm);
let seek_to = start_time - count_in_duration; // may be negative
if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
@ -1025,14 +1018,11 @@ impl TimelinePane {
// Create document clip + clip instance immediately
let doc_clip = AudioClip::new_midi("Recording...",
*shared.recording_clips.get(&layer_id).unwrap_or(&0), 0.0);
*shared.recording_clips.get(&layer_id).unwrap_or(&0), daw_backend::Beats::ZERO);
let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip);
let bpm = shared.action_executor.document().bpm;
let fps = shared.action_executor.document().framerate;
let mut clip_instance = ClipInstance::new(doc_clip_id)
.with_timeline_start(start_time);
clip_instance.sync_from_seconds(bpm, fps);
if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer {
@ -1203,8 +1193,9 @@ impl TimelinePane {
for (ci_idx, clip_instance) in clip_instances.iter().enumerate() {
let clip_duration = effective_clip_duration(document, layer, clip_instance)?;
let tempo_map = document.tempo_map();
let instance_start = clip_instance.effective_start();
let instance_duration = clip_instance.total_duration(clip_duration);
let instance_duration = clip_instance.total_duration(clip_duration, tempo_map);
let instance_end = instance_start + instance_duration;
let start_x = self.time_to_x(instance_start);
@ -1284,12 +1275,13 @@ impl TimelinePane {
let child_clips = group.all_child_clip_instances();
let mut spans: Vec<(f64, f64, Vec<uuid::Uuid>)> = Vec::new(); // (start, end, clip_ids)
let tempo_map = document.tempo_map();
for (_child_layer_id, ci) in &child_clips {
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
ci.trim_end.unwrap_or(1.0) - ci.trim_start
});
let start = ci.effective_start();
let end = start + ci.total_duration(clip_dur);
let end = start + ci.total_duration(clip_dur, tempo_map);
spans.push((start, end, vec![ci.id]));
}
@ -1367,57 +1359,23 @@ impl TimelinePane {
((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32
}
/// Effective display start for a clip instance.
/// Effective display start for a clip instance, in seconds.
///
/// In Measures mode, uses `timeline_start_beats` as the canonical position so clips stay
/// anchored to their beat position during live BPM drag preview. Falls back to seconds
/// in other modes or when beat data is unavailable.
fn instance_display_start(&self, ci: &lightningbeam_core::clip::ClipInstance, bpm: f64) -> f64 {
if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures
&& (ci.timeline_start_beats.abs() > 1e-12 || ci.timeline_start == 0.0)
{
ci.timeline_start_beats * 60.0 / bpm - ci.loop_before.unwrap_or(0.0)
} else {
ci.effective_start()
}
/// `timeline_start` is in beats; converts to seconds using the current (preview) BPM so
/// clips stay anchored to their beat position during live BPM drag.
fn instance_display_start(&self, ci: &lightningbeam_core::clip::ClipInstance, tempo_map: &daw_backend::TempoMap) -> f64 {
tempo_map.transform(ci.effective_start())
}
/// In Measures mode, uses beats fields for the clip's on-timeline duration so the width
/// stays correct during live BPM drag preview. Falls back to seconds in other modes.
fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, bpm: f64) -> f64 {
use lightningbeam_core::document::TimelineMode;
if self.time_display_format == TimelineMode::Measures {
// Looping/extended clip: explicit timeline_duration_beats
if let Some(dur_beats) = ci.timeline_duration_beats {
if dur_beats.abs() > 1e-12 {
return dur_beats * 60.0 / bpm;
}
}
// Non-looping: derive from trim range in beats
let ts_beats = ci.trim_start_beats;
if let Some(te_beats) = ci.trim_end_beats {
if te_beats.abs() > 1e-12 || ts_beats.abs() > 1e-12 {
return (te_beats - ts_beats).max(0.0) * 60.0 / bpm;
}
}
}
ci.total_duration(clip_dur_secs)
/// Effective on-timeline duration for a clip instance, in seconds.
///
/// `total_duration` is in beats; converts to seconds using the current (preview) BPM.
fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, tempo_map: &daw_backend::TempoMap) -> f64 {
tempo_map.transform(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) - tempo_map.transform(ci.effective_start())
}
/// In Measures mode, returns the clip content start (trim_start) and duration in
/// beat-derived display seconds, for use when rendering note overlays.
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, bpm: f64) -> (f64, f64) {
use lightningbeam_core::document::TimelineMode;
if self.time_display_format == TimelineMode::Measures {
let ts_beats = ci.trim_start_beats;
if let Some(te_beats) = ci.trim_end_beats {
if te_beats.abs() > 1e-12 || ts_beats.abs() > 1e-12 {
let start = ts_beats * 60.0 / bpm;
let dur = (te_beats - ts_beats).max(0.0) * 60.0 / bpm;
return (start, dur);
}
}
}
/// Returns the clip content start (trim_start) and duration in display seconds.
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, _bpm: f64) -> (f64, f64) {
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
(ci.trim_start, (trim_end - ci.trim_start).max(0.0))
}
@ -1433,7 +1391,7 @@ impl TimelinePane {
/// - Seconds mode: no snapping
fn quantize_grid_size(
&self,
bpm: f64,
tempo_map: &daw_backend::TempoMap,
time_sig: &lightningbeam_core::document::TimeSignature,
framerate: f64,
) -> Option<f64> {
@ -1441,8 +1399,8 @@ impl TimelinePane {
TimelineMode::Frames => Some(1.0 / framerate),
TimelineMode::Measures => {
use lightningbeam_core::beat_time::{beat_duration, measure_duration};
let beat = beat_duration(bpm);
let measure = measure_duration(bpm, time_sig);
let beat = beat_duration(0.0, tempo_map);
let measure = measure_duration(0.0, tempo_map, time_sig);
let pps = self.pixels_per_second as f64;
// Very zoomed in: 16th note > 40px → no snap
if pps * beat / 4.0 > 40.0 { return None; }
@ -1467,11 +1425,11 @@ impl TimelinePane {
fn snap_to_grid(
&self,
t: f64,
bpm: f64,
tempo_map: &daw_backend::TempoMap,
time_sig: &lightningbeam_core::document::TimeSignature,
framerate: f64,
) -> f64 {
match self.quantize_grid_size(bpm, time_sig, framerate) {
match self.quantize_grid_size(tempo_map, time_sig, framerate) {
Some(grid) => (t / grid).round() * grid,
None => t,
}
@ -1481,11 +1439,11 @@ impl TimelinePane {
/// Snaps the anchor clip's resulting position to the grid; all selected clips use the same offset.
fn snapped_move_offset(
&self,
bpm: f64,
tempo_map: &daw_backend::TempoMap,
time_sig: &lightningbeam_core::document::TimeSignature,
framerate: f64,
) -> f64 {
match self.quantize_grid_size(bpm, time_sig, framerate) {
match self.quantize_grid_size(tempo_map, time_sig, framerate) {
Some(grid) => {
let snapped = ((self.drag_anchor_start + self.drag_offset) / grid).round() * grid;
snapped - self.drag_anchor_start
@ -1524,7 +1482,7 @@ impl TimelinePane {
/// Render the time ruler at the top
fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme,
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64) {
tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64) {
let painter = ui.painter();
// Background
@ -1570,8 +1528,8 @@ impl TimelinePane {
}
}
TimelineMode::Measures => {
let beats_per_second = bpm / 60.0;
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
let beats_per_second = tempo_map.bpm_at(daw_backend::Beats(0.0)) / 60.0;
let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
let bpm_count = time_sig.numerator;
let px_per_beat = beat_dur as f32 * self.pixels_per_second;
@ -1724,15 +1682,10 @@ impl TimelinePane {
let note_style = theme.style(".timeline-midi-note", ctx);
let note_color = note_style.background_color().unwrap_or(egui::Color32::BLACK);
// In Measures mode during BPM drag, derive display time from beats so notes
// stay anchored to their beat positions.
// `timestamp` is in beats; convert to display-seconds using the current (preview) BPM.
let tempo_map = daw_backend::TempoMap::constant(display_bpm.unwrap_or(120.0));
let event_display_time = |ev: &daw_backend::audio::midi::MidiEvent| -> f64 {
if let Some(bpm) = display_bpm {
if ev.timestamp_beats.abs() > 1e-12 || ev.timestamp == 0.0 {
return ev.timestamp_beats * 60.0 / bpm;
}
}
ev.timestamp
tempo_map.transform(ev.timestamp.beats_to_f64())
};
// Build a map of active notes (note_number -> note_on_timestamp)
@ -2782,7 +2735,7 @@ impl TimelinePane {
}
}
TimelineMode::Measures => {
let beats_per_second = document.bpm / 60.0;
let beats_per_second = document.tempo_map().bpm_at(daw_backend::Beats(0.0)) / 60.0;
let bpm_count = document.time_signature.numerator;
let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64;
@ -2860,10 +2813,10 @@ impl TimelinePane {
ci.trim_end.unwrap_or(1.0) - ci.trim_start
});
let mut start = ci.effective_start();
let dur = ci.total_duration(clip_dur);
let dur = ci.total_duration(clip_dur, document.tempo_map());
// Apply drag offset for selected clips during move
if is_move_drag && selection.contains_clip_instance(&ci.id) {
start = (start + self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate)).max(0.0);
start = (start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0);
}
ranges.push((start, start + dur));
}
@ -2933,9 +2886,9 @@ impl TimelinePane {
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start);
let mut ci_start = ci.effective_start();
if is_move_drag && selection.contains_clip_instance(&ci.id) {
ci_start = (ci_start + self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate)).max(0.0);
ci_start = (ci_start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0);
}
let ci_duration = ci.total_duration(clip_dur);
let ci_duration = ci.total_duration(clip_dur, document.tempo_map());
let ci_end = ci_start + ci_duration;
let sx = self.time_to_x(ci_start);
@ -3052,9 +3005,9 @@ impl TimelinePane {
let clip_dur = audio_clip.duration;
let mut ci_start = ci.effective_start();
if is_move_drag && selection.contains_clip_instance(&ci.id) {
ci_start = (ci_start + self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate)).max(0.0);
ci_start = (ci_start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0);
}
let ci_duration = ci.total_duration(clip_dur);
let ci_duration = ci.total_duration(clip_dur, document.tempo_map());
let ci_screen_start = rect.min.x + self.time_to_x(ci_start);
let ci_screen_end = ci_screen_start + (ci_duration * self.pixels_per_second as f64) as f32;
@ -3150,11 +3103,11 @@ impl TimelinePane {
.filter(|ci| selection.contains_clip_instance(&ci.id))
.filter_map(|ci| {
let dur = document.get_clip_duration(&ci.clip_id)?;
Some((ci.id, ci.effective_start(), ci.total_duration(dur)))
Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map())))
})
.collect();
if !group.is_empty() {
Some(document.clamp_group_move_offset(&layer.id(), &group, self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate)))
Some(document.clamp_group_move_offset(&layer.id(), &group, self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)))
} else {
None
}
@ -3167,7 +3120,7 @@ impl TimelinePane {
let preview_ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0);
let mut start = ci.effective_start();
let mut duration = ci.total_duration(clip_dur);
let mut duration = ci.total_duration(clip_dur, document.tempo_map());
let is_selected = selection.contains_clip_instance(&ci.id);
let is_linked = if self.clip_drag_state.is_some() {
@ -3187,7 +3140,7 @@ impl TimelinePane {
}
}
ClipDragType::TrimLeft => {
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, document.bpm, &document.time_signature, document.framerate).max(0.0).min(clip_dur);
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate).max(0.0).min(clip_dur);
let offset = new_trim - ci.trim_start;
start = (ci.timeline_start + offset).max(0.0);
duration = (clip_dur - new_trim).max(0.0);
@ -3197,7 +3150,7 @@ impl TimelinePane {
}
ClipDragType::TrimRight => {
let old_trim_end = ci.trim_end.unwrap_or(clip_dur);
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.bpm, &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur);
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur);
duration = (new_trim_end - ci.trim_start).max(0.0);
}
ClipDragType::LoopExtendRight => {
@ -3205,7 +3158,7 @@ impl TimelinePane {
let content_window = (trim_end - ci.trim_start).max(0.0);
let current_right = ci.timeline_duration.unwrap_or(content_window);
let right_edge = ci.timeline_start + current_right + self.drag_offset;
let snapped_edge = self.snap_to_grid(right_edge, document.bpm, &document.time_signature, document.framerate);
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate);
let new_right = (snapped_edge - ci.timeline_start).max(content_window);
let loop_before = ci.loop_before.unwrap_or(0.0);
duration = loop_before + new_right;
@ -3217,7 +3170,7 @@ impl TimelinePane {
let desired = (current_loop_before - self.drag_offset).max(0.0);
let snapped = (desired / content_window).round() * content_window;
start = ci.timeline_start - snapped;
duration = snapped + ci.effective_duration(clip_dur);
duration = snapped + ci.effective_duration(clip_dur, document.tempo_map());
}
}
}
@ -3236,13 +3189,13 @@ impl TimelinePane {
if let Some(clip_duration) = clip_duration {
// Calculate effective duration accounting for trimming.
// In Measures mode, uses beats fields so width tracks BPM during live drag.
let mut instance_duration = self.instance_display_duration(clip_instance, clip_duration, document.bpm);
let mut instance_duration = self.instance_display_duration(clip_instance, clip_duration, document.tempo_map());
// Instance positioned on the layer's timeline using timeline_start.
// In Measures mode, uses timeline_start_beats so clips stay at their beat
// position during live BPM drag preview.
let _layer_data = layer.layer();
let mut instance_start = self.instance_display_start(clip_instance, document.bpm);
let mut instance_start = self.instance_display_start(clip_instance, document.tempo_map());
// Apply drag offset preview for selected clips with snapping
let is_selected = selection.contains_clip_instance(&clip_instance.id);
@ -3267,7 +3220,7 @@ impl TimelinePane {
// Track preview trim values for note/waveform rendering.
// In Measures mode, derive from beats so they track BPM during live drag.
let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm);
let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm());
let mut preview_trim_start = base_trim_start;
let mut preview_clip_duration = base_clip_duration;
@ -3282,7 +3235,7 @@ impl TimelinePane {
}
ClipDragType::TrimLeft => {
// Trim left: calculate new trim_start with snap to adjacent clips
let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.bpm, &document.time_signature, document.framerate)
let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
.max(0.0)
.min(clip_duration);
@ -3322,7 +3275,7 @@ impl TimelinePane {
ClipDragType::TrimRight => {
// Trim right: extend or reduce duration with snap to adjacent clips
let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.bpm, &document.time_signature, document.framerate)
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
.max(clip_instance.trim_start)
.min(clip_duration);
@ -3356,7 +3309,7 @@ impl TimelinePane {
let content_window = (trim_end - clip_instance.trim_start).max(0.0);
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
let right_edge = clip_instance.timeline_start + current_right + self.drag_offset;
let snapped_edge = self.snap_to_grid(right_edge, document.bpm, &document.time_signature, document.framerate);
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate);
let desired_right = (snapped_edge - clip_instance.timeline_start).max(content_window);
let new_right = if desired_right > current_right {
@ -3404,7 +3357,7 @@ impl TimelinePane {
};
// Recompute instance_start and instance_duration
let right_duration = clip_instance.effective_duration(clip_duration);
let right_duration = clip_instance.effective_duration(clip_duration, document.tempo_map());
instance_start = clip_instance.timeline_start - new_loop_before;
instance_duration = new_loop_before + right_duration;
content_origin = clip_instance.timeline_start;
@ -3525,7 +3478,7 @@ impl TimelinePane {
if iter_duration <= 0.0 { continue; }
let note_bpm = if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures {
Some(document.bpm)
Some(document.bpm())
} else {
None
};
@ -3547,7 +3500,7 @@ impl TimelinePane {
}
} else {
let note_bpm = if self.time_display_format == lightningbeam_core::document::TimelineMode::Measures {
Some(document.bpm)
Some(document.bpm())
} else {
None
};
@ -4066,7 +4019,7 @@ impl TimelinePane {
let clip_duration = effective_clip_duration(document, layer, clip_instance);
if let Some(clip_duration) = clip_duration {
let instance_duration = clip_instance.total_duration(clip_duration);
let instance_duration = clip_instance.total_duration(clip_duration, document.tempo_map());
let instance_start = clip_instance.effective_start();
let instance_end = instance_start + instance_duration;
@ -4419,7 +4372,7 @@ impl TimelinePane {
HashMap::new();
// Compute snapped offset once for all selected clips (preserves relative spacing)
let move_offset = self.snapped_move_offset(document.bpm, &document.time_signature, document.framerate);
let move_offset = self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate);
// Iterate through all layers (including group children) to find selected clip instances
for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
@ -4481,8 +4434,7 @@ impl TimelinePane {
// New trim_start is snapped then clamped to valid range
let desired_trim_start = self.snap_to_grid(
old_trim_start + self.drag_offset,
document.bpm, &document.time_signature, document.framerate,
old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate,
).max(0.0).min(clip_duration);
// Apply overlap prevention when extending left
@ -4525,11 +4477,10 @@ impl TimelinePane {
// Calculate new trim_end based on current duration
let current_duration =
clip_instance.effective_duration(clip_duration);
clip_instance.effective_duration(clip_duration, document.tempo_map());
let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration);
let desired_trim_end = self.snap_to_grid(
old_trim_end_val + self.drag_offset,
document.bpm, &document.time_signature, document.framerate,
old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate,
).max(clip_instance.trim_start).min(clip_duration);
// Apply overlap prevention when extending right
@ -4607,7 +4558,7 @@ impl TimelinePane {
let content_window = (trim_end - clip_instance.trim_start).max(0.0);
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
let right_edge = clip_instance.timeline_start + current_right + self.drag_offset;
let snapped_edge = self.snap_to_grid(right_edge, document.bpm, &document.time_signature, document.framerate);
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate);
let desired_right = snapped_edge - clip_instance.timeline_start;
let new_right = if desired_right > current_right {
@ -4785,7 +4736,7 @@ impl TimelinePane {
if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) {
if let Some(pos) = response.interact_pointer_pos() {
let x = (pos.x - content_rect.min.x).max(0.0);
let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.bpm, &document.time_signature, document.framerate);
let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate);
*playback_time = new_time;
self.is_scrubbing = true;
// Seek immediately so it works while playing
@ -4799,7 +4750,7 @@ impl TimelinePane {
else if self.is_scrubbing && response.dragged() && !self.is_panning {
if let Some(pos) = response.interact_pointer_pos() {
let x = (pos.x - content_rect.min.x).max(0.0);
let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.bpm, &document.time_signature, document.framerate);
let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate);
*playback_time = new_time;
if let Some(controller_arc) = audio_controller {
let mut controller = controller_arc.lock().unwrap();
@ -5092,9 +5043,9 @@ impl PaneRenderer for TimelinePane {
// Time display (format-dependent)
{
let (bpm, time_sig_num, time_sig_den, framerate) = {
let (tempo_map, bpm, time_sig_num, time_sig_den, framerate) = {
let doc = shared.action_executor.document();
(doc.bpm, doc.time_signature.numerator, doc.time_signature.denominator, doc.framerate)
(doc.tempo_map().clone(), doc.bpm(), doc.time_signature.numerator, doc.time_signature.denominator, doc.framerate)
};
match self.time_display_format {
@ -5104,7 +5055,7 @@ impl PaneRenderer for TimelinePane {
TimelineMode::Measures => {
let time_sig = lightningbeam_core::document::TimeSignature { numerator: time_sig_num, denominator: time_sig_den };
let pos = lightningbeam_core::beat_time::time_to_measure(
*shared.playback_time, bpm, &time_sig,
*shared.playback_time, &tempo_map, &time_sig,
);
ui.colored_label(text_color, format!(
"BAR: {}.{} | BPM: {:.0} | {}/{}",
@ -5204,7 +5155,7 @@ impl PaneRenderer for TimelinePane {
self.bpm_drag_start = Some(bpm);
}
// Live preview: update document directly so grid reflows immediately
shared.action_executor.document_mut().bpm = bpm_val;
shared.action_executor.document_mut().set_bpm(bpm_val);
if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.set_tempo(bpm_val as f32, (time_sig_num, time_sig_den));
@ -5214,35 +5165,17 @@ impl PaneRenderer for TimelinePane {
// On commit, dispatch a single ChangeBpmAction (single undo entry)
if bpm_response.drag_stopped() || bpm_response.lost_focus() {
if let Some(start_bpm) = self.bpm_drag_start.take() {
let new_bpm = shared.action_executor.document().bpm;
let new_bpm = shared.action_executor.document().bpm();
if (start_bpm - new_bpm).abs() > 1e-6
&& self.time_display_format == lightningbeam_core::document::TimelineMode::Measures
{
use lightningbeam_core::actions::ChangeBpmAction;
// document.bpm is already new_bpm from the live preview — keep it.
// ChangeBpmAction::new uses the passed old/new params, not document.bpm,
// so we don't need to revert (which would cause a one-frame flash).
// document.bpm() is already new_bpm from the live preview — build action
// with new_bpm so it records the correct undo state.
let action = ChangeBpmAction::new(
start_bpm,
new_bpm,
shared.action_executor.document(),
shared.midi_event_cache,
);
// Immediately update midi_event_cache for rendering
for (clip_id, events) in action.new_midi_events() {
shared.midi_event_cache.insert(clip_id, events.clone());
}
// Optimistically rescale automation keyframe times in the cache.
// The backend rescales asynchronously via ApplyBpmChange; this keeps
// the cache consistent without waiting for the engine to round-trip.
let scale = start_bpm / new_bpm;
for lanes in self.automation_cache.values_mut() {
for lane in lanes.iter_mut() {
for kf in lane.keyframes.iter_mut() {
kf.time *= scale;
}
}
}
shared.pending_actions.push(Box::new(action));
}
}
@ -5271,7 +5204,7 @@ impl PaneRenderer for TimelinePane {
doc.time_signature.denominator = *den;
if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.set_tempo(doc.bpm as f32, (*num, *den));
controller.set_tempo(doc.bpm() as f32, (*num, *den));
}
}
}
@ -5331,7 +5264,7 @@ impl PaneRenderer for TimelinePane {
let clip_duration = effective_clip_duration(document, layer, clip_instance);
if let Some(clip_duration) = clip_duration {
let instance_duration = clip_instance.effective_duration(clip_duration);
let instance_duration = clip_instance.effective_duration(clip_duration, document.tempo_map());
let instance_end = clip_instance.timeline_start + instance_duration;
max_endpoint = max_endpoint.max(instance_end);
}
@ -5390,7 +5323,7 @@ impl PaneRenderer for TimelinePane {
// Render time ruler (clip to ruler rect)
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
self.render_ruler(ui, ruler_rect, shared.theme, document.bpm, &document.time_signature, document.framerate);
self.render_ruler(ui, ruler_rect, shared.theme, document.tempo_map(), &document.time_signature, document.framerate);
// Render layer rows with clipping
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
@ -5486,8 +5419,8 @@ impl PaneRenderer for TimelinePane {
// Invalidate automation cache when BPM changes (but not during a live drag —
// during drag the backend hasn't rescaled yet so re-querying would give stale data).
let bpm_committed = self.bpm_drag_start.is_none();
if bpm_committed && (document.bpm - self.automation_cache_bpm).abs() > 1e-9 {
self.automation_cache_bpm = document.bpm;
if bpm_committed && (document.bpm() - self.automation_cache_bpm).abs() > 1e-9 {
self.automation_cache_bpm = document.bpm();
self.automation_cache.clear();
}
@ -5631,7 +5564,7 @@ impl PaneRenderer for TimelinePane {
for inst in instances {
if !shared.selection.contains_clip_instance(&inst.id) { continue; }
if let Some(dur) = document.get_clip_duration(&inst.clip_id) {
let eff = inst.effective_duration(dur);
let eff = inst.effective_duration(dur, document.tempo_map());
let start = inst.timeline_start;
let end = start + eff;
let min_dist = min_split_px as f64 / self.pixels_per_second as f64;
@ -5657,7 +5590,7 @@ impl PaneRenderer for TimelinePane {
.filter(|ci| shared.selection.contains_clip_instance(&ci.id))
.all(|ci| {
if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
let eff = ci.effective_duration(dur);
let eff = ci.effective_duration(dur, document.tempo_map());
let max_extend = document.find_max_trim_extend_right(
&layer_id, &ci.id, ci.timeline_start, eff,
);
@ -5696,7 +5629,7 @@ impl PaneRenderer for TimelinePane {
enabled = instances.iter().all(|ci| {
let paste_start = (ci.timeline_start + offset).max(0.0);
if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
let eff = ci.effective_duration(dur);
let eff = ci.effective_duration(dur, document.tempo_map());
document
.find_nearest_valid_position(
&layer_id,