Compare commits

...

4 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 54d5764bd0 Make beats canonical representation rather than seconds 2026-06-02 13:06:36 -04:00
Skyler Lehmkuhl f372a84313 Massive tempo refactor - make beats canonical time rep and allow them to be non constant 2026-04-02 10:26:01 -04:00
Skyler Lehmkuhl ae146533d9 Update automation lanes too 2026-04-01 10:17:52 -04:00
Skyler Lehmkuhl 3fc4773ec3 Fix remaining sites that weren't updating properly on BPM changes 2026-04-01 09:33:35 -04:00
50 changed files with 1844 additions and 2150 deletions

View File

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

View File

@ -1,5 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use crate::time::{Beats, Seconds};
use crate::tempo_map::TempoMap;
/// Audio clip instance ID type /// Audio clip instance ID type
pub type AudioClipInstanceId = u32; pub type AudioClipInstanceId = u32;
@ -9,47 +11,31 @@ pub type ClipId = AudioClipInstanceId;
/// Audio clip instance that references content in the AudioClipPool /// 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 /// ## Timing Model
/// - `internal_start` / `internal_end`: Define the region of the source audio to play (trimming) /// - `internal_start` / `internal_end`: Region of the source audio to play (seconds — audio file seek positions)
/// - `external_start` / `external_duration`: Define where the clip appears on the timeline and how long /// - `external_start` / `external_duration`: Where the clip appears on the timeline (**beats**)
/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display
/// ///
/// ## Looping /// ## Looping
/// If `external_duration` is greater than `internal_end - internal_start`, /// If `external_duration_secs(bpm)` > `internal_end - internal_start`, the clip loops.
/// the clip will seamlessly loop back to `internal_start` when it reaches `internal_end`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioClipInstance { pub struct AudioClipInstance {
pub id: AudioClipInstanceId, pub id: AudioClipInstanceId,
pub audio_pool_index: usize, pub audio_pool_index: usize,
/// Start position within the audio content (seconds) /// Start position within the audio content
pub internal_start: f64, pub internal_start: Seconds,
#[serde(default)] pub internal_start_beats: f64, /// End position within the audio content
#[serde(default)] pub internal_start_frames: f64, pub internal_end: Seconds,
/// 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 on the timeline (seconds) /// Start position on the timeline
pub external_start: f64, pub external_start: Beats,
#[serde(default)] pub external_start_beats: f64, /// Duration on the timeline
#[serde(default)] pub external_start_frames: f64, pub external_duration: Beats,
/// 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,
/// Clip-level gain /// Clip-level gain
pub gain: f32, pub gain: f32,
/// Per-instance read-ahead buffer for compressed audio streaming. /// 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)] #[serde(skip)]
pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>, pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>,
} }
@ -58,154 +44,71 @@ pub struct AudioClipInstance {
pub type Clip = AudioClipInstance; pub type Clip = AudioClipInstance;
impl AudioClipInstance { impl AudioClipInstance {
/// Create a new audio clip instance
pub fn new( pub fn new(
id: AudioClipInstanceId, id: AudioClipInstanceId,
audio_pool_index: usize, audio_pool_index: usize,
internal_start: f64, internal_start: Seconds,
internal_end: f64, internal_end: Seconds,
external_start: f64, external_start: Beats,
external_duration: f64, external_duration: Beats,
) -> Self { ) -> Self {
Self { Self {
id, id,
audio_pool_index, audio_pool_index,
internal_start, internal_start,
internal_start_beats: 0.0,
internal_start_frames: 0.0,
internal_end, internal_end,
internal_end_beats: 0.0,
internal_end_frames: 0.0,
external_start, external_start,
external_start_beats: 0.0,
external_start_frames: 0.0,
external_duration, external_duration,
external_duration_beats: 0.0,
external_duration_frames: 0.0,
gain: 1.0, gain: 1.0,
read_ahead: None, read_ahead: None,
} }
} }
/// Create a clip instance from legacy parameters (for backwards compatibility) pub fn external_end(&self) -> Beats {
/// 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 {
self.external_start + self.external_duration self.external_start + self.external_duration
} }
/// Get the end time of this clip instance on the timeline pub fn external_start_secs(&self, tempo_map: &TempoMap) -> Seconds {
/// (Alias for external_end(), for backwards compatibility) tempo_map.beats_to_seconds(self.external_start)
pub fn end_time(&self) -> f64 {
self.external_end()
} }
/// Get the start time on the timeline pub fn external_end_secs(&self, tempo_map: &TempoMap) -> Seconds {
/// (Alias for external_start, for backwards compatibility) tempo_map.beats_to_seconds(self.external_end())
pub fn start_time(&self) -> f64 {
self.external_start
} }
/// Get the internal (content) duration pub fn external_duration_secs(&self, tempo_map: &TempoMap) -> Seconds {
pub fn internal_duration(&self) -> f64 { 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 self.internal_end - self.internal_start
} }
/// Check if this clip instance loops pub fn is_looping(&self, tempo_map: &TempoMap) -> bool {
pub fn is_looping(&self) -> bool { self.external_duration_secs(tempo_map) > self.internal_duration()
self.external_duration > self.internal_duration()
} }
/// Get the position within the audio content for a given timeline position /// Get the audio content position for a given timeline position. Handles looping.
/// Returns None if the timeline position is outside this clip instance pub fn get_content_position(&self, timeline_pos: Seconds, tempo_map: &TempoMap) -> Option<Seconds> {
/// Handles looping automatically let start_secs = self.external_start_secs(tempo_map);
pub fn get_content_position(&self, timeline_pos: f64) -> Option<f64> { let end_secs = self.external_end_secs(tempo_map);
if timeline_pos < self.external_start || timeline_pos >= self.external_end() { if timeline_pos < start_secs || timeline_pos >= end_secs {
return None; return None;
} }
let relative_pos = timeline_pos - start_secs;
let relative_pos = timeline_pos - self.external_start;
let internal_duration = self.internal_duration(); let internal_duration = self.internal_duration();
if internal_duration.0 <= 0.0 {
if internal_duration <= 0.0 {
return None; return None;
} }
// Wrap around for looping
let content_offset = relative_pos % internal_duration; let content_offset = relative_pos % internal_duration;
Some(self.internal_start + content_offset) Some(self.internal_start + content_offset)
} }
/// Set clip gain
pub fn set_gain(&mut self, gain: f32) { pub fn set_gain(&mut self, gain: f32) {
self.gain = gain.max(0.0); 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::audio::track::{Track, TrackId, TrackNode};
use crate::command::{AudioEvent, Command, Query, QueryResponse}; use crate::command::{AudioEvent, Command, Query, QueryResponse};
use crate::io::MidiInputManager; use crate::io::MidiInputManager;
use crate::time::{Beats, Seconds};
use petgraph::stable_graph::NodeIndex; use petgraph::stable_graph::NodeIndex;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
@ -109,10 +110,11 @@ pub struct Engine {
timing_sum_total_us: u64, timing_sum_total_us: u64,
timing_overrun_count: u64, timing_overrun_count: u64,
// Current tempo/framerate — kept in sync with SetTempo/ApplyBpmChange so that // Current tempo map — kept in sync with SetTempo/SetTempoMap commands.
// newly-created clip instances can be immediately synced via sync_from_seconds. tempo_map: crate::TempoMap,
current_bpm: f64,
current_fps: f64, current_fps: f64,
// Current time signature — updated by SetTempo, used when SetTempoMap fires.
time_sig: (u32, u32),
} }
impl Engine { impl Engine {
@ -189,8 +191,9 @@ impl Engine {
timing_worst_render_us: 0, timing_worst_render_us: 0,
timing_sum_total_us: 0, timing_sum_total_us: 0,
timing_overrun_count: 0, timing_overrun_count: 0,
current_bpm: 120.0, tempo_map: crate::TempoMap::constant(120.0),
current_fps: 30.0, current_fps: 30.0,
time_sig: (4, 4),
} }
} }
@ -379,7 +382,7 @@ impl Engine {
} }
// Convert playhead from frames to seconds for timeline-based rendering // 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. // Reset per-clip read-ahead targets before rendering.
self.project.reset_read_ahead_targets(); self.project.reset_read_ahead_targets();
@ -390,6 +393,7 @@ impl Engine {
&self.audio_pool, &self.audio_pool,
&mut self.buffer_pool, &mut self.buffer_pool,
playhead_seconds, playhead_seconds,
&self.tempo_map,
self.sample_rate, self.sample_rate,
self.channels, self.channels,
false, false,
@ -427,7 +431,8 @@ impl Engine {
// Send MIDI recording progress if active // Send MIDI recording progress if active
if let Some(recording) = &self.midi_recording_state { 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 duration = current_time - recording.start_time;
let notes = recording.get_notes_with_active(current_time); let notes = recording.get_notes_with_active(current_time);
let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress( let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress(
@ -451,7 +456,7 @@ impl Engine {
} else { } else {
// Not playing: render live MIDI (keyboard input + note-off tails) through the // Not playing: render live MIDI (keyboard input + note-off tails) through the
// normal group hierarchy so mixer gain is correctly applied. // 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() { if self.mix_buffer.len() != output.len() {
self.mix_buffer.resize(output.len(), 0.0); self.mix_buffer.resize(output.len(), 0.0);
} }
@ -463,6 +468,7 @@ impl Engine {
&self.audio_pool, &self.audio_pool,
&mut self.buffer_pool, &mut self.buffer_pool,
playhead_seconds, playhead_seconds,
&self.tempo_map,
self.sample_rate, self.sample_rate,
self.channels, self.channels,
true, // live_only true, // live_only
@ -562,7 +568,8 @@ impl Engine {
if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { 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) { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.internal_end = clip.internal_start + duration; 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 +741,16 @@ impl Engine {
} }
} }
Command::MoveClip(track_id, clip_id, new_start_time) => { Command::MoveClip(track_id, clip_id, new_start_time) => {
// Moving just changes external_start, external_duration stays the same // Moving just changes external_start (beats), external_duration stays the same
let bpm = self.current_bpm;
let fps = self.current_fps;
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.external_start = new_start_time; clip.external_start = Beats(new_start_time);
clip.sync_from_seconds(bpm, fps);
} }
} }
Some(crate::audio::track::TrackNode::Midi(track)) => { Some(crate::audio::track::TrackNode::Midi(track)) => {
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) {
instance.external_start = new_start_time; instance.external_start = Beats(new_start_time);
instance.sync_from_seconds(bpm, fps);
} }
} }
_ => {} _ => {}
@ -757,24 +760,19 @@ impl Engine {
Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => { Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => {
// Trim changes which portion of the source content is used // Trim changes which portion of the source content is used
// Also updates external_duration to match internal duration (no looping after trim) // 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) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.internal_start = new_internal_start; clip.internal_start = Seconds(new_internal_start);
clip.internal_end = new_internal_end; clip.internal_end = Seconds(new_internal_end);
clip.external_duration = new_internal_end - new_internal_start; clip.external_duration = Beats(new_internal_end - new_internal_start);
clip.sync_from_seconds(bpm, fps);
} }
} }
Some(crate::audio::track::TrackNode::Midi(track)) => { 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) { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
instance.internal_start = new_internal_start; instance.internal_start = Beats(new_internal_start);
instance.internal_end = new_internal_end; instance.internal_end = Beats(new_internal_end);
instance.external_duration = new_internal_end - new_internal_start; instance.external_duration = Beats(new_internal_end - new_internal_start);
instance.sync_from_seconds(bpm, fps);
} }
} }
_ => {} _ => {}
@ -782,21 +780,16 @@ impl Engine {
self.refresh_clip_snapshot(); self.refresh_clip_snapshot();
} }
Command::ExtendClip(track_id, clip_id, new_external_duration) => { Command::ExtendClip(track_id, clip_id, new_external_duration) => {
// Extend changes the external duration (enables looping if > internal duration) // Extend changes the external duration (beats; enables looping if > internal duration)
let bpm = self.current_bpm;
let fps = self.current_fps;
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.external_duration = new_external_duration; clip.external_duration = Beats(new_external_duration);
clip.sync_from_seconds(bpm, fps);
} }
} }
Some(crate::audio::track::TrackNode::Midi(track)) => { 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) { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
instance.external_duration = new_external_duration; instance.external_duration = Beats(new_external_duration);
instance.sync_from_seconds(bpm, fps);
} }
} }
_ => {} _ => {}
@ -823,7 +816,7 @@ impl Engine {
} }
Command::SetOffset(track_id, offset) => { Command::SetOffset(track_id, offset) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { 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) => { Command::SetPitchShift(track_id, semitones) => {
@ -833,12 +826,12 @@ impl Engine {
} }
Command::SetTrimStart(track_id, trim_start) => { Command::SetTrimStart(track_id, trim_start) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { 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) => { Command::SetTrimEnd(track_id, trim_end) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { 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) => { Command::CreateAudioTrack(name, parent_id) => {
@ -915,14 +908,20 @@ impl Engine {
} }
Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => { Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => {
// Create a new clip instance with the pre-assigned clip_id // 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, clip_id,
pool_index, pool_index,
start_time, Seconds(offset),
duration, Seconds(offset + content_dur_secs),
offset, start_beats,
Beats(duration),
); );
clip.sync_from_seconds(self.current_bpm, self.current_fps);
// Add clip to track // Add clip to track
if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) {
@ -945,13 +944,12 @@ impl Engine {
let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
// Create clip content in the pool // 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); self.project.midi_clip_pool.add_existing_clip(clip);
// Create an instance for this clip on the track // Create an instance for this clip on the track
let instance_id = self.project.next_midi_clip_instance_id(); let instance_id = self.project.next_midi_clip_instance_id();
let mut instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time); let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time));
instance.sync_from_seconds(self.current_bpm, self.current_fps);
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
track.clip_instances.push(instance); track.clip_instances.push(instance);
@ -965,12 +963,12 @@ impl Engine {
// Add a MIDI note event to the specified clip in the pool // 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 // 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) { if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) {
// Timestamp is now in seconds (sample-rate independent) // Timestamp is in beats (canonical)
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); clip.add_event(note_on);
// Add note off event // 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); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.add_event(note_off); clip.add_event(note_off);
} else { } else {
@ -979,9 +977,9 @@ impl Engine {
if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) { if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) {
let actual_clip_id = instance.clip_id; let actual_clip_id = instance.clip_id;
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_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); 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); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.add_event(note_off); clip.add_event(note_off);
} }
@ -991,14 +989,8 @@ impl Engine {
} }
Command::AddLoadedMidiClip(track_id, clip, start_time) => { Command::AddLoadedMidiClip(track_id, clip, start_time) => {
// Add a pre-loaded MIDI clip to the track with the given start time // Add a pre-loaded MIDI clip to the track with the given start time
let bpm = self.current_bpm; if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) {
let fps = self.current_fps; // instance positions are already in beats; nothing to sync
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);
}
}
} }
self.refresh_clip_snapshot(); self.refresh_clip_snapshot();
} }
@ -1009,13 +1001,13 @@ impl Engine {
clip.events.clear(); clip.events.clear();
// Add new events from the notes array // 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 { 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); clip.events.push(note_on);
// Add note off event // 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); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.events.push(note_off); clip.events.push(note_off);
} }
@ -1077,7 +1069,7 @@ impl Engine {
} }
Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => { Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => {
// Add an automation point to the specified lane // 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) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
@ -1103,17 +1095,17 @@ impl Engine {
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(lane) = track.get_automation_lane_mut(lane_id) { 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)) => { Some(crate::audio::track::TrackNode::Midi(track)) => {
if let Some(lane) = track.get_automation_lane_mut(lane_id) { 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)) => { Some(crate::audio::track::TrackNode::Group(group)) => {
if let Some(lane) = group.get_automation_lane_mut(lane_id) { 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 => {} None => {}
@ -1250,9 +1242,9 @@ impl Engine {
// If MIDI recording is active on this track, capture the event // If MIDI recording is active on this track, capture the event
if let Some(recording) = &mut self.midi_recording_state { if let Some(recording) = &mut self.midi_recording_state {
if recording.track_id == track_id { if recording.track_id == track_id {
let absolute_time = self.playhead as f64 / self.sample_rate as f64; 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}s, playhead={}, sample_rate={}", eprintln!("[MIDI_RECORDING] NoteOn captured: note={}, velocity={}, absolute_time={:.3} beats, playhead={}, sample_rate={}",
note, velocity, absolute_time, self.playhead, self.sample_rate); note, velocity, absolute_time.0, self.playhead, self.sample_rate);
recording.note_on(note, velocity, absolute_time); recording.note_on(note, velocity, absolute_time);
} }
} }
@ -1273,9 +1265,9 @@ impl Engine {
// If MIDI recording is active on this track, capture the event // If MIDI recording is active on this track, capture the event
if let Some(recording) = &mut self.midi_recording_state { if let Some(recording) = &mut self.midi_recording_state {
if recording.track_id == track_id { if recording.track_id == track_id {
let absolute_time = self.playhead as f64 / self.sample_rate as f64; 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}s, playhead={}, sample_rate={}", eprintln!("[MIDI_RECORDING] NoteOff captured: note={}, absolute_time={:.3} beats, playhead={}, sample_rate={}",
note, absolute_time, self.playhead, self.sample_rate); note, absolute_time.0, self.playhead, self.sample_rate);
recording.note_off(note, absolute_time); recording.note_off(note, absolute_time);
} }
} }
@ -1301,16 +1293,17 @@ impl Engine {
} }
Command::SetTempo(bpm, time_sig) => { Command::SetTempo(bpm, time_sig) => {
self.time_sig = time_sig;
self.metronome.update_timing(bpm, time_sig); self.metronome.update_timing(bpm, time_sig);
self.project.set_tempo(bpm, time_sig.0); self.project.set_tempo(bpm, time_sig.0);
self.current_bpm = bpm as f64; self.tempo_map.set_global_bpm(bpm as f64);
} }
Command::ApplyBpmChange(bpm, fps, midi_durations) => { Command::SetTempoMap(map) => {
self.current_bpm = bpm; let bpm0 = map.global_bpm() as f32;
self.current_fps = fps; self.metronome.update_timing(bpm0, self.time_sig);
self.project.apply_bpm_change(bpm, fps, &midi_durations); self.project.set_tempo(bpm0, self.time_sig.0);
self.refresh_clip_snapshot(); self.tempo_map = map;
} }
// Node graph commands // Node graph commands
@ -1864,9 +1857,17 @@ impl Engine {
let buffer_size = self.buffer_pool.buffer_size(); let buffer_size = self.buffer_pool.buffer_size();
if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
let current = metatrack.current_subtracks(); let current = metatrack.current_subtracks();
let has_subtrack_node = metatrack.audio_graph.node_indices().any(|idx| {
metatrack.audio_graph.get_graph_node(idx)
.map(|n| n.node.node_type() == "SubtrackInputs")
.unwrap_or(false)
});
// No-op if subtrack list is unchanged (prevents every-frame graph rebuilds) // No-op if subtrack list is unchanged AND the graph is already initialised.
if current == subtracks { // Without the `has_subtrack_node` guard a freshly-created metatrack
// (empty graph, no SubtrackInputs) with zero subtracks would never get
// its default Volume automation node.
if current == subtracks && has_subtrack_node {
return; return;
} }
@ -2174,15 +2175,20 @@ impl Engine {
} }
}; };
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { let graph = match self.project.get_track_mut(track_id) {
let graph = &mut track.instrument_graph; Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
_ => None,
};
if let Some(graph) = graph {
let node_idx = NodeIndex::new(node_id as usize); let node_idx = NodeIndex::new(node_id as usize);
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
// Downcast to AutomationInputNode using as_any_mut // Downcast to AutomationInputNode using as_any_mut
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() { if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
let keyframe = AutomationKeyframe { let keyframe = AutomationKeyframe {
time, time: Beats(time),
value, value,
interpolation, interpolation,
ease_out, ease_out,
@ -2199,13 +2205,18 @@ impl Engine {
Command::AutomationRemoveKeyframe(track_id, node_id, time) => { Command::AutomationRemoveKeyframe(track_id, node_id, time) => {
use crate::audio::node_graph::nodes::AutomationInputNode; use crate::audio::node_graph::nodes::AutomationInputNode;
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { let graph = match self.project.get_track_mut(track_id) {
let graph = &mut track.instrument_graph; Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
_ => None,
};
if let Some(graph) = graph {
let node_idx = NodeIndex::new(node_id as usize); let node_idx = NodeIndex::new(node_id as usize);
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { 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>() { 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 { } else {
eprintln!("Node {} is not an AutomationInputNode", node_id); eprintln!("Node {} is not an AutomationInputNode", node_id);
} }
@ -2216,8 +2227,13 @@ impl Engine {
Command::AutomationSetName(track_id, node_id, name) => { Command::AutomationSetName(track_id, node_id, name) => {
use crate::audio::node_graph::nodes::AutomationInputNode; use crate::audio::node_graph::nodes::AutomationInputNode;
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { let graph = match self.project.get_track_mut(track_id) {
let graph = &mut track.instrument_graph; Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
_ => None,
};
if let Some(graph) = graph {
let node_idx = NodeIndex::new(node_id as usize); let node_idx = NodeIndex::new(node_id as usize);
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
@ -2514,7 +2530,7 @@ impl Engine {
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) { if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
use crate::command::MidiClipData; use crate::command::MidiClipData;
QueryResponse::MidiClipData(Ok(MidiClipData { QueryResponse::MidiClipData(Ok(MidiClipData {
duration: clip.duration, duration: clip.duration.0,
events: clip.events.clone(), events: clip.events.clone(),
})) }))
} else { } else {
@ -2526,12 +2542,15 @@ impl Engine {
use crate::audio::node_graph::nodes::{AutomationInputNode, InterpolationType}; use crate::audio::node_graph::nodes::{AutomationInputNode, InterpolationType};
use crate::command::types::AutomationKeyframeData; use crate::command::types::AutomationKeyframeData;
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) { let graph = match self.project.get_track(track_id) {
let graph = &track.instrument_graph; Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
_ => None,
};
if let Some(graph) = graph {
let node_idx = NodeIndex::new(node_id as usize); let node_idx = NodeIndex::new(node_id as usize);
if let Some(graph_node) = graph.get_graph_node(node_idx) { if let Some(graph_node) = graph.get_graph_node(node_idx) {
// Downcast to AutomationInputNode
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() { if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
let keyframes: Vec<AutomationKeyframeData> = auto_node.keyframes() let keyframes: Vec<AutomationKeyframeData> = auto_node.keyframes()
.iter() .iter()
@ -2542,9 +2561,8 @@ impl Engine {
InterpolationType::Step => "step", InterpolationType::Step => "step",
InterpolationType::Hold => "hold", InterpolationType::Hold => "hold",
}.to_string(); }.to_string();
AutomationKeyframeData { AutomationKeyframeData {
time: kf.time, time: kf.time.0,
value: kf.value, value: kf.value,
interpolation: interpolation_str, interpolation: interpolation_str,
ease_out: kf.ease_out, ease_out: kf.ease_out,
@ -2552,7 +2570,6 @@ impl Engine {
} }
}) })
.collect(); .collect();
QueryResponse::AutomationKeyframes(Ok(keyframes)) QueryResponse::AutomationKeyframes(Ok(keyframes))
} else { } else {
QueryResponse::AutomationKeyframes(Err(format!("Node {} is not an AutomationInputNode", node_id))) QueryResponse::AutomationKeyframes(Err(format!("Node {} is not an AutomationInputNode", node_id)))
@ -2561,19 +2578,22 @@ impl Engine {
QueryResponse::AutomationKeyframes(Err(format!("Node {} not found in track {}", node_id, track_id))) QueryResponse::AutomationKeyframes(Err(format!("Node {} not found in track {}", node_id, track_id)))
} }
} else { } else {
QueryResponse::AutomationKeyframes(Err(format!("Track {} not found or is not a MIDI track", track_id))) QueryResponse::AutomationKeyframes(Err(format!("Track {} not found", track_id)))
} }
} }
Query::GetAutomationName(track_id, node_id) => { Query::GetAutomationName(track_id, node_id) => {
use crate::audio::node_graph::nodes::AutomationInputNode; use crate::audio::node_graph::nodes::AutomationInputNode;
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) { let graph = match self.project.get_track(track_id) {
let graph = &track.instrument_graph; Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
_ => None,
};
if let Some(graph) = graph {
let node_idx = NodeIndex::new(node_id as usize); let node_idx = NodeIndex::new(node_id as usize);
if let Some(graph_node) = graph.get_graph_node(node_idx) { if let Some(graph_node) = graph.get_graph_node(node_idx) {
// Downcast to AutomationInputNode
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() { if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
QueryResponse::AutomationName(Ok(auto_node.display_name().to_string())) QueryResponse::AutomationName(Ok(auto_node.display_name().to_string()))
} else { } else {
@ -2583,17 +2603,21 @@ impl Engine {
QueryResponse::AutomationName(Err(format!("Node {} not found in track {}", node_id, track_id))) QueryResponse::AutomationName(Err(format!("Node {} not found in track {}", node_id, track_id)))
} }
} else { } else {
QueryResponse::AutomationName(Err(format!("Track {} not found or is not a MIDI track", track_id))) QueryResponse::AutomationName(Err(format!("Track {} not found", track_id)))
} }
} }
Query::GetAutomationRange(track_id, node_id) => { Query::GetAutomationRange(track_id, node_id) => {
use crate::audio::node_graph::nodes::AutomationInputNode; use crate::audio::node_graph::nodes::AutomationInputNode;
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) { let graph = match self.project.get_track(track_id) {
let graph = &track.instrument_graph; Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
_ => None,
};
if let Some(graph) = graph {
let node_idx = NodeIndex::new(node_id as usize); let node_idx = NodeIndex::new(node_id as usize);
if let Some(graph_node) = graph.get_graph_node(node_idx) { if let Some(graph_node) = graph.get_graph_node(node_idx) {
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() { if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
QueryResponse::AutomationRange(Ok((auto_node.value_min, auto_node.value_max))) QueryResponse::AutomationRange(Ok((auto_node.value_min, auto_node.value_max)))
@ -2604,7 +2628,7 @@ impl Engine {
QueryResponse::AutomationRange(Err(format!("Node {} not found", node_id))) QueryResponse::AutomationRange(Err(format!("Node {} not found", node_id)))
} }
} else { } else {
QueryResponse::AutomationRange(Err(format!("Track {} not found or is not a MIDI track", track_id))) QueryResponse::AutomationRange(Err(format!("Track {} not found", track_id)))
} }
} }
@ -2749,19 +2773,9 @@ impl Engine {
} }
} }
Query::AddMidiClipSync(track_id, clip, start_time) => { Query::AddMidiClipSync(track_id, clip, start_time) => {
// Add MIDI clip to track and return the instance ID // Add MIDI clip to track and return the instance ID (positions already in beats)
let bpm = self.current_bpm; let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) {
let fps = self.current_fps; Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
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))
}
Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())),
}; };
self.refresh_clip_snapshot(); self.refresh_clip_snapshot();
@ -2769,10 +2783,9 @@ impl Engine {
} }
Query::AddMidiClipInstanceSync(track_id, mut instance) => { Query::AddMidiClipInstanceSync(track_id, mut instance) => {
// Add MIDI clip instance to track (clip must already be in pool) // 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(); let instance_id = self.project.next_midi_clip_instance_id();
instance.id = 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) { let result = match self.project.add_midi_clip_instance(track_id, instance) {
Ok(_) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), Ok(_) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
@ -2940,7 +2953,7 @@ impl Engine {
} }
/// Handle starting a recording /// 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 crate::io::WavWriter;
use std::env; use std::env;
@ -2963,10 +2976,10 @@ impl Engine {
let clip = crate::audio::clip::Clip::new( let clip = crate::audio::clip::Clip::new(
clip_id, clip_id,
0, // Temporary pool index, will be updated on finalization 0, // Temporary pool index, will be updated on finalization
0.0, // internal_start Seconds(0.0), // internal_start
0.0, // internal_end - Duration starts at 0, will be updated during recording Seconds(0.0), // internal_end - Duration starts at 0, will be updated during recording
start_time, // external_start (timeline position) start_time, // external_start (timeline position, already Beats)
start_time, // external_end - will be updated during recording Beats(0.0), // external_duration - will be updated during recording
); );
// Add clip to track // Add clip to track
@ -3095,7 +3108,7 @@ impl Engine {
} }
/// Handle starting MIDI recording /// 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 // Check if track exists and is a MIDI track
if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) {
// Create MIDI recording state // Create MIDI recording state
@ -3105,7 +3118,7 @@ impl Engine {
// so they start at t=0 of the recording rather than being lost // so they start at t=0 of the recording rather than being lost
if let Some(held) = self.midi_held_notes.get(&track_id) { if let Some(held) = self.midi_held_notes.get(&track_id) {
for (&note, &velocity) in held { 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); recording_state.note_on(note, velocity, start_time);
} }
} }
@ -3132,8 +3145,8 @@ impl Engine {
} }
// Close out any active notes at the current playhead position // Close out any active notes at the current playhead position
let end_time = self.playhead as f64 / self.sample_rate as f64; 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); eprintln!("[MIDI_RECORDING] Closing active notes at time {}", end_time.0);
recording.close_active_notes(end_time); recording.close_active_notes(end_time);
let clip_id = recording.clip_id; let clip_id = recording.clip_id;
@ -3142,8 +3155,8 @@ impl Engine {
let note_count = notes.len(); let note_count = notes.len();
let recording_duration = end_time - recording.start_time; let recording_duration = end_time - recording.start_time;
eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3}s", eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats",
clip_id, track_id, note_count, recording_duration); 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) // 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); eprintln!("[MIDI_RECORDING] Looking for clip {} in midi_clip_pool", clip_id);
@ -3152,16 +3165,17 @@ impl Engine {
// Clear existing events // Clear existing events
clip.events.clear(); clip.events.clear();
// Update clip duration to match the actual recording time // Recording duration is already in beats (canonical)
clip.duration = recording_duration; let duration_beats = recording_duration;
clip.duration = duration_beats;
// Add new events from the recorded notes // Add new events from the recorded notes.
// Timestamps are now stored in seconds (sample-rate independent) // Recorded timestamps are already in beats (canonical).
for (start_time, note, velocity, duration) in notes.iter() { for (start_time, note, velocity, duration) in notes.iter() {
let note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity); let note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity);
eprintln!("[MIDI_RECORDING] Note {}: start_time={:.3}s, duration={:.3}s", eprintln!("[MIDI_RECORDING] Note {}: start={:.3} beats, duration={:.3} beats",
note, start_time, duration); note, start_time.0, duration.0);
clip.events.push(note_on); clip.events.push(note_on);
@ -3171,17 +3185,17 @@ impl Engine {
clip.events.push(note_off); 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()); 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()); 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 // 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(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) { if let Some(instance) = track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) {
instance.internal_end = recording_duration; instance.internal_end = duration_beats;
instance.external_duration = recording_duration; instance.external_duration = duration_beats;
eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3}s, external_duration={:.3}s", eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3} beats, external_duration={:.3} beats",
instance.internal_end, instance.external_duration); instance.internal_end.0, instance.external_duration.0);
} }
} }
} else { } else {
@ -3627,9 +3641,15 @@ impl EngineController {
let _ = self.command_tx.push(Command::AutomationSetName(track_id, node_id, name)); let _ = self.command_tx.push(Command::AutomationSetName(track_id, node_id, name));
} }
/// Set the min/max output range of an AutomationInput node (param ids 0 = min, 1 = max)
pub fn automation_set_range(&mut self, track_id: TrackId, node_id: u32, min: f32, max: f32) {
self.graph_set_parameter(track_id, node_id, 0, min);
self.graph_set_parameter(track_id, node_id, 1, max);
}
/// Start recording on a track /// Start recording on a track
pub fn start_recording(&mut self, track_id: TrackId, start_time: f64) { 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 /// Stop the current recording
@ -3649,7 +3669,7 @@ impl EngineController {
/// Start MIDI recording on a track /// Start MIDI recording on a track
pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) { 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 /// Stop the current MIDI recording
@ -3687,12 +3707,15 @@ impl EngineController {
let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature)); let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature));
} }
/// After a BPM change: update MIDI clip durations and sync all clip beats/frames. /// Replace the full tempo map (multi-entry variable tempo)
/// Call this after move_clip() has been called for all affected clips. pub fn set_tempo_map(&mut self, map: crate::TempoMap) {
pub fn apply_bpm_change(&mut self, bpm: f64, fps: f64, midi_durations: Vec<(crate::audio::MidiClipId, f64)>) { let _ = self.command_tx.push(Command::SetTempoMap(map));
let _ = self.command_tx.push(Command::ApplyBpmChange(bpm, fps, midi_durations));
} }
/// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale
/// automation keyframe times to preserve beat positions.
/// `from_bpm` is the BPM before the change; `to_bpm` is the new BPM.
/// Call this after move_clip() has been called for all affected clips.
// Node graph operations // Node graph operations
/// Add a node to a track's instrument graph /// 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::pool::AudioPool;
use super::project::Project; use super::project::Project;
use crate::command::AudioEvent; use crate::command::AudioEvent;
use crate::tempo_map::TempoMap;
use crate::time::Seconds;
use std::path::Path; use std::path::Path;
/// Render chunk size for offline export. Matches the real-time playback buffer size /// Render chunk size for offline export. Matches the real-time playback buffer size
@ -42,10 +44,12 @@ pub struct ExportSettings {
pub bit_depth: u16, pub bit_depth: u16,
/// MP3 bitrate in kbps (128, 192, 256, 320) /// MP3 bitrate in kbps (128, 192, 256, 320)
pub mp3_bitrate: u32, pub mp3_bitrate: u32,
/// Start time in seconds /// Start time
pub start_time: f64, pub start_time: Seconds,
/// End time in seconds /// End time
pub end_time: f64, pub end_time: Seconds,
/// Tempo map for beat-position scheduling
pub tempo_map: TempoMap,
} }
impl Default for ExportSettings { impl Default for ExportSettings {
@ -56,8 +60,9 @@ impl Default for ExportSettings {
channels: 2, channels: 2,
bit_depth: 16, bit_depth: 16,
mp3_bitrate: 320, mp3_bitrate: 320,
start_time: 0.0, start_time: Seconds::ZERO,
end_time: 60.0, end_time: Seconds(60.0),
tempo_map: TempoMap::constant(120.0),
} }
} }
} }
@ -79,15 +84,15 @@ pub fn export_audio<P: AsRef<Path>>(
{ {
// Validate duration // Validate duration
let duration = settings.end_time - settings.start_time; let duration = settings.end_time - settings.start_time;
if duration <= 0.0 { if duration <= Seconds::ZERO {
return Err(format!( return Err(format!(
"Export duration is zero or negative (start={:.3}s, end={:.3}s). \ "Export duration is zero or negative (start={:.3}s, end={:.3}s). \
Check that the timeline has content.", 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 { if total_frames == 0 {
return Err("Export would produce zero audio frames".to_string()); return Err("Export would produce zero audio frames".to_string());
} }
@ -148,11 +153,11 @@ pub fn render_to_memory(
{ {
// Calculate total number of frames // Calculate total number of frames
let duration = settings.end_time - settings.start_time; 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; let total_samples = total_frames * settings.channels as usize;
println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}", 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; let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
@ -178,6 +183,7 @@ pub fn render_to_memory(
pool, pool,
&mut buffer_pool, &mut buffer_pool,
playhead, playhead,
&settings.tempo_map,
settings.sample_rate, settings.sample_rate,
settings.channels, settings.channels,
false, false,
@ -185,9 +191,9 @@ pub fn render_to_memory(
// Calculate how many samples we actually need from this chunk // Calculate how many samples we actually need from this chunk
let remaining_time = settings.end_time - playhead; 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 // 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; let samples = frames_needed * settings.channels as usize;
// Ensure we don't exceed chunk size // Ensure we don't exceed chunk size
samples.min(chunk_samples) 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()); println!("Export: rendered {} samples total", all_samples.len());
@ -361,7 +367,7 @@ fn export_mp3<P: AsRef<Path>>(
// Calculate rendering parameters // Calculate rendering parameters
let duration = settings.end_time - settings.start_time; 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_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64; let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
@ -396,6 +402,7 @@ fn export_mp3<P: AsRef<Path>>(
pool, pool,
&mut buffer_pool, &mut buffer_pool,
playhead, playhead,
&settings.tempo_map,
settings.sample_rate, settings.sample_rate,
settings.channels, settings.channels,
false, false,
@ -403,8 +410,8 @@ fn export_mp3<P: AsRef<Path>>(
// Calculate how many samples we need from this chunk // Calculate how many samples we need from this chunk
let remaining_time = settings.end_time - playhead; 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 {
((remaining_time * settings.sample_rate as f64) as usize * settings.channels as usize) ((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize)
.min(chunk_samples) .min(chunk_samples)
} else { } else {
chunk_samples 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 // Encode any remaining samples as the final frame
@ -529,7 +536,7 @@ fn export_aac<P: AsRef<Path>>(
// Calculate rendering parameters // Calculate rendering parameters
let duration = settings.end_time - settings.start_time; 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_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64; let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
@ -564,6 +571,7 @@ fn export_aac<P: AsRef<Path>>(
pool, pool,
&mut buffer_pool, &mut buffer_pool,
playhead, playhead,
&settings.tempo_map,
settings.sample_rate, settings.sample_rate,
settings.channels, settings.channels,
false, false,
@ -571,8 +579,8 @@ fn export_aac<P: AsRef<Path>>(
// Calculate how many samples we need from this chunk // Calculate how many samples we need from this chunk
let remaining_time = settings.end_time - playhead; 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 {
((remaining_time * settings.sample_rate as f64) as usize * settings.channels as usize) ((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize)
.min(chunk_samples) .min(chunk_samples)
} else { } else {
chunk_samples 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 // 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 /// MIDI event representing a single MIDI message
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct MidiEvent { pub struct MidiEvent {
/// Time position within the clip in seconds (sample-rate independent) /// Time position in beats (quarter-note beats)
pub timestamp: f64, pub timestamp: Beats,
/// 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,
/// MIDI status byte (includes channel) /// MIDI status byte (includes channel)
pub status: u8, pub status: u8,
/// First data byte (note number, CC number, etc.) /// First data byte (note number, CC number, etc.)
@ -18,79 +14,28 @@ pub struct MidiEvent {
} }
impl MidiEvent { impl MidiEvent {
/// Create a new MIDI event pub fn new(timestamp: Beats, status: u8, data1: u8, data2: u8) -> Self {
pub fn new(timestamp: f64, status: u8, data1: u8, data2: u8) -> Self { Self { timestamp, status, data1, data2 }
Self {
timestamp,
timestamp_beats: 0.0,
timestamp_frames: 0.0,
status,
data1,
data2,
}
} }
/// Create a note on event pub fn note_on(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self {
pub fn note_on(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self { Self { timestamp, status: 0x90 | (channel & 0x0F), data1: note, data2: velocity }
Self {
timestamp,
timestamp_beats: 0.0,
timestamp_frames: 0.0,
status: 0x90 | (channel & 0x0F),
data1: note,
data2: velocity,
}
} }
/// Create a note off event pub fn note_off(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self {
pub fn note_off(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self { Self { timestamp, status: 0x80 | (channel & 0x0F), data1: note, data2: velocity }
Self {
timestamp,
timestamp_beats: 0.0,
timestamp_frames: 0.0,
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 { pub fn is_note_on(&self) -> bool {
(self.status & 0xF0) == 0x90 && self.data2 > 0 (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 { pub fn is_note_off(&self) -> bool {
(self.status & 0xF0) == 0x80 || ((self.status & 0xF0) == 0x90 && self.data2 == 0) (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 }
pub fn channel(&self) -> u8 { pub fn message_type(&self) -> u8 { self.status & 0xF0 }
self.status & 0x0F
}
/// Get the message type (upper 4 bits of status)
pub fn message_type(&self) -> u8 {
self.status & 0xF0
}
} }
/// MIDI clip ID type (for clips stored in the pool) /// 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) /// MIDI clip instance ID type (for instances placed on tracks)
pub type MidiClipInstanceId = u32; pub type MidiClipInstanceId = u32;
/// MIDI clip content - stores the actual MIDI events /// MIDI clip content — stores the actual MIDI events.
/// /// `duration` is in beats.
/// This represents the content data stored in the MidiClipPool.
/// Events have timestamps relative to the start of the clip (0.0 = clip beginning).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClip { pub struct MidiClip {
pub id: MidiClipId, pub id: MidiClipId,
pub events: Vec<MidiEvent>, pub events: Vec<MidiEvent>,
pub duration: f64, // Total content duration in seconds /// Total content duration in beats
pub duration: Beats,
pub name: String, pub name: String,
} }
impl MidiClip { impl MidiClip {
/// Create a new MIDI clip with content pub fn new(id: MidiClipId, events: Vec<MidiEvent>, duration: Beats, name: String) -> Self {
pub fn new(id: MidiClipId, events: Vec<MidiEvent>, duration: f64, name: String) -> Self { let mut clip = Self { id, events, duration, name };
let mut clip = Self {
id,
events,
duration,
name,
};
// Sort events by timestamp
clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
clip clip
} }
/// Create an empty MIDI clip pub fn empty(id: MidiClipId, duration: Beats, name: String) -> Self {
pub fn empty(id: MidiClipId, duration: f64, name: String) -> Self { Self { id, events: Vec::new(), duration, name }
Self {
id,
events: Vec::new(),
duration,
name,
}
} }
/// Add a MIDI event to the clip
pub fn add_event(&mut self, event: MidiEvent) { pub fn add_event(&mut self, event: MidiEvent) {
self.events.push(event); self.events.push(event);
// Keep events sorted by timestamp
self.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); self.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
} }
/// Get events within a time range (relative to clip start) /// Get events within a beat 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: Beats, end: Beats) -> Vec<MidiEvent> {
pub fn get_events_in_range(&self, start: f64, end: f64) -> Vec<MidiEvent> { self.events.iter()
self.events
.iter()
.filter(|e| e.timestamp >= start && e.timestamp < end) .filter(|e| e.timestamp >= start && e.timestamp < end)
.copied() .copied()
.collect() .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 /// All timing fields are in beats.
/// - `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`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClipInstance { pub struct MidiClipInstance {
pub id: MidiClipInstanceId, pub id: MidiClipInstanceId,
pub clip_id: MidiClipId, // Reference to MidiClip in pool pub clip_id: MidiClipId,
/// Start position within the clip content (seconds) /// Start of the trimmed region within the clip content (beats)
pub internal_start: f64, pub internal_start: Beats,
#[serde(default)] pub internal_start_beats: f64, /// End of the trimmed region within the clip content (beats)
#[serde(default)] pub internal_start_frames: f64, pub internal_end: Beats,
/// 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 position on the timeline (seconds) /// Start position on the timeline (beats)
pub external_start: f64, pub external_start: Beats,
#[serde(default)] pub external_start_beats: f64, /// Duration on the timeline (beats); > internal duration = looping
#[serde(default)] pub external_start_frames: f64, pub external_duration: Beats,
/// 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,
} }
impl MidiClipInstance { impl MidiClipInstance {
/// Create a new MIDI clip instance
pub fn new( pub fn new(
id: MidiClipInstanceId, id: MidiClipInstanceId,
clip_id: MidiClipId, clip_id: MidiClipId,
internal_start: f64, internal_start: Beats,
internal_end: f64, internal_end: Beats,
external_start: f64, external_start: Beats,
external_duration: f64, external_duration: Beats,
) -> Self { ) -> Self {
Self { Self { id, clip_id, internal_start, internal_end, external_start, external_duration }
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,
}
} }
/// 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( pub fn from_full_clip(
id: MidiClipInstanceId, id: MidiClipInstanceId,
clip_id: MidiClipId, clip_id: MidiClipId,
clip_duration: f64, clip_duration: Beats,
external_start: f64, external_start: Beats,
) -> Self { ) -> Self {
Self { Self {
id, id,
clip_id, clip_id,
internal_start: 0.0, internal_start: Beats::ZERO,
internal_start_beats: 0.0,
internal_start_frames: 0.0,
internal_end: clip_duration, internal_end: clip_duration,
internal_end_beats: 0.0,
internal_end_frames: 0.0,
external_start, external_start,
external_start_beats: 0.0,
external_start_frames: 0.0,
external_duration: clip_duration, external_duration: clip_duration,
external_duration_beats: 0.0,
external_duration_frames: 0.0,
} }
} }
/// Get the internal (content) duration pub fn internal_duration(&self) -> Beats { self.internal_end - self.internal_start }
pub fn internal_duration(&self) -> f64 { pub fn external_end(&self) -> Beats { self.external_start + self.external_duration }
self.internal_end - self.internal_start pub fn is_looping(&self) -> bool { self.external_duration > self.internal_duration() }
}
/// Get the end time on the timeline /// Check if this instance overlaps with a beat range
pub fn external_end(&self) -> f64 { pub fn overlaps_range(&self, range_start: Beats, range_end: Beats) -> bool {
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 {
self.external_start < range_end && self.external_end() > range_start self.external_start < range_end && self.external_end() > range_start
} }
/// Populate beats/frames from the current seconds values. /// Get events that should fire in a given beat range on the timeline.
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) { /// Returns events with `timestamp` set to their global timeline beat position.
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)
pub fn get_events_in_range( pub fn get_events_in_range(
&self, &self,
clip: &MidiClip, clip: &MidiClip,
range_start_seconds: f64, range_start: Beats,
range_end_seconds: f64, range_end: Beats,
) -> Vec<MidiEvent> { ) -> Vec<MidiEvent> {
let mut result = Vec::new(); let mut result = Vec::new();
// Check if instance overlaps with the range if !self.overlaps_range(range_start, range_end) {
if !self.overlaps_range(range_start_seconds, range_end_seconds) {
return result; return result;
} }
let internal_duration = self.internal_duration(); let internal_duration = self.internal_duration();
if internal_duration <= 0.0 { if internal_duration <= Beats::ZERO {
return result; return result;
} }
// Calculate how many complete loops fit in the external duration
let num_loops = if self.external_duration > internal_duration { let num_loops = if self.external_duration > internal_duration {
(self.external_duration / internal_duration).ceil() as usize (self.external_duration / internal_duration).ceil() as usize
} else { } else {
@ -342,24 +165,18 @@ impl MidiClipInstance {
let external_end = self.external_end(); let external_end = self.external_end();
for loop_idx in 0..num_loops { 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 { 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 { if event.timestamp < self.internal_start || event.timestamp > self.internal_end {
continue; continue;
} }
// Convert to timeline time
let relative_content_time = event.timestamp - self.internal_start; let relative_content_time = event.timestamp - self.internal_start;
let timeline_time = self.external_start + loop_offset + relative_content_time; let timeline_time = self.external_start + loop_offset + relative_content_time;
// Check if within current buffer range and instance bounds if timeline_time >= range_start
// Use <= for external_end so note-offs at the clip boundary are included && timeline_time < range_end
if timeline_time >= range_start_seconds
&& timeline_time < range_end_seconds
&& timeline_time <= external_end && timeline_time <= external_end
{ {
let mut adjusted_event = *event; let mut adjusted_event = *event;

View File

@ -1,6 +1,7 @@
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use std::collections::HashMap; use std::collections::HashMap;
use super::midi::{MidiClip, MidiClipId, MidiEvent}; use super::midi::{MidiClip, MidiClipId, MidiEvent};
use crate::time::Beats;
/// Pool for storing MIDI clip content /// Pool for storing MIDI clip content
/// Similar to AudioClipPool but for MIDI data /// 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 /// Add a new clip to the pool with the given events and duration
/// Returns the ID of the newly created clip /// 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; let id = self.next_id;
self.next_id += 1; self.next_id += 1;

View File

@ -1,6 +1,7 @@
use super::node_trait::AudioNode; use super::node_trait::AudioNode;
use super::types::{ConnectionError, SignalType}; use super::types::{ConnectionError, SignalType};
use crate::audio::midi::MidiEvent; use crate::audio::midi::MidiEvent;
use crate::time::Beats;
use petgraph::algo::has_path_connecting; use petgraph::algo::has_path_connecting;
use petgraph::stable_graph::{NodeIndex, StableGraph}; use petgraph::stable_graph::{NodeIndex, StableGraph};
use petgraph::visit::{EdgeRef, IntoEdgeReferences}; use petgraph::visit::{EdgeRef, IntoEdgeReferences};
@ -93,8 +94,8 @@ pub struct AudioGraph {
/// UI positions for nodes (node_index -> (x, y)) /// UI positions for nodes (node_index -> (x, y))
node_positions: std::collections::HashMap<u32, (f32, f32)>, node_positions: std::collections::HashMap<u32, (f32, f32)>,
/// Current playback time (for automation nodes) /// Current playback time in beats (for automation nodes)
playback_time: f64, playback_time: Beats,
/// Project tempo (synced from Engine via SetTempo) /// Project tempo (synced from Engine via SetTempo)
bpm: f32, bpm: f32,
@ -123,7 +124,7 @@ impl AudioGraph {
// Pre-allocate MIDI input buffers (max 128 events per port) // Pre-allocate MIDI input buffers (max 128 events per port)
midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(), midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(),
node_positions: std::collections::HashMap::new(), node_positions: std::collections::HashMap::new(),
playback_time: 0.0, playback_time: Beats::ZERO,
bpm: 120.0, bpm: 120.0,
beats_per_bar: 4, beats_per_bar: 4,
topo_cache: None, topo_cache: None,
@ -475,7 +476,7 @@ impl AudioGraph {
} }
/// Process the graph and produce audio output /// 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 // Update playback time
self.playback_time = playback_time; self.playback_time = playback_time;
@ -484,6 +485,7 @@ impl AudioGraph {
for node in self.graph.node_weights_mut() { for node in self.graph.node_weights_mut() {
if let Some(auto_node) = node.node.as_any_mut().downcast_mut::<AutomationInputNode>() { if let Some(auto_node) = node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
auto_node.set_playback_time(playback_time); 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>() { } else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() {
beat_node.set_playback_time(playback_time); beat_node.set_playback_time(playback_time);
beat_node.set_tempo(self.bpm, self.beats_per_bar); beat_node.set_tempo(self.bpm, self.beats_per_bar);
@ -1017,7 +1019,7 @@ impl AudioGraph {
serialized.automation_display_name = Some(auto_node.display_name().to_string()); serialized.automation_display_name = Some(auto_node.display_name().to_string());
serialized.automation_keyframes = auto_node.keyframes().iter().map(|kf| { serialized.automation_keyframes = auto_node.keyframes().iter().map(|kf| {
SerializedKeyframe { SerializedKeyframe {
time: kf.time, time: kf.time.0,
value: kf.value, value: kf.value,
interpolation: match kf.interpolation { interpolation: match kf.interpolation {
InterpolationType::Linear => "linear", InterpolationType::Linear => "linear",
@ -1357,7 +1359,7 @@ impl AudioGraph {
auto_node.clear_keyframes(); auto_node.clear_keyframes();
for kf in &serialized_node.automation_keyframes { for kf in &serialized_node.automation_keyframes {
auto_node.add_keyframe(AutomationKeyframe { auto_node.add_keyframe(AutomationKeyframe {
time: kf.time, time: crate::time::Beats(kf.time),
value: kf.value, value: kf.value,
interpolation: match kf.interpolation.as_str() { interpolation: match kf.interpolation.as_str() {
"bezier" => InterpolationType::Bezier, "bezier" => InterpolationType::Bezier,

View File

@ -1,5 +1,6 @@
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent; use crate::audio::midi::MidiEvent;
use crate::time::Beats;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
@ -16,8 +17,7 @@ pub enum InterpolationType {
/// A single keyframe in an automation curve /// A single keyframe in an automation curve
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationKeyframe { pub struct AutomationKeyframe {
/// Time in seconds (absolute project time) pub time: Beats,
pub time: f64,
/// CV output value /// CV output value
pub value: f32, pub value: f32,
/// Interpolation type to next keyframe /// Interpolation type to next keyframe
@ -29,7 +29,7 @@ pub struct AutomationKeyframe {
} }
impl AutomationKeyframe { impl AutomationKeyframe {
pub fn new(time: f64, value: f32) -> Self { pub fn new(time: Beats, value: f32) -> Self {
Self { Self {
time, time,
value, value,
@ -47,8 +47,10 @@ pub struct AutomationInputNode {
keyframes: Vec<AutomationKeyframe>, keyframes: Vec<AutomationKeyframe>,
outputs: Vec<NodePort>, outputs: Vec<NodePort>,
parameters: Vec<Parameter>, parameters: Vec<Parameter>,
/// Shared playback time (set by the graph before processing) /// Shared playback time in beats (set by the graph before processing)
playback_time: Arc<RwLock<f64>>, 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) /// Minimum output value (for UI display range)
pub value_min: f32, pub value_min: f32,
/// Maximum output value (for UI display range) /// Maximum output value (for UI display range)
@ -71,22 +73,26 @@ impl AutomationInputNode {
Self { Self {
name: name.clone(), name: name.clone(),
display_name: "Automation".to_string(), display_name: "Automation".to_string(),
keyframes: vec![AutomationKeyframe::new(0.0, 0.0)], keyframes: vec![AutomationKeyframe::new(Beats::ZERO, 0.0)],
outputs, outputs,
parameters, 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_min: -1.0,
value_max: 1.0, value_max: 1.0,
} }
} }
/// Set the playback time (called by graph before processing) pub fn set_playback_time(&mut self, time: Beats) {
pub fn set_playback_time(&mut self, time: f64) {
if let Ok(mut playback) = self.playback_time.write() { if let Ok(mut playback) = self.playback_time.write() {
*playback = time; *playback = time;
} }
} }
pub fn set_bpm(&mut self, bpm: f64) {
self.bpm = bpm;
}
/// Get the display name (shown in UI) /// Get the display name (shown in UI)
pub fn display_name(&self) -> &str { pub fn display_name(&self) -> &str {
&self.display_name &self.display_name
@ -116,8 +122,7 @@ impl AutomationInputNode {
} }
} }
/// Remove keyframe at specific time (with tolerance) pub fn remove_keyframe_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
pub fn remove_keyframe_at_time(&mut self, time: f64, tolerance: f64) -> bool {
if let Some(idx) = self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) { if let Some(idx) = self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) {
self.keyframes.remove(idx); self.keyframes.remove(idx);
true true
@ -126,10 +131,8 @@ impl AutomationInputNode {
} }
} }
/// Update an existing keyframe
pub fn update_keyframe(&mut self, keyframe: AutomationKeyframe) { 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, Beats(0.001));
self.remove_keyframe_at_time(keyframe.time, 0.001);
self.add_keyframe(keyframe); self.add_keyframe(keyframe);
} }
@ -143,24 +146,20 @@ impl AutomationInputNode {
self.keyframes.clear(); self.keyframes.clear();
} }
/// Evaluate curve at a specific time fn evaluate_at_time(&self, time: Beats) -> f32 {
fn evaluate_at_time(&self, time: f64) -> f32 {
if self.keyframes.is_empty() { if self.keyframes.is_empty() {
return 0.0; return 0.0;
} }
// Before first keyframe
if time <= self.keyframes[0].time { if time <= self.keyframes[0].time {
return self.keyframes[0].value; return self.keyframes[0].value;
} }
// After last keyframe
let last_idx = self.keyframes.len() - 1; let last_idx = self.keyframes.len() - 1;
if time >= self.keyframes[last_idx].time { if time >= self.keyframes[last_idx].time {
return self.keyframes[last_idx].value; return self.keyframes[last_idx].value;
} }
// Find bracketing keyframes
for i in 0..self.keyframes.len() - 1 { for i in 0..self.keyframes.len() - 1 {
let kf1 = &self.keyframes[i]; let kf1 = &self.keyframes[i];
let kf2 = &self.keyframes[i + 1]; let kf2 = &self.keyframes[i + 1];
@ -173,14 +172,12 @@ impl AutomationInputNode {
0.0 0.0
} }
/// Interpolate between two keyframes fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: Beats) -> f32 {
fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: f64) -> f32 {
// Calculate normalized position between keyframes (0.0 to 1.0)
let t = if kf2.time == kf1.time { let t = if kf2.time == kf1.time {
0.0 0.0f64
} else { } else {
((time - kf1.time) / (kf2.time - kf1.time)) as f32 (time - kf1.time) / (kf2.time - kf1.time)
}; } as f32;
match kf1.interpolation { match kf1.interpolation {
InterpolationType::Linear => { InterpolationType::Linear => {
@ -257,19 +254,17 @@ impl AudioNode for AutomationInputNode {
let output = &mut outputs[0]; let output = &mut outputs[0];
let length = output.len(); let length = output.len();
// Get the starting playback time
let playhead = if let Ok(playback) = self.playback_time.read() { let playhead = if let Ok(playback) = self.playback_time.read() {
*playback *playback
} else { } else {
0.0 Beats::ZERO
}; };
// Calculate time per sample // Advance per sample in beats: beats_per_sample = bpm / 60 / sample_rate
let sample_duration = 1.0 / sample_rate as f64; let beats_per_sample = self.bpm / 60.0 / sample_rate as f64;
// Evaluate curve for each sample
for i in 0..length { 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); output[i] = self.evaluate_at_time(time);
} }
} }
@ -293,7 +288,8 @@ impl AudioNode for AutomationInputNode {
keyframes: self.keyframes.clone(), keyframes: self.keyframes.clone(),
outputs: self.outputs.clone(), outputs: self.outputs.clone(),
parameters: self.parameters.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_min: self.value_min,
value_max: self.value_max, 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::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
use crate::audio::midi::MidiEvent; use crate::audio::midi::MidiEvent;
use crate::time::Beats;
const PARAM_RESOLUTION: u32 = 0; const PARAM_RESOLUTION: u32 = 0;
@ -61,12 +62,12 @@ pub struct BeatNode {
bpm: f32, bpm: f32,
beats_per_bar: u32, beats_per_bar: u32,
resolution: BeatResolution, resolution: BeatResolution,
/// Playback time in seconds, set by the graph before process() /// Playback time in beats, set by the graph before process()
playback_time: f64, playback_time: Beats,
/// Previous playback_time to detect paused state /// Previous playback_time to detect paused state
prev_playback_time: f64, prev_playback_time: Beats,
/// Free-running time accumulator for when playback is stopped /// Free-running beat accumulator for when playback is stopped
free_run_time: f64, free_run_time: Beats,
inputs: Vec<NodePort>, inputs: Vec<NodePort>,
outputs: Vec<NodePort>, outputs: Vec<NodePort>,
parameters: Vec<Parameter>, parameters: Vec<Parameter>,
@ -92,16 +93,16 @@ impl BeatNode {
bpm: DEFAULT_BPM, bpm: DEFAULT_BPM,
beats_per_bar: DEFAULT_BEATS_PER_BAR, beats_per_bar: DEFAULT_BEATS_PER_BAR,
resolution: BeatResolution::Quarter, resolution: BeatResolution::Quarter,
playback_time: 0.0, playback_time: Beats::ZERO,
prev_playback_time: -1.0, prev_playback_time: Beats(-1.0),
free_run_time: 0.0, free_run_time: Beats::ZERO,
inputs, inputs,
outputs, outputs,
parameters, parameters,
} }
} }
pub fn set_playback_time(&mut self, time: f64) { pub fn set_playback_time(&mut self, time: Beats) {
self.playback_time = time; 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 }; let base_time = if paused { self.free_run_time } else { self.playback_time };
for i in 0..len { for i in 0..len {
let time = base_time + i as f64 * sample_period; // base_time is already in beats; advance by beats_per_second per second
let beat_pos = time * beats_per_second; let beat_pos = base_time.0 + i as f64 * beats_per_second * sample_period;
// Beat subdivision phase: 0→1 sawtooth // Beat subdivision phase: 0→1 sawtooth
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32; 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) // 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) { fn reset(&mut self) {
self.playback_time = 0.0; self.playback_time = Beats::ZERO;
self.prev_playback_time = -1.0; self.prev_playback_time = Beats(-1.0);
self.free_run_time = 0.0; self.free_run_time = Beats::ZERO;
} }
fn node_type(&self) -> &str { fn node_type(&self) -> &str {
@ -211,9 +212,9 @@ impl AudioNode for BeatNode {
bpm: self.bpm, bpm: self.bpm,
beats_per_bar: self.beats_per_bar, beats_per_bar: self.beats_per_bar,
resolution: self.resolution, resolution: self.resolution,
playback_time: 0.0, playback_time: Beats::ZERO,
prev_playback_time: -1.0, prev_playback_time: Beats(-1.0),
free_run_time: 0.0, free_run_time: Beats::ZERO,
inputs: self.inputs.clone(), inputs: self.inputs.clone(),
outputs: self.outputs.clone(), outputs: self.outputs.clone(),
parameters: self.parameters.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::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
use crate::audio::midi::MidiEvent; use crate::audio::midi::MidiEvent;
use crate::time::Beats;
const PARAM_MODE: u32 = 0; const PARAM_MODE: u32 = 0;
const PARAM_STEPS: u32 = 1; const PARAM_STEPS: u32 = 1;
@ -244,14 +245,14 @@ impl AudioNode for SequencerNode {
// Note-off for notes no longer active // Note-off for notes no longer active
for &note in &self.prev_active_notes { for &note in &self.prev_active_notes {
if !new_notes.contains(&note) { 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 // Note-on for newly active notes
for &note in &new_notes { for &note in &new_notes {
if !self.prev_active_notes.contains(&note) { 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 // Process this voice's graph with its MIDI events
// Note: playback_time is 0.0 since voice allocator doesn't track time // 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 // Auto-deactivate releasing voices that have gone silent
if voice_state.releasing { if voice_state.releasing {

View File

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

View File

@ -4,6 +4,8 @@ use super::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, Mi
use super::midi_pool::MidiClipPool; use super::midi_pool::MidiClipPool;
use super::pool::AudioClipPool; use super::pool::AudioClipPool;
use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode}; use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode};
use crate::tempo_map::TempoMap;
use crate::time::{Beats, Seconds};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use std::collections::HashMap; use std::collections::HashMap;
@ -216,46 +218,6 @@ impl Project {
self.tracks.iter().map(|(&id, node)| (id, node)) self.tracks.iter().map(|(&id, node)| (id, node))
} }
/// After a BPM change, update MIDI clip durations then sync all clip beats/frames from seconds.
///
/// `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, 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(bpm, fps);
}
}
crate::audio::track::TrackNode::Midi(t) => {
// 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(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 /// 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>)> { 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) { if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) {
@ -328,9 +290,9 @@ impl Project {
&mut self, &mut self,
track_id: TrackId, track_id: TrackId,
events: Vec<MidiEvent>, events: Vec<MidiEvent>,
duration: f64, duration: Beats,
name: String, name: String,
external_start: f64, external_start: Beats,
) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> { ) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> {
// Verify track exists and is a MIDI track // Verify track exists and is a MIDI track
if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) { if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) {
@ -363,11 +325,11 @@ impl Project {
/// Legacy method for backwards compatibility - creates clip and instance from old MidiClip format /// 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> { 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 /// 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) // Add the clip to the pool (it already has events and duration)
let duration = clip.duration; let duration = clip.duration;
let clip_id = clip.id; let clip_id = clip.id;
@ -411,7 +373,8 @@ impl Project {
output: &mut [f32], output: &mut [f32],
audio_pool: &AudioClipPool, audio_pool: &AudioClipPool,
buffer_pool: &mut BufferPool, buffer_pool: &mut BufferPool,
playhead_seconds: f64, playhead_seconds: Seconds,
tempo_map: &TempoMap,
sample_rate: u32, sample_rate: u32,
channels: u32, channels: u32,
live_only: bool, live_only: bool,
@ -423,7 +386,7 @@ impl Project {
// Create initial render context // Create initial render context
let ctx = RenderContext { let ctx = RenderContext {
live_only, 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) // Render each root track (index-based to avoid clone)
@ -497,7 +460,7 @@ impl Project {
let mut track_buffer = buffer_pool.acquire(); let mut track_buffer = buffer_pool.acquire();
track_buffer.resize(output.len(), 0.0); track_buffer.resize(output.len(), 0.0);
track_buffer.fill(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) // 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); let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
track.peak_level = track.peak_level.max(buffer_peak); track.peak_level = track.peak_level.max(buffer_peak);
@ -585,7 +548,7 @@ impl Project {
let mut graph_output = buffer_pool.acquire(); let mut graph_output = buffer_pool.acquire();
graph_output.resize(output.len(), 0.0); graph_output.resize(output.len(), 0.0);
graph_output.fill(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()) { for (out_sample, graph_sample) in output.iter_mut().zip(graph_output.iter()) {
*out_sample += graph_sample * group.volume; *out_sample += graph_sample * group.volume;
@ -680,7 +643,7 @@ impl Project {
pub fn send_midi_note_on(&mut self, track_id: TrackId, note: u8, velocity: u8) { 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 // 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) { 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); track.queue_live_midi(event);
} }
} }
@ -689,7 +652,7 @@ impl Project {
pub fn send_midi_note_off(&mut self, track_id: TrackId, note: u8) { 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 // 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) { 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); track.queue_live_midi(event);
} }
} }

View File

@ -1,6 +1,7 @@
/// Audio recording system for capturing microphone input /// Audio recording system for capturing microphone input
use crate::audio::{ClipId, MidiClipId, TrackId}; use crate::audio::{ClipId, MidiClipId, TrackId};
use crate::io::{WavWriter, WaveformPeak}; use crate::io::{WavWriter, WaveformPeak};
use crate::time::{Beats, Seconds};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
@ -18,8 +19,8 @@ pub struct RecordingState {
pub sample_rate: u32, pub sample_rate: u32,
/// Number of channels /// Number of channels
pub channels: u32, pub channels: u32,
/// Timeline start position in seconds /// Timeline start position
pub start_time: f64, pub start_time: Beats,
/// Total frames recorded /// Total frames recorded
pub frames_written: usize, pub frames_written: usize,
/// Whether recording is currently paused /// Whether recording is currently paused
@ -45,7 +46,7 @@ impl RecordingState {
writer: WavWriter, writer: WavWriter,
sample_rate: u32, sample_rate: u32,
channels: u32, channels: u32,
start_time: f64, start_time: Beats,
_flush_interval_seconds: f64, // No longer used - kept for API compatibility _flush_interval_seconds: f64, // No longer used - kept for API compatibility
) -> Self { ) -> Self {
// Calculate frames per waveform peak // Calculate frames per waveform peak
@ -130,9 +131,9 @@ impl RecordingState {
} }
} }
/// Get current recording duration in seconds /// Get current recording duration
pub fn duration(&self) -> f64 { pub fn duration(&self) -> Seconds {
self.frames_written as f64 / self.sample_rate as f64 Seconds(self.frames_written as f64 / self.sample_rate as f64)
} }
/// Finalize the recording and return the temp file path, waveform, and audio data /// 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 /// Active MIDI note waiting for its noteOff event
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct ActiveMidiNote { struct ActiveMidiNote {
/// MIDI note number (0-127)
note: u8, note: u8,
/// Velocity (0-127)
velocity: u8, velocity: u8,
/// Absolute time when note started (seconds) start_time: Beats,
start_time: f64,
} }
/// State of an active MIDI recording session /// State of an active MIDI recording session.
pub struct MidiRecordingState { pub struct MidiRecordingState {
/// Track being recorded to
pub track_id: TrackId, pub track_id: TrackId,
/// MIDI clip ID
pub clip_id: MidiClipId, pub clip_id: MidiClipId,
/// Timeline start position in seconds pub start_time: Beats,
pub start_time: f64,
/// Currently active notes (noteOn without matching noteOff)
/// Maps note number to ActiveMidiNote
active_notes: HashMap<u8, ActiveMidiNote>, active_notes: HashMap<u8, ActiveMidiNote>,
/// Completed notes ready to be added to clip /// Completed notes: (time_offset, note, velocity, duration) — all times in beats
/// Format: (time_offset, note, velocity, duration) pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
pub completed_notes: Vec<(f64, u8, u8, f64)>,
} }
impl MidiRecordingState { impl MidiRecordingState {
/// Create a new MIDI recording state pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: Beats) -> Self {
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: f64) -> Self {
Self { Self {
track_id, track_id,
clip_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: Beats) {
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 });
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: Beats) {
pub fn note_off(&mut self, note: u8, absolute_time: f64) {
// Find the matching noteOn
if let Some(active_note) = self.active_notes.remove(&note) { 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 { if absolute_time <= self.start_time {
return; 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 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(( self.completed_notes.push((
time_offset, note_start - self.start_time,
active_note.note, active_note.note,
active_note.velocity, 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) -> &[(Beats, u8, u8, Beats)] {
pub fn get_notes(&self) -> &[(f64, u8, u8, f64)] {
&self.completed_notes &self.completed_notes
} }
/// Get the number of completed notes
pub fn note_count(&self) -> usize { pub fn note_count(&self) -> usize {
self.completed_notes.len() self.completed_notes.len()
} }
/// Get all completed notes plus currently-held notes with a provisional duration. /// 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: Beats) -> Vec<(Beats, u8, u8, Beats)> {
pub fn get_notes_with_active(&self, current_time: f64) -> Vec<(f64, u8, u8, f64)> {
let mut notes = self.completed_notes.clone(); let mut notes = self.completed_notes.clone();
for active in self.active_notes.values() { for active in self.active_notes.values() {
let note_start = active.start_time.max(self.start_time); let note_start = active.start_time.max(self.start_time);
let time_offset = note_start - self.start_time; notes.push((
let provisional_dur = (current_time - note_start).max(0.0); note_start - self.start_time,
notes.push((time_offset, active.note, active.velocity, provisional_dur)); active.note,
active.velocity,
(current_time - note_start).max(Beats::ZERO),
));
} }
notes notes
} }
/// Get the note numbers of all currently held (active) notes
pub fn active_note_numbers(&self) -> Vec<u8> { pub fn active_note_numbers(&self) -> Vec<u8> {
self.active_notes.keys().copied().collect() self.active_notes.keys().copied().collect()
} }
/// Close out all active notes at the given time pub fn close_active_notes(&mut self, end_time: Beats) {
/// This should be called when stopping recording to end any held notes
pub fn close_active_notes(&mut self, end_time: f64) {
let active_notes: Vec<_> = self.active_notes.drain().collect(); let active_notes: Vec<_> = self.active_notes.drain().collect();
for (_note_num, active_note) in active_notes { for (_note_num, active_note) in active_notes {
let note_start = active_note.start_time.max(self.start_time); 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(( self.completed_notes.push((
time_offset, note_start - self.start_time,
active_note.note, active_note.note,
active_note.velocity, 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::nodes::{AudioInputNode, AudioOutputNode};
use super::node_graph::preset::GraphPreset; use super::node_graph::preset::GraphPreset;
use super::pool::AudioClipPool; use super::pool::AudioClipPool;
use crate::tempo_map::TempoMap;
use crate::time::{Beats, Seconds};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -23,10 +25,12 @@ pub type Track = AudioTrack;
/// Rendering context that carries timing information through the track hierarchy /// Rendering context that carries timing information through the track hierarchy
/// ///
/// This allows metatracks to transform time for their children (time stretch, offset, etc.) /// This allows metatracks to transform time for their children (time stretch, offset, etc.)
#[derive(Debug, Clone, Copy)] #[derive(Clone, Copy)]
pub struct RenderContext { pub struct RenderContext<'a> {
/// Current playhead position in seconds (in transformed time) /// 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 /// Audio sample rate
pub sample_rate: u32, pub sample_rate: u32,
/// Number of channels /// Number of channels
@ -41,16 +45,17 @@ pub struct RenderContext {
pub live_only: bool, pub live_only: bool,
} }
impl RenderContext { impl<'a> RenderContext<'a> {
/// Create a new render context
pub fn new( pub fn new(
playhead_seconds: f64, playhead_seconds: Seconds,
tempo_map: &'a TempoMap,
sample_rate: u32, sample_rate: u32,
channels: u32, channels: u32,
buffer_size: usize, buffer_size: usize,
) -> Self { ) -> Self {
Self { Self {
playhead_seconds, playhead_seconds,
tempo_map,
sample_rate, sample_rate,
channels, channels,
buffer_size, buffer_size,
@ -59,15 +64,21 @@ impl RenderContext {
} }
} }
/// Get the duration of the buffer in seconds pub fn buffer_duration(&self) -> Seconds {
pub fn buffer_duration(&self) -> f64 { Seconds(self.buffer_size as f64 / (self.sample_rate as f64 * self.channels as f64))
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) -> Seconds {
pub fn buffer_end(&self) -> f64 {
self.playhead_seconds + self.buffer_duration() 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 /// 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, pub time_stretch: f32,
/// Pitch shift in semitones (for future implementation) /// Pitch shift in semitones (for future implementation)
pub pitch_shift: f32, pub pitch_shift: f32,
/// Time offset in seconds (shift content forward/backward in time) /// Time offset (shift content forward/backward in time)
pub offset: f64, pub offset: Seconds,
/// Trim start: offset into the metatrack's internal content (seconds) /// Trim start: offset into the metatrack's internal content
/// Children will see time starting from this point /// Children will see time starting from this point
pub trim_start: f64, pub trim_start: Seconds,
/// Trim end: offset into the metatrack's internal content (seconds) /// Trim end: offset into the metatrack's internal content
/// None means no end trim (play until content ends) /// None means no end trim (play until content ends)
pub trim_end: Option<f64>, pub trim_end: Option<Seconds>,
/// Automation lanes for this metatrack /// Automation lanes for this metatrack
pub automation_lanes: HashMap<AutomationLaneId, AutomationLane>, pub automation_lanes: HashMap<AutomationLaneId, AutomationLane>,
next_automation_id: AutomationLaneId, next_automation_id: AutomationLaneId,
@ -231,8 +242,8 @@ impl Metatrack {
solo: false, solo: false,
time_stretch: 1.0, time_stretch: 1.0,
pitch_shift: 0.0, pitch_shift: 0.0,
offset: 0.0, offset: Seconds::ZERO,
trim_start: 0.0, trim_start: Seconds::ZERO,
trim_end: None, trim_end: None,
automation_lanes: HashMap::new(), automation_lanes: HashMap::new(),
next_automation_id: 0, next_automation_id: 0,
@ -252,7 +263,11 @@ impl Metatrack {
graph graph
} }
/// Build the explicit subtrack mixing graph: SubtrackInputs → Mixer → AudioOutput. /// Build the default subtrack mixing graph: SubtrackInputs → Mixer → Gain → AudioOutput,
/// with an AutomationInput ("Volume", range 0..2) feeding the Gain's CV port.
///
/// Existing Volume keyframes are preserved across rebuilds so that adding/removing
/// a child track doesn't reset the automation.
/// ///
/// `subtracks` is an ordered list of (backend TrackId, display name) for each child. /// `subtracks` is an ordered list of (backend TrackId, display name) for each child.
/// Replaces the current graph and marks `graph_is_default = true`. /// Replaces the current graph and marks `graph_is_default = true`.
@ -262,35 +277,60 @@ impl Metatrack {
sample_rate: u32, sample_rate: u32,
buffer_size: usize, buffer_size: usize,
) { ) {
use super::node_graph::nodes::{SubtrackInputsNode, MixerNode}; use super::node_graph::nodes::{SubtrackInputsNode, MixerNode, GainNode, AutomationInputNode};
use super::node_graph::nodes::AutomationKeyframe;
use crate::time::Beats;
// Preserve existing Volume keyframes before rebuilding.
let existing_volume_kfs = self.get_volume_automation_keyframes();
let n = subtracks.len(); let n = subtracks.len();
let mut graph = AudioGraph::new(sample_rate, buffer_size); let mut graph = AudioGraph::new(sample_rate, buffer_size);
// SubtrackInputs node (N outputs, one per child) // SubtrackInputs node (N outputs, one per child)
// NOTE: `new()` initialises buffers as zero-length; call `update_subtracks` immediately
// to allocate stereo interleaved buffers (buffer_size * 2 samples each).
let mut inputs_node = SubtrackInputsNode::new("Subtrack Inputs", subtracks); let mut inputs_node = SubtrackInputsNode::new("Subtrack Inputs", subtracks);
let subtracks_copy = inputs_node.subtracks().to_vec(); let subtracks_copy = inputs_node.subtracks().to_vec();
inputs_node.update_subtracks(subtracks_copy, buffer_size); inputs_node.update_subtracks(subtracks_copy, buffer_size);
let inputs_id = graph.add_node(Box::new(inputs_node)); let inputs_id = graph.add_node(Box::new(inputs_node));
graph.set_node_position(inputs_id, 100.0, 150.0); graph.set_node_position(inputs_id, 100.0, 150.0);
// Mixer node (starts with 1 spare; grows as connections are made) // Mixer node
let mixer_node = Box::new(MixerNode::new("Mixer")); let mixer_node = Box::new(MixerNode::new("Mixer"));
let mixer_id = graph.add_node(mixer_node); let mixer_id = graph.add_node(mixer_node);
graph.set_node_position(mixer_id, 350.0, 150.0); graph.set_node_position(mixer_id, 330.0, 150.0);
// Gain node — group volume control
let gain_id = graph.add_node(Box::new(GainNode::new("Volume")));
graph.set_node_position(gain_id, 520.0, 150.0);
// AutomationInput — drives the Gain's CV port
let mut auto_node = AutomationInputNode::new("Volume CV");
auto_node.set_display_name("Volume".to_string());
auto_node.value_min = 0.0;
auto_node.value_max = 2.0;
auto_node.clear_keyframes();
if existing_volume_kfs.is_empty() {
auto_node.add_keyframe(AutomationKeyframe::new(Beats::ZERO, 1.0));
} else {
for kf in existing_volume_kfs {
auto_node.add_keyframe(kf);
}
}
let auto_id = graph.add_node(Box::new(auto_node));
graph.set_node_position(auto_id, 520.0, 320.0);
// AudioOutput node // AudioOutput node
let output_node = Box::new(AudioOutputNode::new("Audio Output")); let output_node = Box::new(AudioOutputNode::new("Audio Output"));
let output_id = graph.add_node(output_node); let output_id = graph.add_node(output_node);
graph.set_node_position(output_id, 600.0, 150.0); graph.set_node_position(output_id, 720.0, 150.0);
// Connect SubtrackInputs[i] → Mixer[i] for each subtrack // Connect SubtrackInputs[i] → Mixer[i] for each subtrack
for i in 0..n { for i in 0..n {
let _ = graph.connect(inputs_id, i, mixer_id, i); let _ = graph.connect(inputs_id, i, mixer_id, i);
} }
let _ = graph.connect(mixer_id, 0, output_id, 0); let _ = graph.connect(mixer_id, 0, gain_id, 0); // Mixer → Gain audio
let _ = graph.connect(auto_id, 0, gain_id, 1); // AutomationInput → Gain CV
let _ = graph.connect(gain_id, 0, output_id, 0); // Gain → Audio Out
graph.set_output_node(Some(output_id)); graph.set_output_node(Some(output_id));
self.audio_graph = graph; self.audio_graph = graph;
@ -298,6 +338,22 @@ impl Metatrack {
self.graph_is_default = true; self.graph_is_default = true;
} }
/// Extract Volume AutomationInput keyframes from the current graph (if any),
/// so they can be preserved across `set_subtrack_graph` rebuilds.
fn get_volume_automation_keyframes(&self) -> Vec<super::node_graph::nodes::AutomationKeyframe> {
use super::node_graph::nodes::AutomationInputNode;
for idx in self.audio_graph.node_indices() {
if let Some(node) = self.audio_graph.get_graph_node(idx) {
if node.node.node_type() == "AutomationInput" {
if let Some(auto_node) = node.node.as_any().downcast_ref::<AutomationInputNode>() {
return auto_node.keyframes().to_vec();
}
}
}
}
Vec::new()
}
/// Add a new subtrack port to the existing graph. /// Add a new subtrack port to the existing graph.
/// ///
/// If `graph_is_default`: also connects the new port to a new Mixer input. /// If `graph_is_default`: also connects the new port to a new Mixer input.
@ -493,7 +549,7 @@ impl Metatrack {
} }
/// Evaluate automation at a specific time and return effective parameters /// 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 volume = self.volume;
let mut time_stretch = self.time_stretch; let mut time_stretch = self.time_stretch;
let mut offset = self.offset; let mut offset = self.offset;
@ -517,7 +573,7 @@ impl Metatrack {
} }
ParameterId::TimeOffset => { ParameterId::TimeOffset => {
if let Some(automated_value) = lane.evaluate(time) { if let Some(automated_value) = lane.evaluate(time) {
offset = automated_value as f64; offset = Seconds(automated_value as f64);
} }
} }
_ => {} _ => {}
@ -561,7 +617,7 @@ impl Metatrack {
/// Check whether this metatrack should produce audio at the given parent time. /// Check whether this metatrack should produce audio at the given parent time.
/// Returns false if the playhead is outside the trim window. /// 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; let local_time = (parent_playhead - self.offset) * self.time_stretch as f64;
if local_time < self.trim_start { if local_time < self.trim_start {
return false; return false;
@ -580,7 +636,7 @@ impl Metatrack {
/// Time stretch affects how fast content plays: 0.5 = half speed, 2.0 = double speed /// Time stretch affects how fast content plays: 0.5 = half speed, 2.0 = double speed
/// Offset shifts content forward/backward in time /// Offset shifts content forward/backward in time
/// Trim start offsets into the internal content /// 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; let mut transformed = ctx;
// Apply transformations in order: // Apply transformations in order:
@ -596,7 +652,7 @@ impl Metatrack {
let stretched = adjusted_playhead * self.time_stretch as f64; let stretched = adjusted_playhead * self.time_stretch as f64;
// 3. Add trim_start so children see time starting from the trim point // 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; transformed.playhead_seconds = stretched + self.trim_start;
// Accumulate time stretch for nested metatracks // Accumulate time stretch for nested metatracks
@ -772,13 +828,13 @@ impl MidiTrack {
// Send note-off for all 128 possible MIDI notes to silence the instrument // Send note-off for all 128 possible MIDI notes to silence the instrument
let mut note_offs = Vec::new(); let mut note_offs = Vec::new();
for note in 0..128 { 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 // Create a silent buffer to process the note-offs
let buffer_size = 512 * 2; // stereo let buffer_size = 512 * 2; // stereo
let mut silent_buffer = vec![0.0f32; buffer_size]; 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) /// Queue a live MIDI event (from virtual keyboard or MIDI controller)
@ -805,17 +861,17 @@ impl MidiTrack {
let mut midi_events = Vec::new(); let mut midi_events = Vec::new();
if !ctx.live_only { if !ctx.live_only {
let buffer_duration_seconds = output.len() as f64 / (ctx.sample_rate as f64 * ctx.channels as f64); let playhead_beats = ctx.playhead_beats();
let buffer_end_seconds = ctx.playhead_seconds + buffer_duration_seconds; 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(); let mut currently_active = HashSet::new();
for instance in &self.clip_instances { 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); currently_active.insert(instance.id);
} }
if let Some(clip) = midi_pool.get_clip(instance.clip_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); midi_events.extend(events);
} }
} }
@ -824,7 +880,7 @@ impl MidiTrack {
for prev_id in &self.prev_active_instances { for prev_id in &self.prev_active_instances {
if !currently_active.contains(prev_id) { if !currently_active.contains(prev_id) {
for note in 0..128u8 { 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; break;
} }
@ -836,10 +892,10 @@ impl MidiTrack {
midi_events.extend(self.live_midi_queue.drain(..)); midi_events.extend(self.live_midi_queue.drain(..));
// Generate audio using instrument graph // 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) // 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 // Apply track volume
for sample in output.iter_mut() { for sample in output.iter_mut() {
@ -848,7 +904,7 @@ impl MidiTrack {
} }
/// Evaluate automation at a specific time and return the effective volume /// 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; let mut volume = self.volume;
// Check for volume automation // Check for volume automation
@ -1081,12 +1137,9 @@ impl AudioTrack {
&mut self, &mut self,
output: &mut [f32], output: &mut [f32],
pool: &AudioClipPool, pool: &AudioClipPool,
playhead_seconds: f64, ctx: RenderContext<'_>,
sample_rate: u32,
channels: u32,
) -> usize { ) -> usize {
let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64); let buffer_end = ctx.buffer_end();
let buffer_end_seconds = playhead_seconds + buffer_duration_seconds;
// Split borrow: take clip_render_buffer out to avoid borrow conflict with &self methods // 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); let mut clip_buffer = std::mem::take(&mut self.clip_render_buffer);
@ -1096,16 +1149,8 @@ impl AudioTrack {
// Render all active clip instances into the buffer // Render all active clip instances into the buffer
for clip in &self.clips { for clip in &self.clips {
// Check if clip overlaps with current buffer time range if clip.external_start_secs(ctx.tempo_map) < buffer_end && clip.external_end_secs(ctx.tempo_map) > ctx.playhead_seconds {
if clip.external_start < buffer_end_seconds && clip.external_end() > playhead_seconds { rendered += self.render_clip(clip, &mut clip_buffer, pool, ctx);
rendered += self.render_clip(
clip,
&mut clip_buffer,
pool,
playhead_seconds,
sample_rate,
channels,
);
} }
} }
@ -1123,13 +1168,13 @@ impl AudioTrack {
} }
// Process through the effects graph (this will write to output buffer) // 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 // Put the buffer back for reuse next callback
self.clip_render_buffer = clip_buffer; self.clip_render_buffer = clip_buffer;
// Evaluate and apply automation // 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 // Apply track volume
for sample in output.iter_mut() { for sample in output.iter_mut() {
@ -1140,7 +1185,7 @@ impl AudioTrack {
} }
/// Evaluate automation at a specific time and return the effective volume /// 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; let mut volume = self.volume;
// Check for volume automation // Check for volume automation
@ -1169,49 +1214,40 @@ impl AudioTrack {
clip: &AudioClipInstance, clip: &AudioClipInstance,
output: &mut [f32], output: &mut [f32],
pool: &AudioClipPool, pool: &AudioClipPool,
playhead_seconds: f64, ctx: RenderContext<'_>,
sample_rate: u32,
channels: u32,
) -> usize { ) -> usize {
let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64); let playhead = ctx.playhead_seconds;
let buffer_end_seconds = playhead_seconds + buffer_duration_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) // 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_start = playhead.max(clip.external_start_secs(tempo_map));
let render_end_seconds = buffer_end_seconds.min(clip.external_end()); let render_end = buffer_end.min(clip.external_end_secs(tempo_map));
// If no overlap, return early if render_start >= render_end {
if render_start_seconds >= render_end_seconds {
return 0; return 0;
} }
let internal_duration = clip.internal_duration(); let internal_duration = clip.internal_duration();
if internal_duration <= 0.0 { if internal_duration <= Seconds::ZERO {
return 0; return 0;
} }
// Calculate combined gain
let combined_gain = clip.gain; let combined_gain = clip.gain;
let mut total_rendered = 0; 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; let samples_per_second = sample_rate as f64 * channels as f64;
// For now, render in a simpler way - iterate through the timeline range let output_start_offset = ((render_start - playhead).0 * samples_per_second + 0.5) as usize;
// and use get_content_position for each sample position let output_end_offset = ((render_end - playhead).0 * samples_per_second + 0.5) as usize;
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;
if output_end_offset > output.len() || output_start_offset > output.len() { if output_end_offset > output.len() || output_start_offset > output.len() {
return 0; return 0;
} }
// If not looping, we can render in one chunk (more efficient) if !clip.is_looping(tempo_map) {
if !clip.is_looping() { let content_start = clip.get_content_position(render_start, tempo_map).unwrap_or(clip.internal_start);
// Simple case: no looping
let content_start = clip.get_content_position(render_start_seconds).unwrap_or(clip.internal_start);
let output_len = output.len(); let output_len = output.len();
let output_slice = &mut output[output_start_offset..output_end_offset.min(output_len)]; let output_slice = &mut output[output_start_offset..output_end_offset.min(output_len)];
@ -1225,23 +1261,20 @@ impl AudioTrack {
clip.read_ahead.as_deref(), clip.read_ahead.as_deref(),
); );
} else { } else {
// Looping case: need to handle wrap-around at loop boundaries // Looping case: handle wrap-around at loop boundaries
// Render in segments, one per loop iteration let mut timeline_pos = render_start;
let mut timeline_pos = render_start_seconds;
let mut output_offset = output_start_offset; let mut output_offset = output_start_offset;
while timeline_pos < render_end_seconds && output_offset < output.len() { while timeline_pos < render_end && output_offset < output.len() {
// Calculate position within the loop let relative_pos = timeline_pos - clip.external_start_secs(tempo_map);
let relative_pos = timeline_pos - clip.external_start; let loop_offset = relative_pos.0 % internal_duration.0;
let loop_offset = relative_pos % internal_duration; let content_pos = clip.internal_start + Seconds(loop_offset);
let content_pos = clip.internal_start + loop_offset;
// Calculate how much we can render before hitting the loop boundary let time_to_loop_end = Seconds(internal_duration.0 - loop_offset);
let time_to_loop_end = internal_duration - loop_offset; let time_to_render_end = render_end - timeline_pos;
let time_to_render_end = render_end_seconds - timeline_pos;
let chunk_duration = time_to_loop_end.min(time_to_render_end); 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); let chunk_samples = chunk_samples.min(output.len() - output_offset);
if chunk_samples == 0 { if chunk_samples == 0 {
@ -1262,7 +1295,7 @@ impl AudioTrack {
total_rendered += rendered; total_rendered += rendered;
output_offset += chunk_samples; 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::buffer_pool::BufferPoolStats;
use crate::audio::node_graph::nodes::LoopMode; use crate::audio::node_graph::nodes::LoopMode;
use crate::io::WaveformPeak; use crate::io::WaveformPeak;
use crate::time::{Beats, Seconds};
/// Commands sent from UI/control thread to audio thread /// Commands sent from UI/control thread to audio thread
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -113,7 +114,7 @@ pub enum Command {
// Recording commands // Recording commands
/// Start recording on a track (track_id, start_time) /// Start recording on a track (track_id, start_time)
StartRecording(TrackId, f64), StartRecording(TrackId, Beats),
/// Stop the current recording /// Stop the current recording
StopRecording, StopRecording,
/// Pause the current recording /// Pause the current recording
@ -123,7 +124,7 @@ pub enum Command {
// MIDI Recording commands // MIDI Recording commands
/// Start MIDI recording on a track (track_id, clip_id, start_time) /// 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 /// Stop the current MIDI recording
StopMidiRecording, StopMidiRecording,
@ -144,10 +145,8 @@ pub enum Command {
SetMetronomeEnabled(bool), SetMetronomeEnabled(bool),
/// Set project tempo and time signature (bpm, (numerator, denominator)) /// Set project tempo and time signature (bpm, (numerator, denominator))
SetTempo(f32, (u32, u32)), SetTempo(f32, (u32, u32)),
/// After a BPM change: update MIDI clip durations and sync all clip beats/frames from seconds. /// Replace the entire tempo map (multi-entry variable tempo support)
/// (bpm, fps, midi_durations: Vec<(clip_id, new_duration_seconds)>) SetTempoMap(crate::TempoMap),
ApplyBpmChange(f64, f64, Vec<(MidiClipId, f64)>),
// Node graph commands // Node graph commands
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y) /// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
GraphAddNode(TrackId, String, f32, f32), GraphAddNode(TrackId, String, f32, f32),
@ -289,7 +288,7 @@ pub enum AudioEvent {
/// Recording started (track_id, clip_id, sample_rate, channels) /// Recording started (track_id, clip_id, sample_rate, channels)
RecordingStarted(TrackId, ClipId, u32, u32), RecordingStarted(TrackId, ClipId, u32, u32),
/// Recording progress update (clip_id, current_duration) /// Recording progress update (clip_id, current_duration)
RecordingProgress(ClipId, f64), RecordingProgress(ClipId, Seconds),
/// Recording stopped (clip_id, pool_index, waveform) /// Recording stopped (clip_id, pool_index, waveform)
RecordingStopped(ClipId, usize, Vec<WaveformPeak>), RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
/// Recording error (error_message) /// Recording error (error_message)
@ -297,8 +296,8 @@ pub enum AudioEvent {
/// MIDI recording stopped (track_id, clip_id, note_count) /// MIDI recording stopped (track_id, clip_id, note_count)
MidiRecordingStopped(TrackId, MidiClipId, usize), MidiRecordingStopped(TrackId, MidiClipId, usize),
/// MIDI recording progress (track_id, clip_id, duration, notes) /// MIDI recording progress (track_id, clip_id, duration, notes)
/// Notes format: (start_time, note, velocity, duration) /// Notes format: (start_time, note, velocity, duration) — all times in beats
MidiRecordingProgress(TrackId, MidiClipId, f64, Vec<(f64, u8, u8, f64)>), MidiRecordingProgress(TrackId, MidiClipId, Beats, Vec<(Beats, u8, u8, Beats)>),
/// Project has been reset /// Project has been reset
ProjectReset, ProjectReset,
/// MIDI note started playing (note, velocity) /// MIDI note started playing (note, velocity)

View File

@ -1,164 +1,69 @@
use crate::audio::midi::{MidiClip, MidiClipId, MidiEvent}; use crate::audio::midi::{MidiClip, MidiClipId, MidiEvent};
use crate::time::Beats;
use std::fs; use std::fs;
use std::path::Path; 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>>( pub fn load_midi_file<P: AsRef<Path>>(
path: P, path: P,
clip_id: MidiClipId, clip_id: MidiClipId,
_sample_rate: u32, _sample_rate: u32,
) -> Result<MidiClip, String> { ) -> Result<MidiClip, String> {
// Read the MIDI file
let data = fs::read(path.as_ref()).map_err(|e| format!("Failed to read MIDI file: {}", e))?; 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))?; 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 { let ticks_per_beat = match smf.header.timing {
midly::Timing::Metrical(tpb) => tpb.as_int() as f64, midly::Timing::Metrical(tpb) => tpb.as_int() as f64,
midly::Timing::Timecode(fps, subframe) => { 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 (fps.as_f32() * subframe as f32) as f64
} }
}; };
// First pass: collect all events with their tick positions and tempo changes let mut events = Vec::new();
#[derive(Debug)] let mut max_tick = 0u64;
enum RawEvent {
Midi {
tick: u64,
channel: u8,
message: midly::MidiMessage,
},
Tempo {
tick: u64,
microseconds_per_beat: f64,
},
}
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 { for track in &smf.tracks {
let mut current_tick = 0u64; let mut current_tick = 0u64;
for event in track { for event in track {
current_tick += event.delta.as_int() as u64; 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 { match event.kind {
midly::TrackEventKind::Midi { channel, message } => { midly::TrackEventKind::Midi { channel, message } => {
raw_events.push(RawEvent::Midi { let ch = channel.as_int();
tick: current_tick, match message {
channel: channel.as_int(), midly::MidiMessage::NoteOn { key, vel } => {
message, let velocity = vel.as_int();
}); if velocity > 0 {
} events.push(MidiEvent::note_on(timestamp, ch, key.as_int(), velocity));
midly::TrackEventKind::Meta(midly::MetaMessage::Tempo(tempo)) => { } else {
raw_events.push(RawEvent::Tempo { events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), 64));
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));
} }
} midly::MidiMessage::NoteOff { key, vel } => {
midly::MidiMessage::NoteOff { key, vel } => { events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), vel.as_int()));
events.push(MidiEvent::note_off( }
timestamp, midly::MidiMessage::Controller { controller, value } => {
channel, let status = 0xB0 | ch;
key.as_int(), events.push(MidiEvent::new(timestamp, status, controller.as_int(), value.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
} }
} }
_ => {} // Tempo and other meta events don't affect beat positions
} }
} }
} }
// Calculate final clip duration let duration = Beats(max_tick as f64 / ticks_per_beat);
let final_delta_ticks = max_time_ticks - last_tick; let clip = MidiClip::new(clip_id, events, duration, "Imported MIDI".to_string());
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());
Ok(clip) Ok(clip)
} }

View File

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

View File

@ -0,0 +1,332 @@
//! 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.
//!
//! # Interpolation
//! Each `TempoEntry` has an `interpolation` field that controls how the BPM
//! changes between that entry and the next:
//! - `Step`: BPM is constant from this entry's beat until the next entry. Instant change.
//! - `Linear`: BPM linearly interpolates from this entry's BPM to the next entry's BPM
//! over the beat range. The seconds calculation uses the exact integral:
//! `Δt = (60 / slope) * ln(bpm_end / bpm_start)` where slope = (bpm_end - bpm_start) / span_beats.
//!
//! # Format
//! `entries` is a sorted `Vec<TempoEntry>` where 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};
/// How the BPM transitions from one `TempoEntry` to the next.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
pub enum TempoInterpolation {
/// BPM stays constant from this entry's beat until the next entry (instant change).
#[default]
Step,
/// BPM linearly interpolates from this entry's BPM to the next entry's BPM
/// over the beat span between the two entries.
Linear,
}
/// A single tempo segment: from `beat` onwards the tempo changes according to `interpolation`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TempoEntry {
/// Start of this tempo segment in beats (quarter-note beats).
pub beat: f64,
/// Tempo at the start of this segment in beats per minute.
pub bpm: f64,
/// Cumulative seconds elapsed at the start of this segment.
/// **Derived** — not serialised; call [`TempoMap::rebuild_seconds`] after any
/// mutation or after deserialization.
#[serde(skip, default)]
pub seconds: f64,
/// How the BPM transitions from this entry to the next.
#[serde(default)]
pub interpolation: TempoInterpolation,
}
/// A piecewise 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)
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Seconds elapsed traversing `span_beats` starting at `bpm_start`.
/// If `bpm_end` is `Some` (linear segment) and differs from `bpm_start`,
/// uses the exact logarithmic integral.
#[inline]
fn segment_duration(span_beats: f64, bpm_start: f64, bpm_end: Option<f64>) -> f64 {
match bpm_end {
None => span_beats * 60.0 / bpm_start,
Some(b1) if (b1 - bpm_start).abs() < 1e-9 => span_beats * 60.0 / bpm_start,
Some(b1) => {
// Linear BPM: BPM(b) = bpm_start + slope * (b - b_start)
// Δt = ∫₀^span 60 / BPM(b) db = (60/slope) * ln(b1/bpm_start)
let slope = (b1 - bpm_start) / span_beats;
(60.0 / slope) * (b1 / bpm_start).ln()
}
}
}
/// Beats elapsed given `delta_seconds` starting at `bpm_start`.
/// If `bpm_end` is `Some` (linear segment) and differs from `bpm_start`,
/// uses the exact exponential inverse.
#[inline]
fn segment_beats(delta_seconds: f64, span_beats: f64, bpm_start: f64, bpm_end: Option<f64>) -> f64 {
match bpm_end {
None => delta_seconds * bpm_start / 60.0,
Some(b1) if (b1 - bpm_start).abs() < 1e-9 => delta_seconds * bpm_start / 60.0,
Some(b1) => {
// Inverse of the logarithmic integral:
// b = b_start + (bpm_start / slope) * (exp(delta_t * slope / 60) - 1)
let slope = (b1 - bpm_start) / span_beats;
(bpm_start / slope) * ((delta_seconds * slope / 60.0).exp() - 1.0)
}
}
}
impl TempoMap {
/// Create a constant-tempo map.
pub fn constant(bpm: f64) -> Self {
Self {
entries: vec![TempoEntry { beat: 0.0, bpm, seconds: 0.0, interpolation: TempoInterpolation::Step }],
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;
let n = self.entries.len();
for i in 0..n {
self.entries[i].seconds = cumulative;
if i + 1 < n {
let span = self.entries[i + 1].beat - self.entries[i].beat;
let bpm_end = if self.entries[i].interpolation == TempoInterpolation::Linear {
Some(self.entries[i + 1].bpm)
} else {
None
};
cumulative += segment_duration(span, self.entries[i].bpm, bpm_end);
}
}
self.last_index.store(0, Ordering::Relaxed);
}
/// Return the instantaneous BPM active at `beat`.
/// For linear segments, returns the interpolated value at that beat.
pub fn bpm_at(&self, beat: Beats) -> f64 {
let n = self.entries.len();
let idx = self.entries.partition_point(|e| e.beat <= beat.0).saturating_sub(1);
let idx = idx.min(n - 1);
let entry = &self.entries[idx];
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
let next = &self.entries[idx + 1];
let t = (beat.0 - entry.beat) / (next.beat - entry.beat);
entry.bpm + (next.bpm - entry.bpm) * t
} else {
entry.bpm
}
}
/// Convert beats to seconds using the tempo map.
///
/// 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 {
Seconds(self.transform(beat.0))
}
/// Convert seconds to beats using binary search on the cached `seconds` offsets.
pub fn seconds_to_beats(&self, seconds: Seconds) -> Beats {
Beats(self.inverse_transform(seconds.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.
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];
let beat_in_seg = beat - entry.beat;
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
let next = &self.entries[idx + 1];
let span = next.beat - entry.beat;
entry.seconds + segment_duration(beat_in_seg, entry.bpm, Some(entry.bpm + (next.bpm - entry.bpm) * beat_in_seg / span))
} else {
entry.seconds + beat_in_seg * 60.0 / entry.bpm
}
}
/// Inverse of [`transform`]: convert parent time units back to local beats.
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];
let delta_t = parent_time - entry.seconds;
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
let next = &self.entries[idx + 1];
let span = next.beat - entry.beat;
entry.beat + segment_beats(delta_t, span, entry.bpm, Some(next.bpm))
} else {
entry.beat + delta_t * entry.bpm / 60.0
}
}
/// Build a `TempoMap` from a list of `(beat, bpm)` keyframes (step interpolation).
/// Always inserts a beat-0 entry using the first keyframe's BPM (or 120.0 if empty).
pub fn from_keyframes(keyframes: &[(f64, f64)]) -> Self {
if keyframes.is_empty() {
return Self::constant(120.0);
}
let mut entries: Vec<TempoEntry> = keyframes
.iter()
.map(|&(beat, bpm)| TempoEntry { beat, bpm, seconds: 0.0, interpolation: TempoInterpolation::Step })
.collect();
entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap());
if entries[0].beat > 0.0 {
entries.insert(0, TempoEntry { beat: 0.0, bpm: entries[0].bpm, seconds: 0.0, interpolation: TempoInterpolation::Step });
}
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.
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_step() {
let m = TempoMap::from_keyframes(&[(0.0, 120.0), (4.0, 60.0)]);
// Beat 0-4: 120 BPM → 4 beats = 2 seconds
assert!((m.beats_to_seconds(Beats(4.0)).0 - 2.0).abs() < 1e-9, "got {}", m.beats_to_seconds(Beats(4.0)).0);
// Beat 4-5: 60 BPM → 1 beat = 1 second
assert!((m.beats_to_seconds(Beats(5.0)).0 - 3.0).abs() < 1e-9);
assert!((m.seconds_to_beats(Seconds(3.0)).0 - 5.0).abs() < 1e-9);
}
#[test]
fn linear_interpolation_round_trip() {
// 120→240 BPM over 4 beats: slope = (240-120)/4 = 30 BPM/beat
// Δt = (60/30) * ln(240/120) = 2 * ln(2) ≈ 1.386s for beats 0-4
let mut m = TempoMap::constant(120.0);
m.entries.push(TempoEntry { beat: 4.0, bpm: 240.0, seconds: 0.0, interpolation: TempoInterpolation::Step });
m.entries[0].interpolation = TempoInterpolation::Linear;
m.rebuild_seconds();
let expected = 2.0 * std::f64::consts::LN_2;
let got = m.beats_to_seconds(Beats(4.0)).0;
assert!((got - expected).abs() < 1e-9, "got {got}, expected {expected}");
// Round-trip
let beats_back = m.seconds_to_beats(Seconds(expected)).0;
assert!((beats_back - 4.0).abs() < 1e-9, "round-trip got {beats_back}");
}
#[test]
fn stack_composition() {
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);
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 { for event in &midi_clip.events {
let status = event.status & 0xF0; 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 { match status {
0x90 if event.data2 > 0 => { 0x90 if event.data2 > 0 => {
@ -878,7 +878,7 @@ fn execute_command(
} }
// Add to local UI state with note data // 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; app.next_clip_id += 1;
// Send to audio engine with the start_time (clip content is separate from timeline position) // 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 { if let Some(valid_start) = adjusted_start {
// Update instance to use the valid position // Update instance to use the valid position
self.clip_instance.timeline_start = valid_start; self.clip_instance.timeline_start = valid_start;
let (bpm, fps) = (document.bpm, document.framerate);
self.clip_instance.sync_from_seconds(bpm, fps);
} else { } else {
// No valid position found - reject the operation // No valid position found - reject the operation
return Err("Cannot add clip: no valid position found on layer (layer is full)".to_string()); 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( let instance = daw_backend::MidiClipInstance::new(
0, // Instance ID will be assigned by backend 0, // Instance ID will be assigned by backend
*midi_clip_id, *midi_clip_id,
internal_start, daw_backend::Beats(internal_start),
internal_end, daw_backend::Beats(internal_end),
external_start, daw_backend::Beats(external_start),
external_duration, daw_backend::Beats(external_duration),
); );
// Send query to add instance and get instance ID // Send query to add instance and get instance ID

View File

@ -1,248 +1,47 @@
//! Change BPM action //! Change BPM / Tempo action
//! //!
//! Atomically changes the document BPM and rescales all clip instance positions and //! Atomically swaps the document `TempoMap`, sending the new map to the engine.
//! MIDI event timestamps so that beat positions are preserved (Measures mode behaviour). //! 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::action::{Action, BackendContext};
use crate::clip::ClipInstance;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::tempo_map::TempoMap;
use std::collections::HashMap;
use uuid::Uuid;
/// Snapshot of all timing fields on a `ClipInstance` /// Action that changes the global BPM by replacing the tempo map.
#[derive(Clone)] #[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 { pub struct ChangeBpmAction {
old_bpm: f64, old_map: TempoMap,
new_bpm: f64, new_map: TempoMap,
time_sig: (u32, u32),
clip_snapshots: Vec<ClipTimingSnapshot>,
midi_snapshots: Vec<MidiClipSnapshot>,
} }
impl ChangeBpmAction { impl ChangeBpmAction {
/// Build the action, computing new positions for all clip instances and MIDI events. /// Build the action from the current document state and a desired new BPM.
/// pub fn new(new_bpm: f64, document: &Document) -> Self {
/// `midi_event_cache` maps backend MIDI clip ID → current event list. let old_map = document.tempo_map().clone();
pub fn new( let mut new_map = old_map.clone();
old_bpm: f64, new_map.set_global_bpm(new_bpm);
new_bpm: f64, Self { old_map, new_map }
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,
}
} }
/// Return the new MIDI event lists for each affected clip (for immediate cache update). /// Build from explicit old/new maps (used when the map already changed in-place,
pub fn new_midi_events(&self) -> impl Iterator<Item = (u32, &Vec<daw_backend::audio::midi::MidiEvent>)> { /// e.g., after live drag preview; the caller provides start and end states).
self.midi_snapshots.iter().map(|s| (s.midi_clip_id, &s.new_events)) pub fn from_maps(old_map: TempoMap, new_map: TempoMap) -> Self {
Self { old_map, new_map }
} }
fn apply_clips(document: &mut Document, snapshots: &[ClipTimingSnapshot], use_new: bool) { pub fn new_bpm(&self) -> f64 { self.new_map.global_bpm() }
for snap in snapshots { pub fn old_bpm(&self) -> f64 { self.old_map.global_bpm() }
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 };
}
}
}
} }
impl Action for ChangeBpmAction { impl Action for ChangeBpmAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
document.bpm = self.new_bpm; *document.tempo_map_mut() = self.new_map.clone();
Self::apply_clips(document, &self.clip_snapshots, true);
Self::apply_midi_durations(document, &self.midi_snapshots, true);
Ok(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
document.bpm = self.old_bpm; *document.tempo_map_mut() = self.old_map.clone();
Self::apply_clips(document, &self.clip_snapshots, false);
Self::apply_midi_durations(document, &self.midi_snapshots, false);
Ok(()) Ok(())
} }
@ -253,109 +52,26 @@ impl Action for ChangeBpmAction {
fn execute_backend( fn execute_backend(
&mut self, &mut self,
backend: &mut BackendContext, backend: &mut BackendContext,
document: &Document, _document: &Document,
) -> Result<(), String> { ) -> Result<(), String> {
let controller = match backend.audio_controller.as_mut() { let controller = match backend.audio_controller.as_mut() {
Some(c) => c, Some(c) => c,
None => return Ok(()), None => return Ok(()),
}; };
controller.set_tempo_map(self.new_map.clone());
// 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 and rescale MIDI clip durations 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.new_bpm, fps, midi_durations);
Ok(()) Ok(())
} }
fn rollback_backend( fn rollback_backend(
&mut self, &mut self,
backend: &mut BackendContext, backend: &mut BackendContext,
document: &Document, _document: &Document,
) -> Result<(), String> { ) -> Result<(), String> {
let controller = match backend.audio_controller.as_mut() { let controller = match backend.audio_controller.as_mut() {
Some(c) => c, Some(c) => c,
None => return Ok(()), None => return Ok(()),
}; };
controller.set_tempo_map(self.old_map.clone());
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 and restore MIDI clip durations 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.old_bpm, fps, midi_durations);
Ok(()) 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 //! Change FPS action
//! //!
//! Atomically changes the document framerate and rescales all clip instance positions //! Atomically changes the document framerate.
//! so that frame positions are preserved (Frames mode behaviour). //! 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::action::{Action, BackendContext};
use crate::clip::ClipInstance;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
/// Snapshot of all timing fields on a `ClipInstance` /// Action that changes the document framerate.
#[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
pub struct ChangeFpsAction { pub struct ChangeFpsAction {
old_fps: f64, old_fps: f64,
new_fps: f64, new_fps: f64,
clip_snapshots: Vec<ClipTimingSnapshot>,
} }
impl ChangeFpsAction { impl ChangeFpsAction {
/// Build the action, computing new positions for all clip instances. /// Build the action from old and new framerates.
pub fn new(old_fps: f64, new_fps: f64, document: &Document) -> Self { pub fn new(old_fps: f64, new_fps: f64, _document: &Document) -> Self {
let bpm = document.bpm; Self { old_fps, new_fps }
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);
}
}
} }
} }
impl Action for ChangeFpsAction { impl Action for ChangeFpsAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
document.framerate = self.new_fps; document.framerate = self.new_fps;
Self::apply_clips(document, &self.clip_snapshots, true);
Ok(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
document.framerate = self.old_fps; document.framerate = self.old_fps;
Self::apply_clips(document, &self.clip_snapshots, false);
Ok(()) Ok(())
} }
@ -160,68 +37,18 @@ impl Action for ChangeFpsAction {
fn execute_backend( fn execute_backend(
&mut self, &mut self,
backend: &mut BackendContext, _backend: &mut BackendContext,
_document: &Document, _document: &Document,
) -> Result<(), String> { ) -> Result<(), String> {
// FPS change does not affect audio timing — only move clips that changed position // FPS does not affect audio scheduling — nothing to do in the backend.
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 => {}
}
}
Ok(()) Ok(())
} }
fn rollback_backend( fn rollback_backend(
&mut self, &mut self,
backend: &mut BackendContext, _backend: &mut BackendContext,
_document: &Document, _document: &Document,
) -> Result<(), String> { ) -> 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(()) Ok(())
} }
} }

View File

@ -119,9 +119,6 @@ impl Action for MoveClipInstancesAction {
// Store adjusted moves for rollback // Store adjusted moves for rollback
self.layer_moves = adjusted_moves.clone(); self.layer_moves = adjusted_moves.clone();
let bpm = document.bpm;
let fps = document.framerate;
// Apply all adjusted moves // Apply all adjusted moves
for (layer_id, moves) in &adjusted_moves { for (layer_id, moves) in &adjusted_moves {
let layer = document.get_layer_mut(layer_id) 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) if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
{ {
clip_instance.timeline_start = *new; 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> { 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 { for (layer_id, moves) in &self.layer_moves {
let layer = document.get_layer_mut(layer_id) let layer = document.get_layer_mut(layer_id)
.ok_or_else(|| format!("Layer {} not found", 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) if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
{ {
clip_instance.timeline_start = *old; 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( let midi_instance = daw_backend::MidiClipInstance::new(
0, 0,
*midi_clip_id, *midi_clip_id,
internal_start, daw_backend::Beats(internal_start),
internal_end, daw_backend::Beats(internal_end),
external_start, daw_backend::Beats(external_start),
external_duration, daw_backend::Beats(external_duration),
); );
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance); let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);

View File

@ -126,8 +126,8 @@ impl Action for SplitClipInstanceAction {
.get_clip_duration(&instance.clip_id) .get_clip_duration(&instance.clip_id)
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
// Calculate the effective duration and timeline end // Calculate the effective duration and timeline end (both in beats)
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; let timeline_end = instance.timeline_start + effective_duration;
// Validate: split_time must be strictly within the clip's timeline span // 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); 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 // Now modify the original (left) instance and add the new (right) instance
let layer_mut = document 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; self.executed = true;
Ok(()) Ok(())
} }
@ -406,10 +390,10 @@ impl Action for SplitClipInstanceAction {
let instance = daw_backend::MidiClipInstance::new( let instance = daw_backend::MidiClipInstance::new(
0, 0,
*midi_clip_id, *midi_clip_id,
internal_start, daw_backend::Beats(internal_start),
internal_end, daw_backend::Beats(internal_end),
external_start, daw_backend::Beats(external_start),
external_duration, daw_backend::Beats(external_duration),
); );
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance); 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::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
}; };
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
let member_old_trim = instance.trim_start; let member_old_trim = instance.trim_start;
@ -138,7 +138,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
}; };
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
let member_old_trim_end = instance.trim_end; let member_old_trim_end = instance.trim_end;
@ -182,7 +182,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
}; };
let instance = clip_instances.iter() let instance = clip_instances.iter()
.find(|ci| &ci.id == instance_id) .find(|ci| &ci.id == instance_id)
@ -260,9 +260,6 @@ impl Action for TrimClipInstancesAction {
// Store clamped trims for rollback // Store clamped trims for rollback
self.layer_trims = clamped_trims.clone(); self.layer_trims = clamped_trims.clone();
let bpm = document.bpm;
let fps = document.framerate;
// Apply all clamped trims // Apply all clamped trims
for (layer_id, trims) in &clamped_trims { for (layer_id, trims) in &clamped_trims {
let layer = match document.get_layer_mut(layer_id) { 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.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> { 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 { for (layer_id, trims) in &self.layer_trims {
let layer = match document.get_layer_mut(layer_id) { let layer = match document.get_layer_mut(layer_id) {
Some(l) => l, Some(l) => l,
@ -340,7 +334,6 @@ impl Action for TrimClipInstancesAction {
clip_instance.trim_end = old.trim_value; 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 //! Beat/measure ↔ seconds conversion utilities
use crate::document::TimeSignature; use crate::document::TimeSignature;
use crate::tempo_map::TempoMap;
/// Position expressed as measure, beat, tick /// Position expressed as measure, beat, tick
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@ -11,9 +12,8 @@ pub struct MeasurePosition {
} }
/// Convert a time in seconds to a measure position /// Convert a time in seconds to a measure position
pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> MeasurePosition { pub fn time_to_measure(time: f64, tempo_map: &TempoMap, time_sig: &TimeSignature) -> MeasurePosition {
let beats_per_second = bpm / 60.0; let total_beats = tempo_map.inverse_transform(time).max(0.0);
let total_beats = (time * beats_per_second).max(0.0);
let beats_per_measure = time_sig.numerator as f64; let beats_per_measure = time_sig.numerator as f64;
let measure = (total_beats / beats_per_measure).floor() as u32 + 1; 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 /// 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 beats_per_measure = time_sig.numerator as f64;
let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure
+ (pos.beat as f64 - 1.0) + (pos.beat as f64 - 1.0)
+ (pos.tick as f64 / 1000.0); + (pos.tick as f64 / 1000.0);
let beats_per_second = bpm / 60.0; tempo_map.transform(total_beats)
total_beats / beats_per_second
} }
/// Get the duration of one beat in seconds /// Get the duration of one beat in seconds at the given beat position
pub fn beat_duration(bpm: f64) -> f64 { pub fn beat_duration(beat: f64, tempo_map: &TempoMap) -> f64 {
60.0 / bpm 60.0 / tempo_map.bpm_at(daw_backend::Beats(beat))
} }
/// Get the duration of one measure in seconds /// Get the duration of one measure in seconds at the given beat position
pub fn measure_duration(bpm: f64, time_sig: &TimeSignature) -> f64 { pub fn measure_duration(beat: f64, tempo_map: &TempoMap, time_sig: &TimeSignature) -> f64 {
beat_duration(bpm) * time_sig.numerator as 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. /// The `clip_duration_fn` resolves referenced clip durations for non-vector layers.
/// Falls back to the stored `duration` field if no content exists. /// Falls back to the stored `duration` field if no content exists.
pub fn content_duration(&self, framerate: f64) -> f64 { pub fn content_duration(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
self.content_duration_with(framerate, |_| None) self.content_duration_with(framerate, tempo_map, |_| None)
} }
/// Like `content_duration`, but with a closure that resolves clip durations /// Like `content_duration`, but with a closure that resolves clip durations
/// for audio/video/effect clip instances inside this movie clip. /// 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 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() { for layer_node in self.layers.iter() {
// Check clip instances on ALL layer types (vector, audio, video, effect) // Check clip instances on ALL layer types (vector, audio, video, effect)
@ -119,33 +124,38 @@ impl VectorClip {
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
}; };
for ci in clip_instances { for ci in clip_instances {
let end = if let Some(td) = ci.timeline_duration { // Compute end position of this clip instance in beats
ci.timeline_start + td let end_beats = if let Some(td_beats) = ci.timeline_duration {
ci.timeline_start + td_beats
} else if let Some(te) = ci.trim_end { } else if let Some(te) = ci.trim_end {
ci.timeline_start + (te - ci.trim_start).max(0.0) let secs = (te - ci.trim_start).max(0.0);
} else if let Some(clip_dur) = clip_duration_fn(&ci.clip_id) { ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start
ci.timeline_start + (clip_dur - ci.trim_start).max(0.0) } 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 { } else {
continue; continue;
}; };
last_time = Some(match last_time { last_beats = Some(last_beats.map_or(end_beats, |t: f64| t.max(end_beats)));
Some(t) => t.max(end),
None => end,
});
} }
// Also check vector layer keyframes // Vector layer keyframes are in seconds
if let AnyLayer::Vector(vector_layer) = &layer_node.data { if let AnyLayer::Vector(vector_layer) = &layer_node.data {
if let Some(last_kf) = vector_layer.keyframes.last() { if let Some(last_kf) = vector_layer.keyframes.last() {
last_time = Some(match last_time { last_secs = Some(last_secs.map_or(last_kf.time, |t: f64| t.max(last_kf.time)));
Some(t) => t.max(last_kf.time),
None => 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, Some(t) => t + frame_duration,
None => self.duration, None => self.duration,
} }
@ -186,9 +196,10 @@ impl VectorClip {
// Handle nested clip instances recursively // Handle nested clip instances recursively
for clip_instance in &vector_layer.clip_instances { for clip_instance in &vector_layer.clip_instances {
// Convert parent clip time to nested clip local time // Convert parent clip time (seconds) to nested clip local time (seconds).
// Apply timeline offset and playback speed, then add trim offset // timeline_start is in beats; convert to seconds using document BPM.
let nested_clip_time = ((clip_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start; 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 // Look up the nested clip definition
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) { 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( pub fn new_midi(
name: impl Into<String>, name: impl Into<String>,
midi_clip_id: u32, midi_clip_id: u32,
duration: f64, duration: daw_backend::Beats,
) -> Self { ) -> Self {
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
name: name.into(), name: name.into(),
duration, duration: duration.beats_to_f64(),
clip_type: AudioClipType::Midi { midi_clip_id }, clip_type: AudioClipType::Midi { midi_clip_id },
folder_id: None, folder_id: None,
} }
@ -589,6 +600,12 @@ impl AnyClip {
/// - Timeline placement (when this instance appears on the parent layer's timeline) /// - Timeline placement (when this instance appears on the parent layer's timeline)
/// - Trimming (trim_start, trim_end within the clip's internal content) /// - Trimming (trim_start, trim_end within the clip's internal content)
/// - Playback speed (time remapping) /// - 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)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ClipInstance { pub struct ClipInstance {
/// Unique identifier for this instance /// Unique identifier for this instance
@ -606,52 +623,25 @@ pub struct ClipInstance {
/// Optional name for this instance /// Optional name for this instance
pub name: Option<String>, pub name: Option<String>,
/// When this instance starts on the timeline (in seconds, relative to parent layer) /// When this instance starts on the timeline, in **beats**.
/// This is the external positioning - where the instance appears on the timeline /// Default: 0.0
/// Default: 0.0 (start at beginning of layer)
pub timeline_start: f64, 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) /// How long this instance appears on the timeline, in **beats**.
/// If timeline_duration > (trim_end - trim_start), the trimmed content will loop /// If set and longer than the trimmed content, the content will loop.
/// Default: None (use trimmed clip duration, no looping) /// Default: None (use trimmed clip duration, no looping)
pub timeline_duration: Option<f64>, 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) /// Trim start: offset into the clip's internal content, in **seconds**.
/// Allows trimming the beginning of the clip /// - For audio: byte-offset into the audio file
/// - For audio: offset into the audio file /// - For video: seek position in the video file
/// - For video: offset into the video file /// - For vector: time offset into the animation
/// - For vector: offset into the animation timeline /// Default: 0.0
/// Default: 0.0 (start at beginning of clip)
pub trim_start: f64, 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) /// Trim end: offset into the clip's internal content, in **seconds**.
/// Allows trimming the end of the clip
/// Default: None (use full clip duration) /// Default: None (use full clip duration)
pub trim_end: Option<f64>, 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 /// Playback speed multiplier
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
@ -663,7 +653,7 @@ pub struct ClipInstance {
/// Default: 1.0 /// Default: 1.0
pub gain: f32, 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. /// When set, loop iterations are drawn/played before the content start.
/// Default: None (no pre-loop) /// Default: None (no pre-loop)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -720,17 +710,9 @@ impl ClipInstance {
opacity: 1.0, opacity: 1.0,
name: None, name: None,
timeline_start: 0.0, timeline_start: 0.0,
timeline_start_beats: 0.0,
timeline_start_frames: 0.0,
timeline_duration: None, timeline_duration: None,
timeline_duration_beats: None,
timeline_duration_frames: None,
trim_start: 0.0, trim_start: 0.0,
trim_start_beats: 0.0,
trim_start_frames: 0.0,
trim_end: None, trim_end: None,
trim_end_beats: None,
trim_end_frames: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None, loop_before: None,
@ -746,71 +728,15 @@ impl ClipInstance {
opacity: 1.0, opacity: 1.0,
name: None, name: None,
timeline_start: 0.0, timeline_start: 0.0,
timeline_start_beats: 0.0,
timeline_start_frames: 0.0,
timeline_duration: None, timeline_duration: None,
timeline_duration_beats: None,
timeline_duration_frames: None,
trim_start: 0.0, trim_start: 0.0,
trim_start_beats: 0.0,
trim_start_frames: 0.0,
trim_end: None, trim_end: None,
trim_end_beats: None,
trim_end_frames: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None, 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 /// Set the transform
pub fn with_transform(mut self, transform: Transform) -> Self { pub fn with_transform(mut self, transform: Transform) -> Self {
self.transform = transform; self.transform = transform;
@ -861,83 +787,83 @@ impl ClipInstance {
self self
} }
/// Set explicit timeline duration by setting trim_end /// Set explicit timeline duration (in beats) by directly setting `timeline_duration`.
/// pub fn with_timeline_duration(mut self, duration_beats: f64) -> Self {
/// For effect instances, this effectively sets the duration since self.timeline_duration = Some(duration_beats);
/// 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);
self self
} }
/// Get the effective duration of this instance (accounting for trimming and looping) /// Content window size in seconds: `trim_end - trim_start`.
/// If timeline_duration is set, returns that (enabling content looping) /// Used for internal looping calculations.
/// Otherwise returns the trimmed content duration pub fn content_window_secs(&self, clip_duration_secs: f64) -> f64 {
pub fn effective_duration(&self, clip_duration: f64) -> f64 { let end = self.trim_end.unwrap_or(clip_duration_secs);
// 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);
(end - self.trim_start).max(0.0) (end - self.trim_start).max(0.0)
} }
/// Get the effective start position on the timeline, accounting for loop_before. /// How long this instance appears on the timeline, in **beats**.
/// This is the left edge of the clip's visual extent. ///
/// 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 { pub fn effective_start(&self) -> f64 {
self.timeline_start - self.loop_before.unwrap_or(0.0) self.timeline_start - self.loop_before.unwrap_or(0.0)
} }
/// Get the total visual duration including both loop_before and effective_duration. /// Total visual duration (loop_before + effective_duration), in **beats**.
pub fn total_duration(&self, clip_duration: f64) -> f64 { 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(clip_duration) 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 /// Returns `None` if the clip instance is not active at `time_secs`.
/// clip's content, accounting for: pub fn remap_time_secs(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option<f64> {
/// - Instance position (timeline_start) let start_secs = tempo_map.transform(self.timeline_start);
/// - Playback speed let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map);
/// - Trimming (trim_start, trim_end) let end_secs = tempo_map.transform(self.timeline_start + dur_beats);
/// - Looping (if timeline_duration > content window)
/// if time_secs < start_secs || time_secs >= end_secs {
/// 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 {
return None; return None;
} }
// Calculate relative time within the instance (0.0 = start of instance) let relative_secs = time_secs - start_secs;
let relative_time = timeline_time - self.timeline_start; 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 { if content_window == 0.0 {
return Some(self.trim_start); return Some(self.trim_start);
} }
// Apply looping if content exceeds the window
let looped_time = if content_time > content_window { let looped_time = if content_time > content_window {
content_time % content_window content_time % content_window
} else { } else {
content_time content_time
}; };
// Add trim_start offset to get final clip time
Some(self.trim_start + looped_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 /// Convert to affine transform
pub fn to_affine(&self) -> vello::kurbo::Affine { pub fn to_affine(&self) -> vello::kurbo::Affine {
self.transform.to_affine() self.transform.to_affine()

View File

@ -6,10 +6,11 @@
use crate::asset_folder::AssetFolderTree; use crate::asset_folder::AssetFolderTree;
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip}; use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
use crate::effect::EffectDefinition; use crate::effect::EffectDefinition;
use crate::layer::AnyLayer; use crate::layer::{AnyLayer, GroupLayer};
use crate::script::ScriptDefinition; use crate::script::ScriptDefinition;
use crate::layout::LayoutNode; use crate::layout::LayoutNode;
use crate::shape::ShapeColor; use crate::shape::ShapeColor;
use crate::tempo_map::TempoMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; 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 } fn default_bpm() -> f64 { 120.0 }
/// How time is displayed in the timeline /// How time is displayed in the timeline
@ -173,14 +176,16 @@ pub struct Document {
/// Framerate (frames per second) /// Framerate (frames per second)
pub framerate: f64, pub framerate: f64,
/// Tempo in beats per minute
#[serde(default = "default_bpm")]
pub bpm: f64,
/// Time signature /// Time signature
#[serde(default)] #[serde(default)]
pub time_signature: TimeSignature, 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 /// Duration in seconds
pub duration: f64, pub duration: f64,
@ -266,8 +271,12 @@ impl Default for Document {
width: 1920.0, width: 1920.0,
height: 1080.0, height: 1080.0,
framerate: 60.0, framerate: 60.0,
bpm: 120.0,
time_signature: TimeSignature::default(), 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, duration: 10.0,
root: GraphicsObject::default(), root: GraphicsObject::default(),
vector_clips: HashMap::new(), 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. /// Rebuild the layer→clip reverse lookup map from all vector clips.
/// Call after deserialization or bulk clip modifications. /// Call after deserialization or bulk clip modifications.
pub fn rebuild_layer_to_clip_map(&mut self) { pub fn rebuild_layer_to_clip_map(&mut self) {
@ -619,56 +650,6 @@ impl Document {
layers 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 === // === CLIP LIBRARY METHODS ===
/// Add a vector clip to the library /// Add a vector clip to the library
@ -866,11 +847,12 @@ impl Document {
if clip.is_group { if clip.is_group {
Some(clip.duration) Some(clip.duration)
} else { } 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) // Resolve nested clip durations (audio, video, other vector clips)
if let Some(vc) = self.vector_clips.get(id) { if let Some(vc) = self.vector_clips.get(id) {
// Avoid deep recursion — use stored duration for nested vector clips // 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) { } else if let Some(ac) = self.audio_clips.get(id) {
Some(ac.duration) Some(ac.duration)
} else if let Some(vc) = self.video_clips.get(id) { } else if let Some(vc) = self.video_clips.get(id) {
@ -961,7 +943,7 @@ impl Document {
}; };
let instance_start = instance.effective_start(); 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 // Check overlap: start_a < end_b AND start_b < end_a
if start_time < instance_end && instance_start < end_time { 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) { if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
let inst_start = instance.effective_start(); 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)); 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) { if let Some(dur) = self.get_clip_duration(&inst.clip_id) {
let start = inst.effective_start(); 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)); non_group.push((start, end));
} }
} }
@ -1184,7 +1166,7 @@ impl Document {
// Calculate other clip's extent (accounting for loop_before) // Calculate other clip's extent (accounting for loop_before)
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { if let Some(clip_duration) = self.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 this clip is to the left and closer than current nearest
if other_end <= current_timeline_start && other_end > nearest_end { 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) { 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 { if other_end <= current_effective_start && other_end > nearest_end {
nearest_end = other_end; nearest_end = other_end;

View File

@ -142,16 +142,18 @@ impl EffectLayer {
self.clip_instances.iter_mut().find(|e| &e.id == id) 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. /// `time_secs` is the playback time in seconds; `bpm` is used to convert
pub fn active_clip_instances_at(&self, time: f64) -> Vec<&ClipInstance> { /// `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; use crate::effect::EFFECT_DURATION;
let time_beats = tempo_map.inverse_transform(time_secs);
self.clip_instances self.clip_instances
.iter() .iter()
.filter(|e| { .filter(|e| {
let end = e.timeline_start + e.effective_duration(EFFECT_DURATION); let end = e.timeline_start + e.effective_duration(EFFECT_DURATION, tempo_map);
time >= e.timeline_start && time < end time_beats >= e.timeline_start && time_beats < end
}) })
.collect() .collect()
} }
@ -239,13 +241,13 @@ mod tests {
layer.add_clip_instance(effect2); layer.add_clip_instance(effect2);
// At time 2: only effect1 active // 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 // 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 // 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] #[test]

View File

@ -76,6 +76,9 @@ pub struct AudioExportSettings {
/// End time in seconds /// End time in seconds
pub end_time: f64, pub end_time: f64,
/// Project BPM (for beat-position scheduling during export)
pub bpm: f64,
} }
impl Default for AudioExportSettings { impl Default for AudioExportSettings {
@ -88,6 +91,7 @@ impl Default for AudioExportSettings {
bitrate_kbps: 320, bitrate_kbps: 320,
start_time: 0.0, start_time: 0.0,
end_time: 60.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 // 5. Extract document and audio backend state
let step5_start = std::time::Instant::now(); let step5_start = std::time::Instant::now();
let mut document = beam_project.ui_state; 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 mut audio_project = beam_project.audio_backend.project;
let audio_pool_entries = beam_project.audio_backend.audio_pool_entries; let audio_pool_entries = beam_project.audio_backend.audio_pool_entries;
let layer_to_track_map = beam_project.audio_backend.layer_to_track_map; 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, parent_transform: Affine,
timeline_time: f64, timeline_time: f64,
) -> Option<Uuid> { ) -> Option<Uuid> {
let tempo_map = document.tempo_map();
for clip_instance in clip_instances.iter().rev() { for clip_instance in clip_instances.iter().rev() {
// Check time bounds: skip clip instances not active at this time // 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 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); let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end { let timeline_beats = tempo_map.inverse_transform(timeline_time);
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
continue; 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) { let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
vector_clip.calculate_content_bounds(document, clip_time) vector_clip.calculate_content_bounds(document, clip_time)
@ -294,16 +299,20 @@ pub fn hit_test_clip_instances_in_rect(
timeline_time: f64, timeline_time: f64,
) -> Vec<Uuid> { ) -> Vec<Uuid> {
let mut hits = Vec::new(); let mut hits = Vec::new();
let tempo_map = document.tempo_map();
for clip_instance in clip_instances { for clip_instance in clip_instances {
// Check time bounds: skip clip instances not active at this time // 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 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); let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end { let timeline_beats = tempo_map.inverse_transform(timeline_time);
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
continue; 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) { let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
vector_clip.calculate_content_bounds(document, clip_time) vector_clip.calculate_content_bounds(document, clip_time)

View File

@ -3,6 +3,7 @@
//! Layers organize objects and shapes, and contain animation data. //! Layers organize objects and shapes, and contain animation data.
use crate::animation::AnimationData; use crate::animation::AnimationData;
use crate::tempo_map::TempoMap;
use crate::clip::ClipInstance; use crate::clip::ClipInstance;
use crate::vector_graph::VectorGraph; use crate::vector_graph::VectorGraph;
use crate::effect_layer::EffectLayer; use crate::effect_layer::EffectLayer;
@ -720,6 +721,12 @@ pub struct GroupLayer {
/// Whether the group is expanded in the timeline /// Whether the group is expanded in the timeline
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub expanded: bool, 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 { fn default_true() -> bool {
@ -746,6 +753,12 @@ impl LayerTrait for GroupLayer {
fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; } fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; }
} }
impl Default for GroupLayer {
fn default() -> Self {
Self::new("Group")
}
}
impl GroupLayer { impl GroupLayer {
/// Create a new group layer /// Create a new group layer
pub fn new(name: impl Into<String>) -> Self { pub fn new(name: impl Into<String>) -> Self {
@ -753,6 +766,21 @@ impl GroupLayer {
layer: Layer::new(LayerType::Group, name), layer: Layer::new(LayerType::Group, name),
children: Vec::new(), children: Vec::new(),
expanded: true, 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: false, // collapsed so it always renders a visible header row
tempo_map: Some(TempoMap::constant(bpm)),
} }
} }
@ -773,7 +801,7 @@ impl GroupLayer {
AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Effect(l) => &l.clip_instances,
AnyLayer::Group(_) => &[], // no nested groups AnyLayer::Group(_) => &[], // no nested groups
AnyLayer::Raster(_) => &[], // raster layers have no clip instances AnyLayer::Raster(_) => &[], // raster layers have no clip instances
}; };
for ci in instances { for ci in instances {
result.push((child_id, ci)); result.push((child_id, ci));
} }

View File

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

View File

@ -391,9 +391,10 @@ pub fn render_layer_isolated(
let mut video_mgr = video_manager.lock().unwrap(); let mut video_mgr = video_manager.lock().unwrap();
let mut instances = Vec::new(); let mut instances = Vec::new();
let tempo_map = document.tempo_map();
for clip_instance in &video_layer.clip_instances { for clip_instance in &video_layer.clip_instances {
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; 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 }; let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time) else { continue };
// Evaluate animated transform properties. // Evaluate animated transform properties.
@ -458,8 +459,9 @@ pub fn render_layer_isolated(
AnyLayer::Effect(effect_layer) => { AnyLayer::Effect(effect_layer) => {
// Effect layers are processed during compositing, not rendered to scene // Effect layers are processed during compositing, not rendered to scene
// Return early with a dedicated effect layer type // Return early with a dedicated effect layer type
let tempo_map = document.tempo_map();
let active_effects: Vec<ClipInstance> = effect_layer let active_effects: Vec<ClipInstance> = effect_layer
.active_clip_instances_at(time) .active_clip_instances_at(time, tempo_map)
.into_iter() .into_iter()
.cloned() .cloned()
.collect(); .collect();
@ -728,16 +730,19 @@ fn render_clip_instance(
}; };
// Remap timeline time to clip's internal time // Remap timeline time to clip's internal time
let tempo_map = document.tempo_map();
let clip_time = if vector_clip.is_group { let clip_time = if vector_clip.is_group {
// Groups are static — visible from timeline_start to the next keyframe boundary // Groups are static — visible from timeline_start to the next keyframe boundary.
let end = group_end_time.unwrap_or(clip_instance.timeline_start); // timeline_start is in beats; group_end_time is in seconds (render time).
if time < clip_instance.timeline_start || time >= end { 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; return;
} }
0.0 0.0
} else { } else {
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration); 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 return; // Clip instance not active at this time
}; };
t t
@ -889,7 +894,8 @@ fn render_video_layer(
}; };
// Remap timeline time to clip's internal time // 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 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 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 clip_time = if vector_clip.is_group {
let end = group_end_time.unwrap_or(clip_instance.timeline_start); let start_secs = tempo_map.transform(clip_instance.timeline_start);
if time < clip_instance.timeline_start || time >= end { return; } let end = group_end_time.unwrap_or(start_secs);
if time < start_secs || time >= end { return; }
0.0 0.0
} else { } else {
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration); 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 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, channels: settings.channels,
bit_depth: settings.bit_depth, bit_depth: settings.bit_depth,
mp3_bitrate: 320, // Not used for WAV/FLAC mp3_bitrate: 320, // Not used for WAV/FLAC
start_time: settings.start_time, start_time: daw_backend::Seconds(settings.start_time),
end_time: settings.end_time, end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
}; };
// Use the existing DAW backend export function // Use the existing DAW backend export function
@ -105,8 +106,9 @@ fn export_audio_ffmpeg_mp3<P: AsRef<Path>>(
channels: settings.channels, channels: settings.channels,
bit_depth: 16, // Unused bit_depth: 16, // Unused
mp3_bitrate: settings.bitrate_kbps, mp3_bitrate: settings.bitrate_kbps,
start_time: settings.start_time, start_time: daw_backend::Seconds(settings.start_time),
end_time: settings.end_time, end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
}; };
// Step 1: Render audio to memory // Step 1: Render audio to memory
@ -294,8 +296,9 @@ fn export_audio_ffmpeg_aac<P: AsRef<Path>>(
channels: settings.channels, channels: settings.channels,
bit_depth: 16, // Unused bit_depth: 16, // Unused
mp3_bitrate: settings.bitrate_kbps, mp3_bitrate: settings.bitrate_kbps,
start_time: settings.start_time, start_time: daw_backend::Seconds(settings.start_time),
end_time: settings.end_time, end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
}; };
// Step 1: Render audio to memory // Step 1: Render audio to memory

View File

@ -696,8 +696,9 @@ impl ExportOrchestrator {
channels: settings.channels, channels: settings.channels,
bit_depth: settings.bit_depth, bit_depth: settings.bit_depth,
mp3_bitrate: settings.bitrate_kbps, mp3_bitrate: settings.bitrate_kbps,
start_time: settings.start_time, start_time: daw_backend::Seconds(settings.start_time),
end_time: settings.end_time, end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
}; };
// Use DAW backend export for all formats // 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( let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def, effect_def,
effect_instance.timeline_start, 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); 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) { if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {

View File

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

View File

@ -1538,6 +1538,21 @@ impl EditorApp {
fn sync_audio_layers_to_backend(&mut self) { fn sync_audio_layers_to_backend(&mut self) {
use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
// Ensure the master layer has a backend group track.
let master_layer_id = self.action_executor.document().master_layer.layer.id;
if !self.layer_to_track_map.contains_key(&master_layer_id) {
if let Some(ref controller_arc) = self.audio_controller {
let track_id_result = {
let mut controller = controller_arc.lock().unwrap();
controller.create_group_track_sync("[Master]".to_string(), None)
};
if let Ok(track_id) = track_id_result {
self.layer_to_track_map.insert(master_layer_id, track_id);
println!("✅ Created master bus track (TrackId: {})", track_id);
}
}
}
// Collect audio layers from root and inside vector clips // Collect audio layers from root and inside vector clips
// Each entry: (layer_id, layer_name, audio_type, parent_clip_id) // Each entry: (layer_id, layer_name, audio_type, parent_clip_id)
let mut audio_layers_to_sync: Vec<(uuid::Uuid, String, AudioLayerType, Option<uuid::Uuid>)> = Vec::new(); let mut audio_layers_to_sync: Vec<(uuid::Uuid, String, AudioLayerType, Option<uuid::Uuid>)> = Vec::new();
@ -1707,6 +1722,17 @@ impl EditorApp {
} }
} }
} }
// Push subtrack graph for the master bus (all root-level tracks as its inputs).
let master_layer_id = self.action_executor.document().master_layer.layer.id;
if let Some(&master_track_id) = self.layer_to_track_map.get(&master_layer_id) {
let root_children = self.action_executor.document().root.children.clone();
let master_subtracks = self.build_subtrack_list_for_group(&root_children);
if let Some(ref controller_arc) = self.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.set_metatrack_subtrack_graph(master_track_id, master_subtracks);
}
}
} }
/// Build the ordered subtrack list for a group layer's direct children. /// Build the ordered subtrack list for a group layer's direct children.
@ -1844,7 +1870,7 @@ impl EditorApp {
let mut result = Vec::new(); let mut result = Vec::new();
for instance in clip_instances { for instance in clip_instances {
if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) { 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; let timeline_end = instance.timeline_start + effective_duration;
const EPSILON: f64 = 0.001; const EPSILON: f64 = 0.001;
@ -2698,7 +2724,7 @@ impl EditorApp {
let mut duplicate = original.clone(); let mut duplicate = original.clone();
duplicate.id = uuid::Uuid::new_v4(); duplicate.id = uuid::Uuid::new_v4();
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(1.0); 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; duplicate.timeline_start = original.timeline_start + effective_duration;
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) { if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
duplicate.clip_id = *new_clip_def_id; duplicate.clip_id = *new_clip_def_id;
@ -3483,6 +3509,14 @@ impl EditorApp {
println!("Menu: Toggle Layer Visibility"); println!("Menu: Toggle Layer Visibility");
// TODO: Implement toggle layer visibility // TODO: Implement toggle layer visibility
} }
MenuAction::ShowMasterTrack => {
// Toggle show_master_track on all Timeline pane instances
for pane in self.pane_instances.values_mut() {
if let panes::PaneInstance::Timeline(t) = pane {
t.show_master_track = !t.show_master_track;
}
}
}
// Timeline menu // Timeline menu
MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => { MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => {
@ -3771,7 +3805,7 @@ impl EditorApp {
// Sync BPM/time signature to metronome // Sync BPM/time signature to metronome
let doc = self.action_executor.document(); let doc = self.action_executor.document();
controller.set_tempo( controller.set_tempo(
doc.bpm as f32, doc.bpm() as f32,
(doc.time_signature.numerator, doc.time_signature.denominator), (doc.time_signature.numerator, doc.time_signature.denominator),
); );
} }
@ -3834,9 +3868,6 @@ impl EditorApp {
// Rebuild MIDI event cache for all MIDI clips (needed for timeline/piano roll rendering) // Rebuild MIDI event cache for all MIDI clips (needed for timeline/piano roll rendering)
let step8_start = std::time::Instant::now(); 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(); self.midi_event_cache.clear();
let midi_clip_ids: Vec<u32> = self.action_executor.document() let midi_clip_ids: Vec<u32> = self.action_executor.document()
.audio_clips.values() .audio_clips.values()
@ -3859,19 +3890,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); 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 // Reset playback state
self.playback_time = 0.0; self.playback_time = 0.0;
self.is_playing = false; self.is_playing = false;
@ -4002,15 +4020,11 @@ impl EditorApp {
/// Rebuild a MIDI event cache entry from backend note format. /// Rebuild a MIDI event cache entry from backend note format.
/// Called after undo/redo to keep the cache consistent with the backend. /// 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)]) { 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); let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(notes.len() * 2);
for &(start_time, note, velocity, duration) in notes { for &(start_time, note, velocity, duration) in notes {
let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity); let on = daw_backend::audio::midi::MidiEvent::note_on(daw_backend::Beats(start_time), 0, note, velocity);
on.sync_from_seconds(bpm, fps);
events.push(on); events.push(on);
let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + duration, 0, note, 0); let off = daw_backend::audio::midi::MidiEvent::note_off(daw_backend::Beats(start_time + duration), 0, note, 0);
off.sync_from_seconds(bpm, fps);
events.push(off); events.push(off);
} }
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
@ -4048,14 +4062,14 @@ impl EditorApp {
let frontend_clip_id = self.action_executor.document_mut().add_audio_clip(clip); let frontend_clip_id = self.action_executor.document_mut().add_audio_clip(clip);
println!("Imported MIDI '{}' ({:.1}s, {} total events) - Frontend ID: {}, Backend ID: {}", 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 { Some(ImportedAssetInfo {
clip_id: frontend_clip_id, clip_id: frontend_clip_id,
clip_type: panes::DragClipType::AudioMidi, clip_type: panes::DragClipType::AudioMidi,
name, name,
dimensions: None, dimensions: None,
duration, duration: duration.beats_to_f64(),
linked_audio_clip_id: None, linked_audio_clip_id: None,
}) })
} else { } else {
@ -5032,7 +5046,7 @@ impl eframe::App for EditorApp {
if let Some(doc_clip_id) = doc_clip_id { if let Some(doc_clip_id) = doc_clip_id {
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
if clip.is_recording() { if clip.is_recording() {
clip.duration = duration; clip.duration = duration.seconds_to_f64();
} }
} }
} }
@ -5202,24 +5216,18 @@ impl eframe::App for EditorApp {
} }
// Update the clip's duration so the timeline bar grows // 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) { 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 // Update midi_event_cache with notes captured so far.
// (inlined to avoid conflicting &mut self borrow) // 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); let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(notes.len() * 2);
for &(start_time, note, velocity, dur) in &notes { for &(start_beats, note, velocity, dur_beats) in &notes {
let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity); events.push(daw_backend::audio::midi::MidiEvent::note_on(start_beats, 0, note, velocity));
on.sync_from_seconds(bpm, fps); events.push(daw_backend::audio::midi::MidiEvent::note_off(start_beats + dur_beats, 0, note, 0));
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);
} }
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
self.midi_event_cache.insert(clip_id, events); self.midi_event_cache.insert(clip_id, events);
@ -5236,13 +5244,7 @@ impl eframe::App for EditorApp {
match controller.query_midi_clip(track_id, clip_id) { match controller.query_midi_clip(track_id, clip_id) {
Ok(midi_clip_data) => { Ok(midi_clip_data) => {
drop(controller); drop(controller);
let bpm = self.action_executor.document().bpm; self.midi_event_cache.insert(clip_id, midi_clip_data.events.clone());
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);
// Update document clip with final duration and name // Update document clip with final duration and name
let doc_clip_id = self.action_executor.document() let doc_clip_id = self.action_executor.document()
@ -5655,10 +5657,14 @@ impl eframe::App for EditorApp {
}); });
// Checked actions show "✔ Label"; hidden actions are not rendered at all // Checked actions show "✔ Label"; hidden actions are not rendered at all
let checked: &[crate::menu::MenuAction] = if self.count_in_enabled && self.metronome_enabled { let master_track_shown = self.pane_instances.values().any(|p| {
&[crate::menu::MenuAction::ToggleCountIn] if let panes::PaneInstance::Timeline(t) = p { t.show_master_track } else { false }
} else { });
&[] let checked: &[crate::menu::MenuAction] = match (self.count_in_enabled && self.metronome_enabled, master_track_shown) {
(true, true) => &[crate::menu::MenuAction::ToggleCountIn, crate::menu::MenuAction::ShowMasterTrack],
(true, false) => &[crate::menu::MenuAction::ToggleCountIn],
(false, true) => &[crate::menu::MenuAction::ShowMasterTrack],
(false, false) => &[],
}; };
let hidden: &[crate::menu::MenuAction] = if timeline_is_measures && self.metronome_enabled { let hidden: &[crate::menu::MenuAction] = if timeline_is_measures && self.metronome_enabled {
&[] &[]

View File

@ -313,6 +313,7 @@ pub enum MenuAction {
AddTestClip, // For testing: adds a test clip to the asset library AddTestClip, // For testing: adds a test clip to the asset library
DeleteLayer, DeleteLayer,
ToggleLayerVisibility, ToggleLayerVisibility,
ShowMasterTrack,
// Timeline menu // Timeline menu
NewKeyframe, NewKeyframe,
@ -412,6 +413,7 @@ impl MenuItemDef {
const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip to Library", action: MenuAction::AddTestClip, shortcut: None }; const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip to Library", action: MenuAction::AddTestClip, shortcut: None };
const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None }; const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None };
const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None }; const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None };
const SHOW_MASTER_TRACK: Self = Self { label: "Show Master Track", action: MenuAction::ShowMasterTrack, shortcut: None };
// Timeline menu items // Timeline menu items
const NEW_KEYFRAME: Self = Self { label: "New Keyframe", action: MenuAction::NewKeyframe, shortcut: Some(Shortcut::new(ShortcutKey::K, NO_CTRL, NO_SHIFT, NO_ALT)) }; const NEW_KEYFRAME: Self = Self { label: "New Keyframe", action: MenuAction::NewKeyframe, shortcut: Some(Shortcut::new(ShortcutKey::K, NO_CTRL, NO_SHIFT, NO_ALT)) };
@ -533,6 +535,8 @@ impl MenuItemDef {
MenuDef::Separator, MenuDef::Separator,
MenuDef::Item(&Self::DELETE_LAYER), MenuDef::Item(&Self::DELETE_LAYER),
MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY), MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY),
MenuDef::Separator,
MenuDef::Item(&Self::SHOW_MASTER_TRACK),
], ],
}, },
// Timeline menu // Timeline menu

View File

@ -391,10 +391,10 @@ fn generate_midi_thumbnail(
// Draw note events // Draw note events
for event in 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; 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; 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) let clip_dur = document.get_clip_duration(&ci.clip_id)
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start); .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.horizontal(|ui| {
ui.label("Duration:"); ui.label("Duration:");
ui.label(format!("{:.2}s", total_dur)); 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(); let mut pending: std::collections::HashMap<u8, (f64, u8)> = std::collections::HashMap::new();
for event in events { for event in events {
if event.is_note_on() { 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() { } else if event.is_note_off() {
if let Some((start, v)) = pending.remove(&event.data1) { 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 { for event in events {
let channel = event.status & 0x0F; let channel = event.status & 0x0F;
if event.is_note_on() { 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() { } else if event.is_note_off() {
if let Some((start, vel, ch)) = active.remove(&event.data1) { 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 { notes.push(ResolvedNote {
note: event.data1, note: event.data1,
channel: ch, channel: ch,
@ -292,13 +292,13 @@ impl PianoRollPane {
// ── Ruler interval calculation ─────────────────────────────────────── // ── 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_pixel_gap = 80.0;
let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64; let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64;
// Use beat-aligned intervals // Use beat-aligned intervals
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm); let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig); let measure_dur = lightningbeam_core::beat_time::measure_duration(0.0, tempo_map, time_sig);
let beat_intervals = [ let beat_intervals = [
beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0, beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0,
measure_dur, measure_dur * 2.0, measure_dur * 4.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) } // end impl PianoRollPane (snap helpers follow as free functions)
fn snap_to_value(t: f64, snap: SnapValue, bpm: f64) -> f64 { fn snap_to_value(t: f64, snap: SnapValue, tempo_map: &daw_backend::TempoMap) -> f64 {
let beat = 60.0 / bpm; let beat = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
match snap { match snap {
SnapValue::None => t, SnapValue::None => t,
SnapValue::Whole => round_to_grid(t, beat * 4.0), 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() *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; const EPS: f64 = 0.002;
if notes.is_empty() { return SnapValue::None; } if notes.is_empty() { return SnapValue::None; }
let order = [ let order = [
@ -359,7 +359,7 @@ fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue {
SnapValue::SixteenthTriplet, SnapValue::ThirtySecondTriplet, SnapValue::SixteenthTriplet, SnapValue::ThirtySecondTriplet,
]; ];
for &sv in &order { 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; return sv;
} }
} }
@ -425,7 +425,7 @@ impl PianoRollPane {
let clip_doc_id = clip_doc_id; // doc-side AudioClip uuid let clip_doc_id = clip_doc_id; // doc-side AudioClip uuid
let duration = mc.external_duration; let duration = mc.external_duration;
let instance_uuid = Uuid::nil(); // no doc-side instance uuid yet 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 let _ = clip_doc_id; // used above for the if-let pattern
} }
} }
@ -435,7 +435,7 @@ impl PianoRollPane {
for instance in &audio_layer.clip_instances { for instance in &audio_layer.clip_instances {
if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { 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)); 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; self.snap_user_changed = false;
if self.snap_value != SnapValue::None && !self.selected_note_indices.is_empty() { if self.snap_value != SnapValue::None && !self.selected_note_indices.is_empty() {
if let Some(clip_id) = self.selected_clip_id { if let Some(clip_id) = self.selected_clip_id {
let bpm = shared.action_executor.document().bpm; let tempo_map = shared.action_executor.document().tempo_map().clone();
self.quantize_selected_notes(clip_id, bpm, shared); self.quantize_selected_notes(clip_id, &tempo_map, shared);
} }
} }
} }
@ -484,11 +484,11 @@ impl PianoRollPane {
// Render grid (clipped to grid area) // Render grid (clipped to grid area)
let grid_painter = ui.painter_at(grid_rect); 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(); 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 // Render clip boundaries and notes
for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data { 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, 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) // Horizontal lines (note separators)
for note in MIN_NOTE..=MAX_NOTE { for note in MIN_NOTE..=MAX_NOTE {
let y = self.note_to_y(note, grid_rect); let y = self.note_to_y(note, grid_rect);
@ -648,9 +648,9 @@ impl PianoRollPane {
} }
// Vertical lines (beat-aligned time grid) // Vertical lines (beat-aligned time grid)
let interval = self.ruler_interval(bpm, time_sig); let interval = self.ruler_interval(tempo_map, time_sig);
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm); let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig); 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 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_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
@ -675,7 +675,7 @@ impl PianoRollPane {
// Labels at measure boundaries // Labels at measure boundaries
if is_measure && x > grid_rect.min.x + 20.0 { 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( painter.text(
pos2(x + 2.0, grid_rect.min.y + 2.0), pos2(x + 2.0, grid_rect.min.y + 2.0),
Align2::LEFT_TOP, Align2::LEFT_TOP,
@ -685,7 +685,7 @@ impl PianoRollPane {
); );
} else if is_beat && !is_measure && x > grid_rect.min.x + 20.0 } else if is_beat && !is_measure && x > grid_rect.min.x + 20.0
&& beat_dur as f32 * self.pixels_per_second > 40.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( painter.text(
pos2(x + 2.0, grid_rect.min.y + 2.0), pos2(x + 2.0, grid_rect.min.y + 2.0),
Align2::LEFT_TOP, Align2::LEFT_TOP,
@ -708,8 +708,8 @@ impl PianoRollPane {
) -> f32 { ) -> f32 {
let mut peak = 0.0f32; let mut peak = 0.0f32;
for ev in events { for ev in events {
if ev.timestamp > note_end + 0.01 { break; } if ev.timestamp.beats_to_f64() > note_end + 0.01 { break; }
if ev.timestamp >= note_start - 0.01 if ev.timestamp.beats_to_f64() >= note_start - 0.01
&& (ev.status & 0xF0) == 0xE0 && (ev.status & 0xF0) == 0xE0
&& (ev.status & 0x0F) == channel && (ev.status & 0x0F) == channel
{ {
@ -770,7 +770,7 @@ impl PianoRollPane {
}; };
let timestamp = note_start + t * note_duration; let timestamp = note_start + t * note_duration;
let (lsb, msb) = encode_bend(normalized); 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 events
} }
@ -794,11 +794,11 @@ impl PianoRollPane {
let ch = ev.status & 0x0F; let ch = ev.status & 0x0F;
let msg = ev.status & 0xF0; let msg = ev.status & 0xF0;
if msg == 0x90 && ev.data2 > 0 { 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) { } else if msg == 0x80 || (msg == 0x90 && ev.data2 == 0) {
if let Some(start) = active.remove(&(ev.data1, ch)) { if let Some(start) = active.remove(&(ev.data1, ch)) {
// Overlaps target range and is NOT the note we're assigning // 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) && !(ev.data1 == note_pitch && ch == current_channel)
{ {
used[ch as usize] = true; 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 { fn find_cc1_for_note(events: &[daw_backend::audio::midi::MidiEvent], note_start: f64, note_end: f64, channel: u8) -> u8 {
let mut cc1 = 0u8; let mut cc1 = 0u8;
for ev in events { 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.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; cc1 = ev.data2;
} }
} }
@ -950,7 +950,7 @@ impl PianoRollPane {
let ts = note.start_time + t as f64 * note.duration; let ts = note.start_time + t as f64 * note.duration;
let mut existing_norm = 0.0f32; let mut existing_norm = 0.0f32;
for ev in events { 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 { if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == drag_ch {
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16); let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
existing_norm = (raw - 8192) as f32 / 8192.0; existing_norm = (raw - 8192) as f32 / 8192.0;
@ -991,7 +991,7 @@ impl PianoRollPane {
// Find last pitch bend event at or before ts // Find last pitch bend event at or before ts
let mut bend_norm = 0.0f32; let mut bend_norm = 0.0f32;
for ev in events { 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 { if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == note.channel {
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16); let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
bend_norm = (raw - 8192) as f32 / 8192.0; 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, 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 // 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 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_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
let end = (end_time / interval).ceil() as i64; 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) { if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) {
let clip_start = selected_clip.1; let clip_start = selected_clip.1;
let trim_start = selected_clip.2; 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( let clip_local_time = snap_to_value(
(time - clip_start).max(0.0) + trim_start, (time - clip_start).max(0.0) + trim_start,
self.snap_value, bpm, self.snap_value, tempo_map,
); );
self.creating_note = Some(TempNote { self.creating_note = Some(TempNote {
note, note,
@ -1395,8 +1395,8 @@ impl PianoRollPane {
self.selection_rect = Some((pos, pos)); self.selection_rect = Some((pos, pos));
self.drag_mode = Some(DragMode::SelectRect); self.drag_mode = Some(DragMode::SelectRect);
let bpm = shared.action_executor.document().bpm; let tempo_map = shared.action_executor.document().tempo_map();
let seek_time = snap_to_value(time.max(0.0), self.snap_value, bpm); let seek_time = snap_to_value(time.max(0.0), self.snap_value, tempo_map);
*shared.playback_time = seek_time; *shared.playback_time = seek_time;
if let Some(ctrl) = shared.audio_controller.as_ref() { if let Some(ctrl) = shared.audio_controller.as_ref() {
if let Ok(mut c) = ctrl.lock() { c.seek(seek_time); } 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 ts = note_start + t * note_duration;
let mut bend = 0.0f32; let mut bend = 0.0f32;
for ev in &new_events { 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 { if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel {
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16); let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
bend = (raw - 8192) as f32 / 8192.0; bend = (raw - 8192) as f32 / 8192.0;
@ -1550,7 +1550,8 @@ impl PianoRollPane {
// Remove old bend events in range before writing combined // Remove old bend events in range before writing combined
new_events.retain(|ev| { new_events.retain(|ev| {
let is_bend = (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel; 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) !(is_bend && in_range)
}); });
@ -1568,20 +1569,15 @@ impl PianoRollPane {
let combined = (existing_norm[i] + zone_norm).clamp(-1.0, 1.0); let combined = (existing_norm[i] + zone_norm).clamp(-1.0, 1.0);
let (lsb, msb) = encode_bend(combined); let (lsb, msb) = encode_bend(combined);
let ts = note_start + i as f64 / num_steps as f64 * note_duration; 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 // For End zone: reset just after note ends so it doesn't bleed into next note
if zone == PitchBendZone::End { if zone == PitchBendZone::End {
let (lsb, msb) = encode_bend(0.0); 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)); 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); self.push_events_action("Set pitch bend", clip_id, old_events, new_events.clone(), shared);
shared.midi_event_cache.insert(clip_id, new_events); 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) { 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); let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(resolved.len() * 2);
for n in resolved { 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_on(daw_backend::Beats(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_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()); events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
shared.midi_event_cache.insert(clip_id, events); shared.midi_event_cache.insert(clip_id, events);
@ -1787,12 +1783,12 @@ impl PianoRollPane {
let resolved = Self::resolve_notes(events); let resolved = Self::resolve_notes(events);
let old_notes = Self::notes_to_backend_format(&resolved); 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(); let mut new_resolved = resolved.clone();
for &idx in &self.selected_note_indices { for &idx in &self.selected_note_indices {
if idx < new_resolved.len() { if idx < new_resolved.len() {
let raw_time = (new_resolved[idx].start_time + dt).max(0.0); 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; 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)); 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 events = match shared.midi_event_cache.get(&clip_id) { Some(e) => e, None => return };
let resolved = Self::resolve_notes(events); let resolved = Self::resolve_notes(events);
let old_notes = Self::notes_to_backend_format(&resolved); let old_notes = Self::notes_to_backend_format(&resolved);
@ -1976,7 +1972,7 @@ impl PianoRollPane {
for &idx in &self.selected_note_indices { for &idx in &self.selected_note_indices {
if idx < new_resolved.len() { if idx < new_resolved.len() {
new_resolved[idx].start_time = 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); 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) // Dot grid background (visible where the spectrogram doesn't draw)
let grid_painter = ui.painter_at(view_rect); 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(); 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 // Find audio pool index for the active layer's clips
@ -2323,7 +2319,7 @@ impl PaneRenderer for PianoRollPane {
for ev in cached.iter_mut() { for ev in cached.iter_mut() {
if ev.is_note_on() && ev.data1 == sn.note if ev.is_note_on() && ev.data1 == sn.note
&& (ev.status & 0x0F) == sn.channel && (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; ev.data2 = new_vel;
} }
@ -2360,21 +2356,16 @@ impl PaneRenderer for PianoRollPane {
let is_cc1 = (ev.status & 0xF0) == 0xB0 let is_cc1 = (ev.status & 0xF0) == 0xB0
&& (ev.status & 0x0F) == sn.channel && (ev.status & 0x0F) == sn.channel
&& ev.data1 == 1; && 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) !(is_cc1 && at_start)
}); });
if new_cc1 > 0 { if new_cc1 > 0 {
new_events.push(daw_backend::audio::midi::MidiEvent::new( 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)); 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); self.push_events_action("Set modulation", clip_id, old_events, new_events.clone(), shared);
shared.midi_event_cache.insert(clip_id, new_events); shared.midi_event_cache.insert(clip_id, new_events);
} }
@ -2416,7 +2407,7 @@ impl PaneRenderer for PianoRollPane {
// Snap-to dropdown — only in Measures mode // Snap-to dropdown — only in Measures mode
let doc = shared.action_executor.document(); let doc = shared.action_executor.document();
let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures; let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures;
let bpm = doc.bpm; let tempo_map = doc.tempo_map();
drop(doc); drop(doc);
if is_measures { if is_measures {
@ -2429,7 +2420,7 @@ impl PaneRenderer for PianoRollPane {
let sel: Vec<&ResolvedNote> = self.selected_note_indices.iter() let sel: Vec<&ResolvedNote> = self.selected_note_indices.iter()
.filter_map(|&i| resolved.get(i)) .filter_map(|&i| resolved.get(i))
.collect(); .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( let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def, effect_def,
effect_instance.timeline_start, 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) // 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) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
// Skip clip instances not active at current time // 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 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 { if self.ctx.playback_time < clip_instance.timeline_start || self.ctx.playback_time >= instance_end {
continue; continue;
} }
@ -2214,7 +2214,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Find clip instance visible at playback time // Find clip instance visible at playback time
let visible_clip = video_layer.clip_instances.iter().find(|inst| { 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 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 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) { if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
video_layer.clip_instances.iter().find(|inst| { video_layer.clip_instances.iter().find(|inst| {
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(0.0); 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 playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
}).map(|inst| inst.id) }).map(|inst| inst.id)
} else { } else {

File diff suppressed because it is too large Load Diff