Compare commits
4 Commits
cfb8e4462b
...
54d5764bd0
| Author | SHA1 | Date |
|---|---|---|
|
|
54d5764bd0 | |
|
|
f372a84313 | |
|
|
ae146533d9 | |
|
|
3fc4773ec3 |
|
|
@ -1,5 +1,6 @@
|
|||
/// Automation system for parameter modulation over time
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::time::Beats;
|
||||
|
||||
/// Unique identifier for automation lanes
|
||||
pub type AutomationLaneId = u32;
|
||||
|
|
@ -35,17 +36,13 @@ pub enum CurveType {
|
|||
/// A single automation point
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AutomationPoint {
|
||||
/// Time in seconds
|
||||
pub time: f64,
|
||||
/// Parameter value (normalized 0.0 to 1.0, or actual value depending on parameter)
|
||||
pub time: Beats,
|
||||
pub value: f32,
|
||||
/// Curve type to next point
|
||||
pub curve: CurveType,
|
||||
}
|
||||
|
||||
impl AutomationPoint {
|
||||
/// Create a new automation point
|
||||
pub fn new(time: f64, value: f32, curve: CurveType) -> Self {
|
||||
pub fn new(time: Beats, value: f32, curve: CurveType) -> Self {
|
||||
Self { time, value, curve }
|
||||
}
|
||||
}
|
||||
|
|
@ -93,8 +90,7 @@ impl AutomationLane {
|
|||
}
|
||||
}
|
||||
|
||||
/// Remove point at specific time
|
||||
pub fn remove_point_at_time(&mut self, time: f64, tolerance: f64) -> bool {
|
||||
pub fn remove_point_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
|
||||
if let Some(idx) = self.points.iter().position(|p| (p.time - time).abs() < tolerance) {
|
||||
self.points.remove(idx);
|
||||
true
|
||||
|
|
@ -113,8 +109,7 @@ impl AutomationLane {
|
|||
&self.points
|
||||
}
|
||||
|
||||
/// Get value at a specific time with interpolation
|
||||
pub fn evaluate(&self, time: f64) -> Option<f32> {
|
||||
pub fn evaluate(&self, time: Beats) -> Option<f32> {
|
||||
if !self.enabled || self.points.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -148,14 +143,12 @@ impl AutomationLane {
|
|||
}
|
||||
}
|
||||
|
||||
/// Interpolate between two automation points based on curve type
|
||||
fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: f64) -> f32 {
|
||||
// Calculate normalized position between points (0.0 to 1.0)
|
||||
fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: Beats) -> f32 {
|
||||
let t = if p2.time == p1.time {
|
||||
0.0
|
||||
0.0f64
|
||||
} else {
|
||||
((time - p1.time) / (p2.time - p1.time)) as f32
|
||||
};
|
||||
(time - p1.time) / (p2.time - p1.time)
|
||||
} as f32;
|
||||
|
||||
// Apply curve
|
||||
let curved_t = match p1.curve {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::time::{Beats, Seconds};
|
||||
use crate::tempo_map::TempoMap;
|
||||
|
||||
/// Audio clip instance ID type
|
||||
pub type AudioClipInstanceId = u32;
|
||||
|
|
@ -9,47 +11,31 @@ pub type ClipId = AudioClipInstanceId;
|
|||
|
||||
/// Audio clip instance that references content in the AudioClipPool
|
||||
///
|
||||
/// This represents a placed instance of audio content on the timeline.
|
||||
/// The actual audio data is stored in the AudioClipPool and referenced by `audio_pool_index`.
|
||||
///
|
||||
/// ## Timing Model
|
||||
/// - `internal_start` / `internal_end`: Define the region of the source audio to play (trimming)
|
||||
/// - `external_start` / `external_duration`: Define where the clip appears on the timeline and how long
|
||||
/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display
|
||||
/// - `internal_start` / `internal_end`: Region of the source audio to play (seconds — audio file seek positions)
|
||||
/// - `external_start` / `external_duration`: Where the clip appears on the timeline (**beats**)
|
||||
///
|
||||
/// ## Looping
|
||||
/// If `external_duration` is greater than `internal_end - internal_start`,
|
||||
/// the clip will seamlessly loop back to `internal_start` when it reaches `internal_end`.
|
||||
/// If `external_duration_secs(bpm)` > `internal_end - internal_start`, the clip loops.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioClipInstance {
|
||||
pub id: AudioClipInstanceId,
|
||||
pub audio_pool_index: usize,
|
||||
|
||||
/// Start position within the audio content (seconds)
|
||||
pub internal_start: f64,
|
||||
#[serde(default)] pub internal_start_beats: f64,
|
||||
#[serde(default)] pub internal_start_frames: f64,
|
||||
/// End position within the audio content (seconds)
|
||||
pub internal_end: f64,
|
||||
#[serde(default)] pub internal_end_beats: f64,
|
||||
#[serde(default)] pub internal_end_frames: f64,
|
||||
/// Start position within the audio content
|
||||
pub internal_start: Seconds,
|
||||
/// End position within the audio content
|
||||
pub internal_end: Seconds,
|
||||
|
||||
/// Start position on the timeline (seconds)
|
||||
pub external_start: f64,
|
||||
#[serde(default)] pub external_start_beats: f64,
|
||||
#[serde(default)] pub external_start_frames: f64,
|
||||
/// Duration on the timeline (seconds) - can be longer than internal duration for looping
|
||||
pub external_duration: f64,
|
||||
#[serde(default)] pub external_duration_beats: f64,
|
||||
#[serde(default)] pub external_duration_frames: f64,
|
||||
/// Start position on the timeline
|
||||
pub external_start: Beats,
|
||||
/// Duration on the timeline
|
||||
pub external_duration: Beats,
|
||||
|
||||
/// Clip-level gain
|
||||
pub gain: f32,
|
||||
|
||||
/// Per-instance read-ahead buffer for compressed audio streaming.
|
||||
/// Each clip instance gets its own buffer so multiple instances of the
|
||||
/// same file (on different tracks or at different positions) don't fight
|
||||
/// over a single target_frame.
|
||||
#[serde(skip)]
|
||||
pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>,
|
||||
}
|
||||
|
|
@ -58,154 +44,71 @@ pub struct AudioClipInstance {
|
|||
pub type Clip = AudioClipInstance;
|
||||
|
||||
impl AudioClipInstance {
|
||||
/// Create a new audio clip instance
|
||||
pub fn new(
|
||||
id: AudioClipInstanceId,
|
||||
audio_pool_index: usize,
|
||||
internal_start: f64,
|
||||
internal_end: f64,
|
||||
external_start: f64,
|
||||
external_duration: f64,
|
||||
internal_start: Seconds,
|
||||
internal_end: Seconds,
|
||||
external_start: Beats,
|
||||
external_duration: Beats,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
audio_pool_index,
|
||||
internal_start,
|
||||
internal_start_beats: 0.0,
|
||||
internal_start_frames: 0.0,
|
||||
internal_end,
|
||||
internal_end_beats: 0.0,
|
||||
internal_end_frames: 0.0,
|
||||
external_start,
|
||||
external_start_beats: 0.0,
|
||||
external_start_frames: 0.0,
|
||||
external_duration,
|
||||
external_duration_beats: 0.0,
|
||||
external_duration_frames: 0.0,
|
||||
gain: 1.0,
|
||||
read_ahead: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a clip instance from legacy parameters (for backwards compatibility)
|
||||
/// Maps old start_time/duration/offset to new timing model
|
||||
pub fn from_legacy(
|
||||
id: AudioClipInstanceId,
|
||||
audio_pool_index: usize,
|
||||
start_time: f64,
|
||||
duration: f64,
|
||||
offset: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
audio_pool_index,
|
||||
internal_start: offset,
|
||||
internal_start_beats: 0.0,
|
||||
internal_start_frames: 0.0,
|
||||
internal_end: offset + duration,
|
||||
internal_end_beats: 0.0,
|
||||
internal_end_frames: 0.0,
|
||||
external_start: start_time,
|
||||
external_start_beats: 0.0,
|
||||
external_start_frames: 0.0,
|
||||
external_duration: duration,
|
||||
external_duration_beats: 0.0,
|
||||
external_duration_frames: 0.0,
|
||||
gain: 1.0,
|
||||
read_ahead: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this clip instance is active at a given timeline position
|
||||
pub fn is_active_at(&self, time_seconds: f64) -> bool {
|
||||
time_seconds >= self.external_start && time_seconds < self.external_end()
|
||||
}
|
||||
|
||||
/// Get the end time of this clip instance on the timeline
|
||||
pub fn external_end(&self) -> f64 {
|
||||
pub fn external_end(&self) -> Beats {
|
||||
self.external_start + self.external_duration
|
||||
}
|
||||
|
||||
/// Get the end time of this clip instance on the timeline
|
||||
/// (Alias for external_end(), for backwards compatibility)
|
||||
pub fn end_time(&self) -> f64 {
|
||||
self.external_end()
|
||||
pub fn external_start_secs(&self, tempo_map: &TempoMap) -> Seconds {
|
||||
tempo_map.beats_to_seconds(self.external_start)
|
||||
}
|
||||
|
||||
/// Get the start time on the timeline
|
||||
/// (Alias for external_start, for backwards compatibility)
|
||||
pub fn start_time(&self) -> f64 {
|
||||
self.external_start
|
||||
pub fn external_end_secs(&self, tempo_map: &TempoMap) -> Seconds {
|
||||
tempo_map.beats_to_seconds(self.external_end())
|
||||
}
|
||||
|
||||
/// Get the internal (content) duration
|
||||
pub fn internal_duration(&self) -> f64 {
|
||||
pub fn external_duration_secs(&self, tempo_map: &TempoMap) -> Seconds {
|
||||
tempo_map.beats_to_seconds(self.external_end()) - tempo_map.beats_to_seconds(self.external_start)
|
||||
}
|
||||
|
||||
pub fn is_active_at(&self, time: Seconds, tempo_map: &TempoMap) -> bool {
|
||||
time >= self.external_start_secs(tempo_map) && time < self.external_end_secs(tempo_map)
|
||||
}
|
||||
|
||||
pub fn internal_duration(&self) -> Seconds {
|
||||
self.internal_end - self.internal_start
|
||||
}
|
||||
|
||||
/// Check if this clip instance loops
|
||||
pub fn is_looping(&self) -> bool {
|
||||
self.external_duration > self.internal_duration()
|
||||
pub fn is_looping(&self, tempo_map: &TempoMap) -> bool {
|
||||
self.external_duration_secs(tempo_map) > self.internal_duration()
|
||||
}
|
||||
|
||||
/// Get the position within the audio content for a given timeline position
|
||||
/// Returns None if the timeline position is outside this clip instance
|
||||
/// Handles looping automatically
|
||||
pub fn get_content_position(&self, timeline_pos: f64) -> Option<f64> {
|
||||
if timeline_pos < self.external_start || timeline_pos >= self.external_end() {
|
||||
/// Get the audio content position for a given timeline position. Handles looping.
|
||||
pub fn get_content_position(&self, timeline_pos: Seconds, tempo_map: &TempoMap) -> Option<Seconds> {
|
||||
let start_secs = self.external_start_secs(tempo_map);
|
||||
let end_secs = self.external_end_secs(tempo_map);
|
||||
if timeline_pos < start_secs || timeline_pos >= end_secs {
|
||||
return None;
|
||||
}
|
||||
|
||||
let relative_pos = timeline_pos - self.external_start;
|
||||
let relative_pos = timeline_pos - start_secs;
|
||||
let internal_duration = self.internal_duration();
|
||||
|
||||
if internal_duration <= 0.0 {
|
||||
if internal_duration.0 <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Wrap around for looping
|
||||
let content_offset = relative_pos % internal_duration;
|
||||
Some(self.internal_start + content_offset)
|
||||
}
|
||||
|
||||
/// Set clip gain
|
||||
pub fn set_gain(&mut self, gain: f32) {
|
||||
self.gain = gain.max(0.0);
|
||||
}
|
||||
|
||||
/// Populate beats/frames from the current seconds values.
|
||||
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
|
||||
self.external_start_beats = self.external_start * bpm / 60.0;
|
||||
self.external_start_frames = self.external_start * fps;
|
||||
self.external_duration_beats = self.external_duration * bpm / 60.0;
|
||||
self.external_duration_frames = self.external_duration * fps;
|
||||
self.internal_start_beats = self.internal_start * bpm / 60.0;
|
||||
self.internal_start_frames = self.internal_start * fps;
|
||||
self.internal_end_beats = self.internal_end * bpm / 60.0;
|
||||
self.internal_end_frames = self.internal_end * fps;
|
||||
}
|
||||
|
||||
/// BPM changed; recompute seconds/frames from the stored beats values.
|
||||
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
|
||||
self.external_start = self.external_start_beats * 60.0 / bpm;
|
||||
self.external_start_frames = self.external_start * fps;
|
||||
self.external_duration = self.external_duration_beats * 60.0 / bpm;
|
||||
self.external_duration_frames = self.external_duration * fps;
|
||||
self.internal_start = self.internal_start_beats * 60.0 / bpm;
|
||||
self.internal_start_frames = self.internal_start * fps;
|
||||
self.internal_end = self.internal_end_beats * 60.0 / bpm;
|
||||
self.internal_end_frames = self.internal_end * fps;
|
||||
}
|
||||
|
||||
/// FPS changed; recompute seconds/beats from the stored frames values.
|
||||
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
|
||||
self.external_start = self.external_start_frames / fps;
|
||||
self.external_start_beats = self.external_start * bpm / 60.0;
|
||||
self.external_duration = self.external_duration_frames / fps;
|
||||
self.external_duration_beats = self.external_duration * bpm / 60.0;
|
||||
self.internal_start = self.internal_start_frames / fps;
|
||||
self.internal_start_beats = self.internal_start * bpm / 60.0;
|
||||
self.internal_end = self.internal_end_frames / fps;
|
||||
self.internal_end_beats = self.internal_end * bpm / 60.0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::audio::recording::{MidiRecordingState, RecordingState};
|
|||
use crate::audio::track::{Track, TrackId, TrackNode};
|
||||
use crate::command::{AudioEvent, Command, Query, QueryResponse};
|
||||
use crate::io::MidiInputManager;
|
||||
use crate::time::{Beats, Seconds};
|
||||
use petgraph::stable_graph::NodeIndex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
|
|
@ -109,10 +110,11 @@ pub struct Engine {
|
|||
timing_sum_total_us: u64,
|
||||
timing_overrun_count: u64,
|
||||
|
||||
// Current tempo/framerate — kept in sync with SetTempo/ApplyBpmChange so that
|
||||
// newly-created clip instances can be immediately synced via sync_from_seconds.
|
||||
current_bpm: f64,
|
||||
// Current tempo map — kept in sync with SetTempo/SetTempoMap commands.
|
||||
tempo_map: crate::TempoMap,
|
||||
current_fps: f64,
|
||||
// Current time signature — updated by SetTempo, used when SetTempoMap fires.
|
||||
time_sig: (u32, u32),
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
|
|
@ -189,8 +191,9 @@ impl Engine {
|
|||
timing_worst_render_us: 0,
|
||||
timing_sum_total_us: 0,
|
||||
timing_overrun_count: 0,
|
||||
current_bpm: 120.0,
|
||||
tempo_map: crate::TempoMap::constant(120.0),
|
||||
current_fps: 30.0,
|
||||
time_sig: (4, 4),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,7 +382,7 @@ impl Engine {
|
|||
}
|
||||
|
||||
// Convert playhead from frames to seconds for timeline-based rendering
|
||||
let playhead_seconds = self.playhead as f64 / self.sample_rate as f64;
|
||||
let playhead_seconds = Seconds(self.playhead as f64 / self.sample_rate as f64);
|
||||
|
||||
// Reset per-clip read-ahead targets before rendering.
|
||||
self.project.reset_read_ahead_targets();
|
||||
|
|
@ -390,6 +393,7 @@ impl Engine {
|
|||
&self.audio_pool,
|
||||
&mut self.buffer_pool,
|
||||
playhead_seconds,
|
||||
&self.tempo_map,
|
||||
self.sample_rate,
|
||||
self.channels,
|
||||
false,
|
||||
|
|
@ -427,7 +431,8 @@ impl Engine {
|
|||
|
||||
// Send MIDI recording progress if active
|
||||
if let Some(recording) = &self.midi_recording_state {
|
||||
let current_time = self.playhead as f64 / self.sample_rate as f64;
|
||||
let current_time_secs = Seconds(self.playhead as f64 / self.sample_rate as f64);
|
||||
let current_time = self.tempo_map.seconds_to_beats(current_time_secs);
|
||||
let duration = current_time - recording.start_time;
|
||||
let notes = recording.get_notes_with_active(current_time);
|
||||
let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress(
|
||||
|
|
@ -451,7 +456,7 @@ impl Engine {
|
|||
} else {
|
||||
// Not playing: render live MIDI (keyboard input + note-off tails) through the
|
||||
// normal group hierarchy so mixer gain is correctly applied.
|
||||
let playhead_seconds = self.playhead as f64 / self.sample_rate as f64;
|
||||
let playhead_seconds = Seconds(self.playhead as f64 / self.sample_rate as f64);
|
||||
if self.mix_buffer.len() != output.len() {
|
||||
self.mix_buffer.resize(output.len(), 0.0);
|
||||
}
|
||||
|
|
@ -463,6 +468,7 @@ impl Engine {
|
|||
&self.audio_pool,
|
||||
&mut self.buffer_pool,
|
||||
playhead_seconds,
|
||||
&self.tempo_map,
|
||||
self.sample_rate,
|
||||
self.channels,
|
||||
true, // live_only
|
||||
|
|
@ -562,7 +568,8 @@ impl Engine {
|
|||
if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) {
|
||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||
clip.internal_end = clip.internal_start + duration;
|
||||
clip.external_duration = duration;
|
||||
let duration_beats = self.tempo_map.seconds_to_beats(duration);
|
||||
clip.external_duration = duration_beats;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -734,20 +741,16 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
Command::MoveClip(track_id, clip_id, new_start_time) => {
|
||||
// Moving just changes external_start, external_duration stays the same
|
||||
let bpm = self.current_bpm;
|
||||
let fps = self.current_fps;
|
||||
// Moving just changes external_start (beats), external_duration stays the same
|
||||
match self.project.get_track_mut(track_id) {
|
||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||
clip.external_start = new_start_time;
|
||||
clip.sync_from_seconds(bpm, fps);
|
||||
clip.external_start = Beats(new_start_time);
|
||||
}
|
||||
}
|
||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) {
|
||||
instance.external_start = new_start_time;
|
||||
instance.sync_from_seconds(bpm, fps);
|
||||
instance.external_start = Beats(new_start_time);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -757,24 +760,19 @@ impl Engine {
|
|||
Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => {
|
||||
// Trim changes which portion of the source content is used
|
||||
// Also updates external_duration to match internal duration (no looping after trim)
|
||||
let bpm = self.current_bpm;
|
||||
let fps = self.current_fps;
|
||||
match self.project.get_track_mut(track_id) {
|
||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||
clip.internal_start = new_internal_start;
|
||||
clip.internal_end = new_internal_end;
|
||||
clip.external_duration = new_internal_end - new_internal_start;
|
||||
clip.sync_from_seconds(bpm, fps);
|
||||
clip.internal_start = Seconds(new_internal_start);
|
||||
clip.internal_end = Seconds(new_internal_end);
|
||||
clip.external_duration = Beats(new_internal_end - new_internal_start);
|
||||
}
|
||||
}
|
||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||
// Note: clip_id here is the pool clip ID, not instance ID
|
||||
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
|
||||
instance.internal_start = new_internal_start;
|
||||
instance.internal_end = new_internal_end;
|
||||
instance.external_duration = new_internal_end - new_internal_start;
|
||||
instance.sync_from_seconds(bpm, fps);
|
||||
instance.internal_start = Beats(new_internal_start);
|
||||
instance.internal_end = Beats(new_internal_end);
|
||||
instance.external_duration = Beats(new_internal_end - new_internal_start);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -782,21 +780,16 @@ impl Engine {
|
|||
self.refresh_clip_snapshot();
|
||||
}
|
||||
Command::ExtendClip(track_id, clip_id, new_external_duration) => {
|
||||
// Extend changes the external duration (enables looping if > internal duration)
|
||||
let bpm = self.current_bpm;
|
||||
let fps = self.current_fps;
|
||||
// Extend changes the external duration (beats; enables looping if > internal duration)
|
||||
match self.project.get_track_mut(track_id) {
|
||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||
clip.external_duration = new_external_duration;
|
||||
clip.sync_from_seconds(bpm, fps);
|
||||
clip.external_duration = Beats(new_external_duration);
|
||||
}
|
||||
}
|
||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||
// Note: clip_id here is the pool clip ID, not instance ID
|
||||
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
|
||||
instance.external_duration = new_external_duration;
|
||||
instance.sync_from_seconds(bpm, fps);
|
||||
instance.external_duration = Beats(new_external_duration);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -823,7 +816,7 @@ impl Engine {
|
|||
}
|
||||
Command::SetOffset(track_id, offset) => {
|
||||
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||
metatrack.offset = offset;
|
||||
metatrack.offset = Seconds(offset);
|
||||
}
|
||||
}
|
||||
Command::SetPitchShift(track_id, semitones) => {
|
||||
|
|
@ -833,12 +826,12 @@ impl Engine {
|
|||
}
|
||||
Command::SetTrimStart(track_id, trim_start) => {
|
||||
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||
metatrack.trim_start = trim_start.max(0.0);
|
||||
metatrack.trim_start = Seconds(trim_start.max(0.0));
|
||||
}
|
||||
}
|
||||
Command::SetTrimEnd(track_id, trim_end) => {
|
||||
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||
metatrack.trim_end = trim_end.map(|t| t.max(0.0));
|
||||
metatrack.trim_end = trim_end.map(|t| Seconds(t.max(0.0)));
|
||||
}
|
||||
}
|
||||
Command::CreateAudioTrack(name, parent_id) => {
|
||||
|
|
@ -915,14 +908,20 @@ impl Engine {
|
|||
}
|
||||
Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => {
|
||||
// Create a new clip instance with the pre-assigned clip_id
|
||||
let mut clip = AudioClipInstance::from_legacy(
|
||||
// start_time and duration are in beats; offset (internal_start) is seconds
|
||||
let start_beats = Beats(start_time);
|
||||
let end_beats = Beats(start_time + duration);
|
||||
let start_secs = self.tempo_map.beats_to_seconds(start_beats);
|
||||
let end_secs = self.tempo_map.beats_to_seconds(end_beats);
|
||||
let content_dur_secs = (end_secs - start_secs).seconds_to_f64();
|
||||
let clip = AudioClipInstance::new(
|
||||
clip_id,
|
||||
pool_index,
|
||||
start_time,
|
||||
duration,
|
||||
offset,
|
||||
Seconds(offset),
|
||||
Seconds(offset + content_dur_secs),
|
||||
start_beats,
|
||||
Beats(duration),
|
||||
);
|
||||
clip.sync_from_seconds(self.current_bpm, self.current_fps);
|
||||
|
||||
// Add clip to track
|
||||
if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) {
|
||||
|
|
@ -945,13 +944,12 @@ impl Engine {
|
|||
let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// Create clip content in the pool
|
||||
let clip = MidiClip::empty(clip_id, duration, format!("MIDI Clip {}", clip_id));
|
||||
let clip = MidiClip::empty(clip_id, Beats(duration), format!("MIDI Clip {}", clip_id));
|
||||
self.project.midi_clip_pool.add_existing_clip(clip);
|
||||
|
||||
// Create an instance for this clip on the track
|
||||
let instance_id = self.project.next_midi_clip_instance_id();
|
||||
let mut instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time);
|
||||
instance.sync_from_seconds(self.current_bpm, self.current_fps);
|
||||
let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time));
|
||||
|
||||
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||
track.clip_instances.push(instance);
|
||||
|
|
@ -965,12 +963,12 @@ impl Engine {
|
|||
// Add a MIDI note event to the specified clip in the pool
|
||||
// Note: clip_id here refers to the clip in the pool, not the instance
|
||||
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) {
|
||||
// Timestamp is now in seconds (sample-rate independent)
|
||||
let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
|
||||
// Timestamp is in beats (canonical)
|
||||
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity);
|
||||
clip.add_event(note_on);
|
||||
|
||||
// Add note off event
|
||||
let note_off_time = time_offset + duration;
|
||||
let note_off_time = Beats(time_offset + duration);
|
||||
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
||||
clip.add_event(note_off);
|
||||
} else {
|
||||
|
|
@ -979,9 +977,9 @@ impl Engine {
|
|||
if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) {
|
||||
let actual_clip_id = instance.clip_id;
|
||||
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) {
|
||||
let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
|
||||
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity);
|
||||
clip.add_event(note_on);
|
||||
let note_off_time = time_offset + duration;
|
||||
let note_off_time = Beats(time_offset + duration);
|
||||
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
||||
clip.add_event(note_off);
|
||||
}
|
||||
|
|
@ -991,14 +989,8 @@ impl Engine {
|
|||
}
|
||||
Command::AddLoadedMidiClip(track_id, clip, start_time) => {
|
||||
// Add a pre-loaded MIDI clip to the track with the given start time
|
||||
let bpm = self.current_bpm;
|
||||
let fps = self.current_fps;
|
||||
if let Ok(instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) {
|
||||
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||
if let Some(inst) = track.clip_instances.iter_mut().find(|i| i.id == instance_id) {
|
||||
inst.sync_from_seconds(bpm, fps);
|
||||
}
|
||||
}
|
||||
if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) {
|
||||
// instance positions are already in beats; nothing to sync
|
||||
}
|
||||
self.refresh_clip_snapshot();
|
||||
}
|
||||
|
|
@ -1009,13 +1001,13 @@ impl Engine {
|
|||
clip.events.clear();
|
||||
|
||||
// Add new events from the notes array
|
||||
// Timestamps are now stored in seconds (sample-rate independent)
|
||||
// Timestamps are in beats (canonical)
|
||||
for (start_time, note, velocity, duration) in notes {
|
||||
let note_on = MidiEvent::note_on(start_time, 0, note, velocity);
|
||||
let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity);
|
||||
clip.events.push(note_on);
|
||||
|
||||
// Add note off event
|
||||
let note_off_time = start_time + duration;
|
||||
let note_off_time = Beats(start_time + duration);
|
||||
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
||||
clip.events.push(note_off);
|
||||
}
|
||||
|
|
@ -1077,7 +1069,7 @@ impl Engine {
|
|||
}
|
||||
Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => {
|
||||
// Add an automation point to the specified lane
|
||||
let point = crate::audio::AutomationPoint::new(time, value, curve);
|
||||
let point = crate::audio::AutomationPoint::new(Beats(time), value, curve);
|
||||
|
||||
match self.project.get_track_mut(track_id) {
|
||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||
|
|
@ -1103,17 +1095,17 @@ impl Engine {
|
|||
match self.project.get_track_mut(track_id) {
|
||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
|
||||
lane.remove_point_at_time(time, tolerance);
|
||||
lane.remove_point_at_time(Beats(time), Beats(tolerance));
|
||||
}
|
||||
}
|
||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
|
||||
lane.remove_point_at_time(time, tolerance);
|
||||
lane.remove_point_at_time(Beats(time), Beats(tolerance));
|
||||
}
|
||||
}
|
||||
Some(crate::audio::track::TrackNode::Group(group)) => {
|
||||
if let Some(lane) = group.get_automation_lane_mut(lane_id) {
|
||||
lane.remove_point_at_time(time, tolerance);
|
||||
lane.remove_point_at_time(Beats(time), Beats(tolerance));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
|
|
@ -1250,9 +1242,9 @@ impl Engine {
|
|||
// If MIDI recording is active on this track, capture the event
|
||||
if let Some(recording) = &mut self.midi_recording_state {
|
||||
if recording.track_id == track_id {
|
||||
let absolute_time = self.playhead as f64 / self.sample_rate as f64;
|
||||
eprintln!("[MIDI_RECORDING] NoteOn captured: note={}, velocity={}, absolute_time={:.3}s, playhead={}, sample_rate={}",
|
||||
note, velocity, absolute_time, self.playhead, self.sample_rate);
|
||||
let absolute_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64));
|
||||
eprintln!("[MIDI_RECORDING] NoteOn captured: note={}, velocity={}, absolute_time={:.3} beats, playhead={}, sample_rate={}",
|
||||
note, velocity, absolute_time.0, self.playhead, self.sample_rate);
|
||||
recording.note_on(note, velocity, absolute_time);
|
||||
}
|
||||
}
|
||||
|
|
@ -1273,9 +1265,9 @@ impl Engine {
|
|||
// If MIDI recording is active on this track, capture the event
|
||||
if let Some(recording) = &mut self.midi_recording_state {
|
||||
if recording.track_id == track_id {
|
||||
let absolute_time = self.playhead as f64 / self.sample_rate as f64;
|
||||
eprintln!("[MIDI_RECORDING] NoteOff captured: note={}, absolute_time={:.3}s, playhead={}, sample_rate={}",
|
||||
note, absolute_time, self.playhead, self.sample_rate);
|
||||
let absolute_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64));
|
||||
eprintln!("[MIDI_RECORDING] NoteOff captured: note={}, absolute_time={:.3} beats, playhead={}, sample_rate={}",
|
||||
note, absolute_time.0, self.playhead, self.sample_rate);
|
||||
recording.note_off(note, absolute_time);
|
||||
}
|
||||
}
|
||||
|
|
@ -1301,16 +1293,17 @@ impl Engine {
|
|||
}
|
||||
|
||||
Command::SetTempo(bpm, time_sig) => {
|
||||
self.time_sig = time_sig;
|
||||
self.metronome.update_timing(bpm, time_sig);
|
||||
self.project.set_tempo(bpm, time_sig.0);
|
||||
self.current_bpm = bpm as f64;
|
||||
self.tempo_map.set_global_bpm(bpm as f64);
|
||||
}
|
||||
|
||||
Command::ApplyBpmChange(bpm, fps, midi_durations) => {
|
||||
self.current_bpm = bpm;
|
||||
self.current_fps = fps;
|
||||
self.project.apply_bpm_change(bpm, fps, &midi_durations);
|
||||
self.refresh_clip_snapshot();
|
||||
Command::SetTempoMap(map) => {
|
||||
let bpm0 = map.global_bpm() as f32;
|
||||
self.metronome.update_timing(bpm0, self.time_sig);
|
||||
self.project.set_tempo(bpm0, self.time_sig.0);
|
||||
self.tempo_map = map;
|
||||
}
|
||||
|
||||
// Node graph commands
|
||||
|
|
@ -1864,9 +1857,17 @@ impl Engine {
|
|||
let buffer_size = self.buffer_pool.buffer_size();
|
||||
if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||
let current = metatrack.current_subtracks();
|
||||
let has_subtrack_node = metatrack.audio_graph.node_indices().any(|idx| {
|
||||
metatrack.audio_graph.get_graph_node(idx)
|
||||
.map(|n| n.node.node_type() == "SubtrackInputs")
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
// No-op if subtrack list is unchanged (prevents every-frame graph rebuilds)
|
||||
if current == subtracks {
|
||||
// No-op if subtrack list is unchanged AND the graph is already initialised.
|
||||
// Without the `has_subtrack_node` guard a freshly-created metatrack
|
||||
// (empty graph, no SubtrackInputs) with zero subtracks would never get
|
||||
// its default Volume automation node.
|
||||
if current == subtracks && has_subtrack_node {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2174,15 +2175,20 @@ impl Engine {
|
|||
}
|
||||
};
|
||||
|
||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||
let graph = &mut track.instrument_graph;
|
||||
let graph = match self.project.get_track_mut(track_id) {
|
||||
Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
|
||||
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
|
||||
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(graph) = graph {
|
||||
let node_idx = NodeIndex::new(node_id as usize);
|
||||
|
||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||
// Downcast to AutomationInputNode using as_any_mut
|
||||
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
||||
let keyframe = AutomationKeyframe {
|
||||
time,
|
||||
time: Beats(time),
|
||||
value,
|
||||
interpolation,
|
||||
ease_out,
|
||||
|
|
@ -2199,13 +2205,18 @@ impl Engine {
|
|||
Command::AutomationRemoveKeyframe(track_id, node_id, time) => {
|
||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||
|
||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||
let graph = &mut track.instrument_graph;
|
||||
let graph = match self.project.get_track_mut(track_id) {
|
||||
Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
|
||||
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
|
||||
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(graph) = graph {
|
||||
let node_idx = NodeIndex::new(node_id as usize);
|
||||
|
||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
||||
auto_node.remove_keyframe_at_time(time, 0.001); // 1ms tolerance
|
||||
auto_node.remove_keyframe_at_time(Beats(time), Beats(0.001)); // 1ms tolerance
|
||||
} else {
|
||||
eprintln!("Node {} is not an AutomationInputNode", node_id);
|
||||
}
|
||||
|
|
@ -2216,8 +2227,13 @@ impl Engine {
|
|||
Command::AutomationSetName(track_id, node_id, name) => {
|
||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||
|
||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||
let graph = &mut track.instrument_graph;
|
||||
let graph = match self.project.get_track_mut(track_id) {
|
||||
Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
|
||||
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
|
||||
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(graph) = graph {
|
||||
let node_idx = NodeIndex::new(node_id as usize);
|
||||
|
||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||
|
|
@ -2514,7 +2530,7 @@ impl Engine {
|
|||
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
|
||||
use crate::command::MidiClipData;
|
||||
QueryResponse::MidiClipData(Ok(MidiClipData {
|
||||
duration: clip.duration,
|
||||
duration: clip.duration.0,
|
||||
events: clip.events.clone(),
|
||||
}))
|
||||
} else {
|
||||
|
|
@ -2526,12 +2542,15 @@ impl Engine {
|
|||
use crate::audio::node_graph::nodes::{AutomationInputNode, InterpolationType};
|
||||
use crate::command::types::AutomationKeyframeData;
|
||||
|
||||
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
|
||||
let graph = &track.instrument_graph;
|
||||
let graph = match self.project.get_track(track_id) {
|
||||
Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
|
||||
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
|
||||
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(graph) = graph {
|
||||
let node_idx = NodeIndex::new(node_id as usize);
|
||||
|
||||
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
||||
// Downcast to AutomationInputNode
|
||||
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||
let keyframes: Vec<AutomationKeyframeData> = auto_node.keyframes()
|
||||
.iter()
|
||||
|
|
@ -2542,9 +2561,8 @@ impl Engine {
|
|||
InterpolationType::Step => "step",
|
||||
InterpolationType::Hold => "hold",
|
||||
}.to_string();
|
||||
|
||||
AutomationKeyframeData {
|
||||
time: kf.time,
|
||||
time: kf.time.0,
|
||||
value: kf.value,
|
||||
interpolation: interpolation_str,
|
||||
ease_out: kf.ease_out,
|
||||
|
|
@ -2552,7 +2570,6 @@ impl Engine {
|
|||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
QueryResponse::AutomationKeyframes(Ok(keyframes))
|
||||
} else {
|
||||
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)))
|
||||
}
|
||||
} else {
|
||||
QueryResponse::AutomationKeyframes(Err(format!("Track {} not found or is not a MIDI track", track_id)))
|
||||
QueryResponse::AutomationKeyframes(Err(format!("Track {} not found", track_id)))
|
||||
}
|
||||
}
|
||||
|
||||
Query::GetAutomationName(track_id, node_id) => {
|
||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||
|
||||
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
|
||||
let graph = &track.instrument_graph;
|
||||
let graph = match self.project.get_track(track_id) {
|
||||
Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
|
||||
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
|
||||
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(graph) = graph {
|
||||
let node_idx = NodeIndex::new(node_id as usize);
|
||||
|
||||
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
||||
// Downcast to AutomationInputNode
|
||||
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||
QueryResponse::AutomationName(Ok(auto_node.display_name().to_string()))
|
||||
} else {
|
||||
|
|
@ -2583,17 +2603,21 @@ impl Engine {
|
|||
QueryResponse::AutomationName(Err(format!("Node {} not found in track {}", node_id, track_id)))
|
||||
}
|
||||
} else {
|
||||
QueryResponse::AutomationName(Err(format!("Track {} not found or is not a MIDI track", track_id)))
|
||||
QueryResponse::AutomationName(Err(format!("Track {} not found", track_id)))
|
||||
}
|
||||
}
|
||||
|
||||
Query::GetAutomationRange(track_id, node_id) => {
|
||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||
|
||||
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
|
||||
let graph = &track.instrument_graph;
|
||||
let graph = match self.project.get_track(track_id) {
|
||||
Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
|
||||
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
|
||||
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(graph) = graph {
|
||||
let node_idx = NodeIndex::new(node_id as usize);
|
||||
|
||||
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
||||
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||
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)))
|
||||
}
|
||||
} 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) => {
|
||||
// Add MIDI clip to track and return the instance ID
|
||||
let bpm = self.current_bpm;
|
||||
let fps = self.current_fps;
|
||||
let result = match self.project.add_midi_clip_at(track_id, clip, start_time) {
|
||||
Ok(instance_id) => {
|
||||
// Sync beats/frames on the newly created instance
|
||||
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||
if let Some(inst) = track.clip_instances.iter_mut().find(|i| i.id == instance_id) {
|
||||
inst.sync_from_seconds(bpm, fps);
|
||||
}
|
||||
}
|
||||
QueryResponse::MidiClipInstanceAdded(Ok(instance_id))
|
||||
}
|
||||
// Add MIDI clip to track and return the instance ID (positions already in beats)
|
||||
let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) {
|
||||
Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
|
||||
Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())),
|
||||
};
|
||||
self.refresh_clip_snapshot();
|
||||
|
|
@ -2769,10 +2783,9 @@ impl Engine {
|
|||
}
|
||||
Query::AddMidiClipInstanceSync(track_id, mut instance) => {
|
||||
// Add MIDI clip instance to track (clip must already be in pool)
|
||||
// Assign instance ID
|
||||
// Assign instance ID; positions are already in beats
|
||||
let instance_id = self.project.next_midi_clip_instance_id();
|
||||
instance.id = instance_id;
|
||||
instance.sync_from_seconds(self.current_bpm, self.current_fps);
|
||||
|
||||
let result = match self.project.add_midi_clip_instance(track_id, instance) {
|
||||
Ok(_) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
|
||||
|
|
@ -2940,7 +2953,7 @@ impl Engine {
|
|||
}
|
||||
|
||||
/// Handle starting a recording
|
||||
fn handle_start_recording(&mut self, track_id: TrackId, start_time: f64) {
|
||||
fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats) {
|
||||
use crate::io::WavWriter;
|
||||
use std::env;
|
||||
|
||||
|
|
@ -2963,10 +2976,10 @@ impl Engine {
|
|||
let clip = crate::audio::clip::Clip::new(
|
||||
clip_id,
|
||||
0, // Temporary pool index, will be updated on finalization
|
||||
0.0, // internal_start
|
||||
0.0, // internal_end - Duration starts at 0, will be updated during recording
|
||||
start_time, // external_start (timeline position)
|
||||
start_time, // external_end - will be updated during recording
|
||||
Seconds(0.0), // internal_start
|
||||
Seconds(0.0), // internal_end - Duration starts at 0, will be updated during recording
|
||||
start_time, // external_start (timeline position, already Beats)
|
||||
Beats(0.0), // external_duration - will be updated during recording
|
||||
);
|
||||
|
||||
// Add clip to track
|
||||
|
|
@ -3095,7 +3108,7 @@ impl Engine {
|
|||
}
|
||||
|
||||
/// Handle starting MIDI recording
|
||||
fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) {
|
||||
fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) {
|
||||
// Check if track exists and is a MIDI track
|
||||
if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) {
|
||||
// Create MIDI recording state
|
||||
|
|
@ -3105,7 +3118,7 @@ impl Engine {
|
|||
// so they start at t=0 of the recording rather than being lost
|
||||
if let Some(held) = self.midi_held_notes.get(&track_id) {
|
||||
for (¬e, &velocity) in held {
|
||||
eprintln!("[MIDI_RECORDING] Injecting held note {} vel {} at start_time {:.3}s", note, velocity, start_time);
|
||||
eprintln!("[MIDI_RECORDING] Injecting held note {} vel {} at start_time {:.3}", note, velocity, start_time.0);
|
||||
recording_state.note_on(note, velocity, start_time);
|
||||
}
|
||||
}
|
||||
|
|
@ -3132,8 +3145,8 @@ impl Engine {
|
|||
}
|
||||
|
||||
// Close out any active notes at the current playhead position
|
||||
let end_time = self.playhead as f64 / self.sample_rate as f64;
|
||||
eprintln!("[MIDI_RECORDING] Closing active notes at time {}", end_time);
|
||||
let end_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64));
|
||||
eprintln!("[MIDI_RECORDING] Closing active notes at time {}", end_time.0);
|
||||
recording.close_active_notes(end_time);
|
||||
|
||||
let clip_id = recording.clip_id;
|
||||
|
|
@ -3142,8 +3155,8 @@ impl Engine {
|
|||
let note_count = notes.len();
|
||||
let recording_duration = end_time - recording.start_time;
|
||||
|
||||
eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3}s",
|
||||
clip_id, track_id, note_count, recording_duration);
|
||||
eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats",
|
||||
clip_id, track_id, note_count, recording_duration.0);
|
||||
|
||||
// Update the MIDI clip in the pool (new model: clips are stored centrally in the pool)
|
||||
eprintln!("[MIDI_RECORDING] Looking for clip {} in midi_clip_pool", clip_id);
|
||||
|
|
@ -3152,16 +3165,17 @@ impl Engine {
|
|||
// Clear existing events
|
||||
clip.events.clear();
|
||||
|
||||
// Update clip duration to match the actual recording time
|
||||
clip.duration = recording_duration;
|
||||
// Recording duration is already in beats (canonical)
|
||||
let duration_beats = recording_duration;
|
||||
clip.duration = duration_beats;
|
||||
|
||||
// Add new events from the recorded notes
|
||||
// Timestamps are now stored in seconds (sample-rate independent)
|
||||
// Add new events from the recorded notes.
|
||||
// Recorded timestamps are already in beats (canonical).
|
||||
for (start_time, note, velocity, duration) in notes.iter() {
|
||||
let note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity);
|
||||
|
||||
eprintln!("[MIDI_RECORDING] Note {}: start_time={:.3}s, duration={:.3}s",
|
||||
note, start_time, duration);
|
||||
eprintln!("[MIDI_RECORDING] Note {}: start={:.3} beats, duration={:.3} beats",
|
||||
note, start_time.0, duration.0);
|
||||
|
||||
clip.events.push(note_on);
|
||||
|
||||
|
|
@ -3171,17 +3185,17 @@ impl Engine {
|
|||
clip.events.push(note_off);
|
||||
}
|
||||
|
||||
// Sort events by timestamp (using partial_cmp for f64)
|
||||
// Sort events by timestamp (using partial_cmp for Beats)
|
||||
clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||
eprintln!("[MIDI_RECORDING] Updated clip {} with {} notes ({} events)", clip_id, note_count, clip.events.len());
|
||||
|
||||
// Also update the clip instance's internal_end and external_duration to match the recording duration
|
||||
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||
if let Some(instance) = track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) {
|
||||
instance.internal_end = recording_duration;
|
||||
instance.external_duration = recording_duration;
|
||||
eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3}s, external_duration={:.3}s",
|
||||
instance.internal_end, instance.external_duration);
|
||||
instance.internal_end = duration_beats;
|
||||
instance.external_duration = duration_beats;
|
||||
eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3} beats, external_duration={:.3} beats",
|
||||
instance.internal_end.0, instance.external_duration.0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -3627,9 +3641,15 @@ impl EngineController {
|
|||
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
|
||||
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
|
||||
|
|
@ -3649,7 +3669,7 @@ impl EngineController {
|
|||
|
||||
/// Start MIDI recording on a track
|
||||
pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) {
|
||||
let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time));
|
||||
let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, Beats(start_time)));
|
||||
}
|
||||
|
||||
/// Stop the current MIDI recording
|
||||
|
|
@ -3687,12 +3707,15 @@ impl EngineController {
|
|||
let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature));
|
||||
}
|
||||
|
||||
/// After a BPM change: update MIDI clip durations and sync all clip beats/frames.
|
||||
/// Call this after move_clip() has been called for all affected clips.
|
||||
pub fn apply_bpm_change(&mut self, bpm: f64, fps: f64, midi_durations: Vec<(crate::audio::MidiClipId, f64)>) {
|
||||
let _ = self.command_tx.push(Command::ApplyBpmChange(bpm, fps, midi_durations));
|
||||
/// Replace the full tempo map (multi-entry variable tempo)
|
||||
pub fn set_tempo_map(&mut self, map: crate::TempoMap) {
|
||||
let _ = self.command_tx.push(Command::SetTempoMap(map));
|
||||
}
|
||||
|
||||
/// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale
|
||||
/// automation keyframe times to preserve beat positions.
|
||||
/// `from_bpm` is the BPM before the change; `to_bpm` is the new BPM.
|
||||
/// Call this after move_clip() has been called for all affected clips.
|
||||
// Node graph operations
|
||||
|
||||
/// Add a node to a track's instrument graph
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ use super::buffer_pool::BufferPool;
|
|||
use super::pool::AudioPool;
|
||||
use super::project::Project;
|
||||
use crate::command::AudioEvent;
|
||||
use crate::tempo_map::TempoMap;
|
||||
use crate::time::Seconds;
|
||||
use std::path::Path;
|
||||
|
||||
/// Render chunk size for offline export. Matches the real-time playback buffer size
|
||||
|
|
@ -42,10 +44,12 @@ pub struct ExportSettings {
|
|||
pub bit_depth: u16,
|
||||
/// MP3 bitrate in kbps (128, 192, 256, 320)
|
||||
pub mp3_bitrate: u32,
|
||||
/// Start time in seconds
|
||||
pub start_time: f64,
|
||||
/// End time in seconds
|
||||
pub end_time: f64,
|
||||
/// Start time
|
||||
pub start_time: Seconds,
|
||||
/// End time
|
||||
pub end_time: Seconds,
|
||||
/// Tempo map for beat-position scheduling
|
||||
pub tempo_map: TempoMap,
|
||||
}
|
||||
|
||||
impl Default for ExportSettings {
|
||||
|
|
@ -56,8 +60,9 @@ impl Default for ExportSettings {
|
|||
channels: 2,
|
||||
bit_depth: 16,
|
||||
mp3_bitrate: 320,
|
||||
start_time: 0.0,
|
||||
end_time: 60.0,
|
||||
start_time: Seconds::ZERO,
|
||||
end_time: Seconds(60.0),
|
||||
tempo_map: TempoMap::constant(120.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,15 +84,15 @@ pub fn export_audio<P: AsRef<Path>>(
|
|||
{
|
||||
// Validate duration
|
||||
let duration = settings.end_time - settings.start_time;
|
||||
if duration <= 0.0 {
|
||||
if duration <= Seconds::ZERO {
|
||||
return Err(format!(
|
||||
"Export duration is zero or negative (start={:.3}s, end={:.3}s). \
|
||||
Check that the timeline has content.",
|
||||
settings.start_time, settings.end_time
|
||||
settings.start_time.seconds_to_f64(), settings.end_time.seconds_to_f64()
|
||||
));
|
||||
}
|
||||
|
||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
||||
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
|
||||
if total_frames == 0 {
|
||||
return Err("Export would produce zero audio frames".to_string());
|
||||
}
|
||||
|
|
@ -148,11 +153,11 @@ pub fn render_to_memory(
|
|||
{
|
||||
// Calculate total number of frames
|
||||
let duration = settings.end_time - settings.start_time;
|
||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
||||
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
|
||||
let total_samples = total_frames * settings.channels as usize;
|
||||
|
||||
println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}",
|
||||
duration, total_frames, total_samples, settings.channels);
|
||||
duration.seconds_to_f64(), total_frames, total_samples, settings.channels);
|
||||
|
||||
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
||||
|
||||
|
|
@ -178,6 +183,7 @@ pub fn render_to_memory(
|
|||
pool,
|
||||
&mut buffer_pool,
|
||||
playhead,
|
||||
&settings.tempo_map,
|
||||
settings.sample_rate,
|
||||
settings.channels,
|
||||
false,
|
||||
|
|
@ -185,9 +191,9 @@ pub fn render_to_memory(
|
|||
|
||||
// Calculate how many samples we actually need from this chunk
|
||||
let remaining_time = settings.end_time - playhead;
|
||||
let samples_needed = if remaining_time < chunk_duration {
|
||||
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
|
||||
// Calculate frames needed and ensure it's a whole number
|
||||
let frames_needed = (remaining_time * settings.sample_rate as f64).round() as usize;
|
||||
let frames_needed = (remaining_time.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
|
||||
let samples = frames_needed * settings.channels as usize;
|
||||
// Ensure we don't exceed chunk size
|
||||
samples.min(chunk_samples)
|
||||
|
|
@ -207,7 +213,7 @@ pub fn render_to_memory(
|
|||
});
|
||||
}
|
||||
|
||||
playhead += chunk_duration;
|
||||
playhead = playhead + Seconds(chunk_duration);
|
||||
}
|
||||
|
||||
println!("Export: rendered {} samples total", all_samples.len());
|
||||
|
|
@ -361,7 +367,7 @@ fn export_mp3<P: AsRef<Path>>(
|
|||
|
||||
// Calculate rendering parameters
|
||||
let duration = settings.end_time - settings.start_time;
|
||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
||||
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
|
||||
|
||||
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
||||
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
||||
|
|
@ -396,6 +402,7 @@ fn export_mp3<P: AsRef<Path>>(
|
|||
pool,
|
||||
&mut buffer_pool,
|
||||
playhead,
|
||||
&settings.tempo_map,
|
||||
settings.sample_rate,
|
||||
settings.channels,
|
||||
false,
|
||||
|
|
@ -403,8 +410,8 @@ fn export_mp3<P: AsRef<Path>>(
|
|||
|
||||
// Calculate how many samples we need from this chunk
|
||||
let remaining_time = settings.end_time - playhead;
|
||||
let samples_needed = if remaining_time < chunk_duration {
|
||||
((remaining_time * settings.sample_rate as f64) as usize * settings.channels as usize)
|
||||
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
|
||||
((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize)
|
||||
.min(chunk_samples)
|
||||
} else {
|
||||
chunk_samples
|
||||
|
|
@ -445,7 +452,7 @@ fn export_mp3<P: AsRef<Path>>(
|
|||
}
|
||||
}
|
||||
|
||||
playhead += chunk_duration;
|
||||
playhead = playhead + Seconds(chunk_duration);
|
||||
}
|
||||
|
||||
// Encode any remaining samples as the final frame
|
||||
|
|
@ -529,7 +536,7 @@ fn export_aac<P: AsRef<Path>>(
|
|||
|
||||
// Calculate rendering parameters
|
||||
let duration = settings.end_time - settings.start_time;
|
||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
||||
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
|
||||
|
||||
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
||||
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
||||
|
|
@ -564,6 +571,7 @@ fn export_aac<P: AsRef<Path>>(
|
|||
pool,
|
||||
&mut buffer_pool,
|
||||
playhead,
|
||||
&settings.tempo_map,
|
||||
settings.sample_rate,
|
||||
settings.channels,
|
||||
false,
|
||||
|
|
@ -571,8 +579,8 @@ fn export_aac<P: AsRef<Path>>(
|
|||
|
||||
// Calculate how many samples we need from this chunk
|
||||
let remaining_time = settings.end_time - playhead;
|
||||
let samples_needed = if remaining_time < chunk_duration {
|
||||
((remaining_time * settings.sample_rate as f64) as usize * settings.channels as usize)
|
||||
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
|
||||
((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize)
|
||||
.min(chunk_samples)
|
||||
} else {
|
||||
chunk_samples
|
||||
|
|
@ -613,7 +621,7 @@ fn export_aac<P: AsRef<Path>>(
|
|||
}
|
||||
}
|
||||
|
||||
playhead += chunk_duration;
|
||||
playhead = playhead + Seconds(chunk_duration);
|
||||
}
|
||||
|
||||
// Encode any remaining samples as the final frame
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
use crate::time::Beats;
|
||||
|
||||
/// MIDI event representing a single MIDI message
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MidiEvent {
|
||||
/// Time position within the clip in seconds (sample-rate independent)
|
||||
pub timestamp: f64,
|
||||
/// Time position in beats (quarter-note beats from clip start); derived from timestamp
|
||||
#[serde(default)]
|
||||
pub timestamp_beats: f64,
|
||||
/// Time position in frames; derived from timestamp
|
||||
#[serde(default)]
|
||||
pub timestamp_frames: f64,
|
||||
/// Time position in beats (quarter-note beats)
|
||||
pub timestamp: Beats,
|
||||
/// MIDI status byte (includes channel)
|
||||
pub status: u8,
|
||||
/// First data byte (note number, CC number, etc.)
|
||||
|
|
@ -18,79 +14,28 @@ pub struct MidiEvent {
|
|||
}
|
||||
|
||||
impl MidiEvent {
|
||||
/// Create a new MIDI event
|
||||
pub fn new(timestamp: f64, status: u8, data1: u8, data2: u8) -> Self {
|
||||
Self {
|
||||
timestamp,
|
||||
timestamp_beats: 0.0,
|
||||
timestamp_frames: 0.0,
|
||||
status,
|
||||
data1,
|
||||
data2,
|
||||
}
|
||||
pub fn new(timestamp: Beats, status: u8, data1: u8, data2: u8) -> Self {
|
||||
Self { timestamp, status, data1, data2 }
|
||||
}
|
||||
|
||||
/// Create a note on event
|
||||
pub fn note_on(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self {
|
||||
Self {
|
||||
timestamp,
|
||||
timestamp_beats: 0.0,
|
||||
timestamp_frames: 0.0,
|
||||
status: 0x90 | (channel & 0x0F),
|
||||
data1: note,
|
||||
data2: velocity,
|
||||
}
|
||||
pub fn note_on(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self {
|
||||
Self { timestamp, status: 0x90 | (channel & 0x0F), data1: note, data2: velocity }
|
||||
}
|
||||
|
||||
/// Create a note off event
|
||||
pub fn note_off(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self {
|
||||
Self {
|
||||
timestamp,
|
||||
timestamp_beats: 0.0,
|
||||
timestamp_frames: 0.0,
|
||||
status: 0x80 | (channel & 0x0F),
|
||||
data1: note,
|
||||
data2: velocity,
|
||||
}
|
||||
pub fn note_off(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self {
|
||||
Self { timestamp, status: 0x80 | (channel & 0x0F), data1: note, data2: velocity }
|
||||
}
|
||||
|
||||
/// Sync beats and frames from seconds (call after constructing or when seconds is canonical)
|
||||
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
|
||||
self.timestamp_beats = self.timestamp * bpm / 60.0;
|
||||
self.timestamp_frames = self.timestamp * fps;
|
||||
}
|
||||
|
||||
/// Recompute seconds and frames from beats (call when BPM changes in Measures mode)
|
||||
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
|
||||
self.timestamp = self.timestamp_beats * 60.0 / bpm;
|
||||
self.timestamp_frames = self.timestamp * fps;
|
||||
}
|
||||
|
||||
/// Recompute seconds and beats from frames (call when FPS changes in Frames mode)
|
||||
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
|
||||
self.timestamp = self.timestamp_frames / fps;
|
||||
self.timestamp_beats = self.timestamp * bpm / 60.0;
|
||||
}
|
||||
|
||||
/// Check if this is a note on event (with non-zero velocity)
|
||||
pub fn is_note_on(&self) -> bool {
|
||||
(self.status & 0xF0) == 0x90 && self.data2 > 0
|
||||
}
|
||||
|
||||
/// Check if this is a note off event (or note on with zero velocity)
|
||||
pub fn is_note_off(&self) -> bool {
|
||||
(self.status & 0xF0) == 0x80 || ((self.status & 0xF0) == 0x90 && self.data2 == 0)
|
||||
}
|
||||
|
||||
/// Get the MIDI channel (0-15)
|
||||
pub fn channel(&self) -> u8 {
|
||||
self.status & 0x0F
|
||||
}
|
||||
|
||||
/// Get the message type (upper 4 bits of status)
|
||||
pub fn message_type(&self) -> u8 {
|
||||
self.status & 0xF0
|
||||
}
|
||||
pub fn channel(&self) -> u8 { self.status & 0x0F }
|
||||
pub fn message_type(&self) -> u8 { self.status & 0xF0 }
|
||||
}
|
||||
|
||||
/// MIDI clip ID type (for clips stored in the pool)
|
||||
|
|
@ -99,240 +44,118 @@ pub type MidiClipId = u32;
|
|||
/// MIDI clip instance ID type (for instances placed on tracks)
|
||||
pub type MidiClipInstanceId = u32;
|
||||
|
||||
/// MIDI clip content - stores the actual MIDI events
|
||||
///
|
||||
/// This represents the content data stored in the MidiClipPool.
|
||||
/// Events have timestamps relative to the start of the clip (0.0 = clip beginning).
|
||||
/// MIDI clip content — stores the actual MIDI events.
|
||||
/// `duration` is in beats.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MidiClip {
|
||||
pub id: MidiClipId,
|
||||
pub events: Vec<MidiEvent>,
|
||||
pub duration: f64, // Total content duration in seconds
|
||||
/// Total content duration in beats
|
||||
pub duration: Beats,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl MidiClip {
|
||||
/// Create a new MIDI clip with content
|
||||
pub fn new(id: MidiClipId, events: Vec<MidiEvent>, duration: f64, name: String) -> Self {
|
||||
let mut clip = Self {
|
||||
id,
|
||||
events,
|
||||
duration,
|
||||
name,
|
||||
};
|
||||
// Sort events by timestamp
|
||||
pub fn new(id: MidiClipId, events: Vec<MidiEvent>, duration: Beats, name: String) -> Self {
|
||||
let mut clip = Self { id, events, duration, name };
|
||||
clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||
clip
|
||||
}
|
||||
|
||||
/// Create an empty MIDI clip
|
||||
pub fn empty(id: MidiClipId, duration: f64, name: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
events: Vec::new(),
|
||||
duration,
|
||||
name,
|
||||
}
|
||||
pub fn empty(id: MidiClipId, duration: Beats, name: String) -> Self {
|
||||
Self { id, events: Vec::new(), duration, name }
|
||||
}
|
||||
|
||||
/// Add a MIDI event to the clip
|
||||
pub fn add_event(&mut self, event: MidiEvent) {
|
||||
self.events.push(event);
|
||||
// Keep events sorted by timestamp
|
||||
self.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||
}
|
||||
|
||||
/// Get events within a time range (relative to clip start)
|
||||
/// This is used by MidiClipInstance to fetch events for a given portion
|
||||
pub fn get_events_in_range(&self, start: f64, end: f64) -> Vec<MidiEvent> {
|
||||
self.events
|
||||
.iter()
|
||||
/// Get events within a beat range (relative to clip start)
|
||||
pub fn get_events_in_range(&self, start: Beats, end: Beats) -> Vec<MidiEvent> {
|
||||
self.events.iter()
|
||||
.filter(|e| e.timestamp >= start && e.timestamp < end)
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// MIDI clip instance - a reference to MidiClip content with timeline positioning
|
||||
/// MIDI clip instance — a reference to MidiClip content with timeline positioning.
|
||||
///
|
||||
/// ## Timing Model
|
||||
/// - `internal_start` / `internal_end`: Define the region of the source clip to play (trimming)
|
||||
/// - `external_start` / `external_duration`: Define where the instance appears on the timeline and how long
|
||||
/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display
|
||||
///
|
||||
/// ## Looping
|
||||
/// If `external_duration` is greater than `internal_end - internal_start`,
|
||||
/// the instance will seamlessly loop back to `internal_start` when it reaches `internal_end`.
|
||||
/// All timing fields are in beats.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MidiClipInstance {
|
||||
pub id: MidiClipInstanceId,
|
||||
pub clip_id: MidiClipId, // Reference to MidiClip in pool
|
||||
pub clip_id: MidiClipId,
|
||||
|
||||
/// Start position within the clip content (seconds)
|
||||
pub internal_start: f64,
|
||||
#[serde(default)] pub internal_start_beats: f64,
|
||||
#[serde(default)] pub internal_start_frames: f64,
|
||||
/// End position within the clip content (seconds)
|
||||
pub internal_end: f64,
|
||||
#[serde(default)] pub internal_end_beats: f64,
|
||||
#[serde(default)] pub internal_end_frames: f64,
|
||||
/// Start of the trimmed region within the clip content (beats)
|
||||
pub internal_start: Beats,
|
||||
/// End of the trimmed region within the clip content (beats)
|
||||
pub internal_end: Beats,
|
||||
|
||||
/// Start position on the timeline (seconds)
|
||||
pub external_start: f64,
|
||||
#[serde(default)] pub external_start_beats: f64,
|
||||
#[serde(default)] pub external_start_frames: f64,
|
||||
/// Duration on the timeline (seconds) - can be longer than internal duration for looping
|
||||
pub external_duration: f64,
|
||||
#[serde(default)] pub external_duration_beats: f64,
|
||||
#[serde(default)] pub external_duration_frames: f64,
|
||||
/// Start position on the timeline (beats)
|
||||
pub external_start: Beats,
|
||||
/// Duration on the timeline (beats); > internal duration = looping
|
||||
pub external_duration: Beats,
|
||||
}
|
||||
|
||||
impl MidiClipInstance {
|
||||
/// Create a new MIDI clip instance
|
||||
pub fn new(
|
||||
id: MidiClipInstanceId,
|
||||
clip_id: MidiClipId,
|
||||
internal_start: f64,
|
||||
internal_end: f64,
|
||||
external_start: f64,
|
||||
external_duration: f64,
|
||||
internal_start: Beats,
|
||||
internal_end: Beats,
|
||||
external_start: Beats,
|
||||
external_duration: Beats,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
clip_id,
|
||||
internal_start,
|
||||
internal_start_beats: 0.0,
|
||||
internal_start_frames: 0.0,
|
||||
internal_end,
|
||||
internal_end_beats: 0.0,
|
||||
internal_end_frames: 0.0,
|
||||
external_start,
|
||||
external_start_beats: 0.0,
|
||||
external_start_frames: 0.0,
|
||||
external_duration,
|
||||
external_duration_beats: 0.0,
|
||||
external_duration_frames: 0.0,
|
||||
}
|
||||
Self { id, clip_id, internal_start, internal_end, external_start, external_duration }
|
||||
}
|
||||
|
||||
/// Create an instance that uses the full clip content (no trimming, no looping)
|
||||
/// Create an instance covering the full clip with no trim
|
||||
pub fn from_full_clip(
|
||||
id: MidiClipInstanceId,
|
||||
clip_id: MidiClipId,
|
||||
clip_duration: f64,
|
||||
external_start: f64,
|
||||
clip_duration: Beats,
|
||||
external_start: Beats,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
clip_id,
|
||||
internal_start: 0.0,
|
||||
internal_start_beats: 0.0,
|
||||
internal_start_frames: 0.0,
|
||||
internal_start: Beats::ZERO,
|
||||
internal_end: clip_duration,
|
||||
internal_end_beats: 0.0,
|
||||
internal_end_frames: 0.0,
|
||||
external_start,
|
||||
external_start_beats: 0.0,
|
||||
external_start_frames: 0.0,
|
||||
external_duration: clip_duration,
|
||||
external_duration_beats: 0.0,
|
||||
external_duration_frames: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the internal (content) duration
|
||||
pub fn internal_duration(&self) -> f64 {
|
||||
self.internal_end - self.internal_start
|
||||
}
|
||||
pub fn internal_duration(&self) -> Beats { self.internal_end - self.internal_start }
|
||||
pub fn external_end(&self) -> Beats { self.external_start + self.external_duration }
|
||||
pub fn is_looping(&self) -> bool { self.external_duration > self.internal_duration() }
|
||||
|
||||
/// Get the end time on the timeline
|
||||
pub fn external_end(&self) -> f64 {
|
||||
self.external_start + self.external_duration
|
||||
}
|
||||
|
||||
/// Check if this instance loops
|
||||
pub fn is_looping(&self) -> bool {
|
||||
self.external_duration > self.internal_duration()
|
||||
}
|
||||
|
||||
/// Get the end time on the timeline (for backwards compatibility)
|
||||
pub fn end_time(&self) -> f64 {
|
||||
self.external_end()
|
||||
}
|
||||
|
||||
/// Get the start time on the timeline (for backwards compatibility)
|
||||
pub fn start_time(&self) -> f64 {
|
||||
self.external_start
|
||||
}
|
||||
|
||||
/// Check if this instance overlaps with a time range
|
||||
pub fn overlaps_range(&self, range_start: f64, range_end: f64) -> bool {
|
||||
/// Check if this instance overlaps with a beat range
|
||||
pub fn overlaps_range(&self, range_start: Beats, range_end: Beats) -> bool {
|
||||
self.external_start < range_end && self.external_end() > range_start
|
||||
}
|
||||
|
||||
/// Populate beats/frames from the current seconds values.
|
||||
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
|
||||
self.external_start_beats = self.external_start * bpm / 60.0;
|
||||
self.external_start_frames = self.external_start * fps;
|
||||
self.external_duration_beats = self.external_duration * bpm / 60.0;
|
||||
self.external_duration_frames = self.external_duration * fps;
|
||||
self.internal_start_beats = self.internal_start * bpm / 60.0;
|
||||
self.internal_start_frames = self.internal_start * fps;
|
||||
self.internal_end_beats = self.internal_end * bpm / 60.0;
|
||||
self.internal_end_frames = self.internal_end * fps;
|
||||
}
|
||||
|
||||
/// BPM changed; recompute seconds/frames from the stored beats values.
|
||||
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
|
||||
self.external_start = self.external_start_beats * 60.0 / bpm;
|
||||
self.external_start_frames = self.external_start * fps;
|
||||
self.external_duration = self.external_duration_beats * 60.0 / bpm;
|
||||
self.external_duration_frames = self.external_duration * fps;
|
||||
self.internal_start = self.internal_start_beats * 60.0 / bpm;
|
||||
self.internal_start_frames = self.internal_start * fps;
|
||||
self.internal_end = self.internal_end_beats * 60.0 / bpm;
|
||||
self.internal_end_frames = self.internal_end * fps;
|
||||
}
|
||||
|
||||
/// FPS changed; recompute seconds/beats from the stored frames values.
|
||||
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
|
||||
self.external_start = self.external_start_frames / fps;
|
||||
self.external_start_beats = self.external_start * bpm / 60.0;
|
||||
self.external_duration = self.external_duration_frames / fps;
|
||||
self.external_duration_beats = self.external_duration * bpm / 60.0;
|
||||
self.internal_start = self.internal_start_frames / fps;
|
||||
self.internal_start_beats = self.internal_start * bpm / 60.0;
|
||||
self.internal_end = self.internal_end_frames / fps;
|
||||
self.internal_end_beats = self.internal_end * bpm / 60.0;
|
||||
}
|
||||
|
||||
/// Get events that should be triggered in a given timeline range
|
||||
///
|
||||
/// This handles:
|
||||
/// - Trimming (internal_start/internal_end)
|
||||
/// - Looping (when external duration > internal duration)
|
||||
/// - Time mapping from timeline to clip content
|
||||
///
|
||||
/// Returns events with timestamps adjusted to timeline time (not clip-relative)
|
||||
/// Get events that should fire in a given beat range on the timeline.
|
||||
/// Returns events with `timestamp` set to their global timeline beat position.
|
||||
pub fn get_events_in_range(
|
||||
&self,
|
||||
clip: &MidiClip,
|
||||
range_start_seconds: f64,
|
||||
range_end_seconds: f64,
|
||||
range_start: Beats,
|
||||
range_end: Beats,
|
||||
) -> Vec<MidiEvent> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
// Check if instance overlaps with the range
|
||||
if !self.overlaps_range(range_start_seconds, range_end_seconds) {
|
||||
if !self.overlaps_range(range_start, range_end) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let internal_duration = self.internal_duration();
|
||||
if internal_duration <= 0.0 {
|
||||
if internal_duration <= Beats::ZERO {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Calculate how many complete loops fit in the external duration
|
||||
let num_loops = if self.external_duration > internal_duration {
|
||||
(self.external_duration / internal_duration).ceil() as usize
|
||||
} else {
|
||||
|
|
@ -342,24 +165,18 @@ impl MidiClipInstance {
|
|||
let external_end = self.external_end();
|
||||
|
||||
for loop_idx in 0..num_loops {
|
||||
let loop_offset = loop_idx as f64 * internal_duration;
|
||||
let loop_offset = internal_duration * loop_idx as f64;
|
||||
|
||||
// Get events from the clip that fall within the internal range
|
||||
for event in &clip.events {
|
||||
// Skip events outside the trimmed region
|
||||
// Use > (not >=) for internal_end so note-offs at the clip boundary are included
|
||||
if event.timestamp < self.internal_start || event.timestamp > self.internal_end {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to timeline time
|
||||
let relative_content_time = event.timestamp - self.internal_start;
|
||||
let timeline_time = self.external_start + loop_offset + relative_content_time;
|
||||
|
||||
// Check if within current buffer range and instance bounds
|
||||
// Use <= for external_end so note-offs at the clip boundary are included
|
||||
if timeline_time >= range_start_seconds
|
||||
&& timeline_time < range_end_seconds
|
||||
if timeline_time >= range_start
|
||||
&& timeline_time < range_end
|
||||
&& timeline_time <= external_end
|
||||
{
|
||||
let mut adjusted_event = *event;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::HashMap;
|
||||
use super::midi::{MidiClip, MidiClipId, MidiEvent};
|
||||
use crate::time::Beats;
|
||||
|
||||
/// Pool for storing MIDI clip content
|
||||
/// Similar to AudioClipPool but for MIDI data
|
||||
|
|
@ -21,7 +22,7 @@ impl MidiClipPool {
|
|||
|
||||
/// Add a new clip to the pool with the given events and duration
|
||||
/// Returns the ID of the newly created clip
|
||||
pub fn add_clip(&mut self, events: Vec<MidiEvent>, duration: f64, name: String) -> MidiClipId {
|
||||
pub fn add_clip(&mut self, events: Vec<MidiEvent>, duration: Beats, name: String) -> MidiClipId {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use super::node_trait::AudioNode;
|
||||
use super::types::{ConnectionError, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use crate::time::Beats;
|
||||
use petgraph::algo::has_path_connecting;
|
||||
use petgraph::stable_graph::{NodeIndex, StableGraph};
|
||||
use petgraph::visit::{EdgeRef, IntoEdgeReferences};
|
||||
|
|
@ -93,8 +94,8 @@ pub struct AudioGraph {
|
|||
/// UI positions for nodes (node_index -> (x, y))
|
||||
node_positions: std::collections::HashMap<u32, (f32, f32)>,
|
||||
|
||||
/// Current playback time (for automation nodes)
|
||||
playback_time: f64,
|
||||
/// Current playback time in beats (for automation nodes)
|
||||
playback_time: Beats,
|
||||
|
||||
/// Project tempo (synced from Engine via SetTempo)
|
||||
bpm: f32,
|
||||
|
|
@ -123,7 +124,7 @@ impl AudioGraph {
|
|||
// Pre-allocate MIDI input buffers (max 128 events per port)
|
||||
midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(),
|
||||
node_positions: std::collections::HashMap::new(),
|
||||
playback_time: 0.0,
|
||||
playback_time: Beats::ZERO,
|
||||
bpm: 120.0,
|
||||
beats_per_bar: 4,
|
||||
topo_cache: None,
|
||||
|
|
@ -475,7 +476,7 @@ impl AudioGraph {
|
|||
}
|
||||
|
||||
/// Process the graph and produce audio output
|
||||
pub fn process(&mut self, output_buffer: &mut [f32], midi_events: &[MidiEvent], playback_time: f64) {
|
||||
pub fn process(&mut self, output_buffer: &mut [f32], midi_events: &[MidiEvent], playback_time: Beats) {
|
||||
// Update playback time
|
||||
self.playback_time = playback_time;
|
||||
|
||||
|
|
@ -484,6 +485,7 @@ impl AudioGraph {
|
|||
for node in self.graph.node_weights_mut() {
|
||||
if let Some(auto_node) = node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
||||
auto_node.set_playback_time(playback_time);
|
||||
auto_node.set_bpm(self.bpm as f64);
|
||||
} else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() {
|
||||
beat_node.set_playback_time(playback_time);
|
||||
beat_node.set_tempo(self.bpm, self.beats_per_bar);
|
||||
|
|
@ -1017,7 +1019,7 @@ impl AudioGraph {
|
|||
serialized.automation_display_name = Some(auto_node.display_name().to_string());
|
||||
serialized.automation_keyframes = auto_node.keyframes().iter().map(|kf| {
|
||||
SerializedKeyframe {
|
||||
time: kf.time,
|
||||
time: kf.time.0,
|
||||
value: kf.value,
|
||||
interpolation: match kf.interpolation {
|
||||
InterpolationType::Linear => "linear",
|
||||
|
|
@ -1357,7 +1359,7 @@ impl AudioGraph {
|
|||
auto_node.clear_keyframes();
|
||||
for kf in &serialized_node.automation_keyframes {
|
||||
auto_node.add_keyframe(AutomationKeyframe {
|
||||
time: kf.time,
|
||||
time: crate::time::Beats(kf.time),
|
||||
value: kf.value,
|
||||
interpolation: match kf.interpolation.as_str() {
|
||||
"bezier" => InterpolationType::Bezier,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use crate::time::Beats;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
|
|
@ -16,8 +17,7 @@ pub enum InterpolationType {
|
|||
/// A single keyframe in an automation curve
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutomationKeyframe {
|
||||
/// Time in seconds (absolute project time)
|
||||
pub time: f64,
|
||||
pub time: Beats,
|
||||
/// CV output value
|
||||
pub value: f32,
|
||||
/// Interpolation type to next keyframe
|
||||
|
|
@ -29,7 +29,7 @@ pub struct AutomationKeyframe {
|
|||
}
|
||||
|
||||
impl AutomationKeyframe {
|
||||
pub fn new(time: f64, value: f32) -> Self {
|
||||
pub fn new(time: Beats, value: f32) -> Self {
|
||||
Self {
|
||||
time,
|
||||
value,
|
||||
|
|
@ -47,8 +47,10 @@ pub struct AutomationInputNode {
|
|||
keyframes: Vec<AutomationKeyframe>,
|
||||
outputs: Vec<NodePort>,
|
||||
parameters: Vec<Parameter>,
|
||||
/// Shared playback time (set by the graph before processing)
|
||||
playback_time: Arc<RwLock<f64>>,
|
||||
/// Shared playback time in beats (set by the graph before processing)
|
||||
playback_time: Arc<RwLock<Beats>>,
|
||||
/// Current BPM (set by the graph before processing, used for per-sample beat advancement)
|
||||
bpm: f64,
|
||||
/// Minimum output value (for UI display range)
|
||||
pub value_min: f32,
|
||||
/// Maximum output value (for UI display range)
|
||||
|
|
@ -71,22 +73,26 @@ impl AutomationInputNode {
|
|||
Self {
|
||||
name: name.clone(),
|
||||
display_name: "Automation".to_string(),
|
||||
keyframes: vec![AutomationKeyframe::new(0.0, 0.0)],
|
||||
keyframes: vec![AutomationKeyframe::new(Beats::ZERO, 0.0)],
|
||||
outputs,
|
||||
parameters,
|
||||
playback_time: Arc::new(RwLock::new(0.0)),
|
||||
playback_time: Arc::new(RwLock::new(Beats::ZERO)),
|
||||
bpm: 120.0,
|
||||
value_min: -1.0,
|
||||
value_max: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the playback time (called by graph before processing)
|
||||
pub fn set_playback_time(&mut self, time: f64) {
|
||||
pub fn set_playback_time(&mut self, time: Beats) {
|
||||
if let Ok(mut playback) = self.playback_time.write() {
|
||||
*playback = time;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_bpm(&mut self, bpm: f64) {
|
||||
self.bpm = bpm;
|
||||
}
|
||||
|
||||
/// Get the display name (shown in UI)
|
||||
pub fn display_name(&self) -> &str {
|
||||
&self.display_name
|
||||
|
|
@ -116,8 +122,7 @@ impl AutomationInputNode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Remove keyframe at specific time (with tolerance)
|
||||
pub fn remove_keyframe_at_time(&mut self, time: f64, tolerance: f64) -> bool {
|
||||
pub fn remove_keyframe_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
|
||||
if let Some(idx) = self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) {
|
||||
self.keyframes.remove(idx);
|
||||
true
|
||||
|
|
@ -126,10 +131,8 @@ impl AutomationInputNode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Update an existing keyframe
|
||||
pub fn update_keyframe(&mut self, keyframe: AutomationKeyframe) {
|
||||
// Remove old keyframe at this time, then add new one
|
||||
self.remove_keyframe_at_time(keyframe.time, 0.001);
|
||||
self.remove_keyframe_at_time(keyframe.time, Beats(0.001));
|
||||
self.add_keyframe(keyframe);
|
||||
}
|
||||
|
||||
|
|
@ -143,24 +146,20 @@ impl AutomationInputNode {
|
|||
self.keyframes.clear();
|
||||
}
|
||||
|
||||
/// Evaluate curve at a specific time
|
||||
fn evaluate_at_time(&self, time: f64) -> f32 {
|
||||
fn evaluate_at_time(&self, time: Beats) -> f32 {
|
||||
if self.keyframes.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Before first keyframe
|
||||
if time <= self.keyframes[0].time {
|
||||
return self.keyframes[0].value;
|
||||
}
|
||||
|
||||
// After last keyframe
|
||||
let last_idx = self.keyframes.len() - 1;
|
||||
if time >= self.keyframes[last_idx].time {
|
||||
return self.keyframes[last_idx].value;
|
||||
}
|
||||
|
||||
// Find bracketing keyframes
|
||||
for i in 0..self.keyframes.len() - 1 {
|
||||
let kf1 = &self.keyframes[i];
|
||||
let kf2 = &self.keyframes[i + 1];
|
||||
|
|
@ -173,14 +172,12 @@ impl AutomationInputNode {
|
|||
0.0
|
||||
}
|
||||
|
||||
/// Interpolate between two keyframes
|
||||
fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: f64) -> f32 {
|
||||
// Calculate normalized position between keyframes (0.0 to 1.0)
|
||||
fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: Beats) -> f32 {
|
||||
let t = if kf2.time == kf1.time {
|
||||
0.0
|
||||
0.0f64
|
||||
} else {
|
||||
((time - kf1.time) / (kf2.time - kf1.time)) as f32
|
||||
};
|
||||
(time - kf1.time) / (kf2.time - kf1.time)
|
||||
} as f32;
|
||||
|
||||
match kf1.interpolation {
|
||||
InterpolationType::Linear => {
|
||||
|
|
@ -257,19 +254,17 @@ impl AudioNode for AutomationInputNode {
|
|||
let output = &mut outputs[0];
|
||||
let length = output.len();
|
||||
|
||||
// Get the starting playback time
|
||||
let playhead = if let Ok(playback) = self.playback_time.read() {
|
||||
*playback
|
||||
} else {
|
||||
0.0
|
||||
Beats::ZERO
|
||||
};
|
||||
|
||||
// Calculate time per sample
|
||||
let sample_duration = 1.0 / sample_rate as f64;
|
||||
// Advance per sample in beats: beats_per_sample = bpm / 60 / sample_rate
|
||||
let beats_per_sample = self.bpm / 60.0 / sample_rate as f64;
|
||||
|
||||
// Evaluate curve for each sample
|
||||
for i in 0..length {
|
||||
let time = playhead + (i as f64 * sample_duration);
|
||||
let time = playhead + Beats(i as f64 * beats_per_sample);
|
||||
output[i] = self.evaluate_at_time(time);
|
||||
}
|
||||
}
|
||||
|
|
@ -293,7 +288,8 @@ impl AudioNode for AutomationInputNode {
|
|||
keyframes: self.keyframes.clone(),
|
||||
outputs: self.outputs.clone(),
|
||||
parameters: self.parameters.clone(),
|
||||
playback_time: Arc::new(RwLock::new(0.0)),
|
||||
playback_time: Arc::new(RwLock::new(Beats::ZERO)),
|
||||
bpm: self.bpm,
|
||||
value_min: self.value_min,
|
||||
value_max: self.value_max,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use crate::time::Beats;
|
||||
|
||||
const PARAM_RESOLUTION: u32 = 0;
|
||||
|
||||
|
|
@ -61,12 +62,12 @@ pub struct BeatNode {
|
|||
bpm: f32,
|
||||
beats_per_bar: u32,
|
||||
resolution: BeatResolution,
|
||||
/// Playback time in seconds, set by the graph before process()
|
||||
playback_time: f64,
|
||||
/// Playback time in beats, set by the graph before process()
|
||||
playback_time: Beats,
|
||||
/// Previous playback_time to detect paused state
|
||||
prev_playback_time: f64,
|
||||
/// Free-running time accumulator for when playback is stopped
|
||||
free_run_time: f64,
|
||||
prev_playback_time: Beats,
|
||||
/// Free-running beat accumulator for when playback is stopped
|
||||
free_run_time: Beats,
|
||||
inputs: Vec<NodePort>,
|
||||
outputs: Vec<NodePort>,
|
||||
parameters: Vec<Parameter>,
|
||||
|
|
@ -92,16 +93,16 @@ impl BeatNode {
|
|||
bpm: DEFAULT_BPM,
|
||||
beats_per_bar: DEFAULT_BEATS_PER_BAR,
|
||||
resolution: BeatResolution::Quarter,
|
||||
playback_time: 0.0,
|
||||
prev_playback_time: -1.0,
|
||||
free_run_time: 0.0,
|
||||
playback_time: Beats::ZERO,
|
||||
prev_playback_time: Beats(-1.0),
|
||||
free_run_time: Beats::ZERO,
|
||||
inputs,
|
||||
outputs,
|
||||
parameters,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_playback_time(&mut self, time: f64) {
|
||||
pub fn set_playback_time(&mut self, time: Beats) {
|
||||
self.playback_time = time;
|
||||
}
|
||||
|
||||
|
|
@ -169,8 +170,8 @@ impl AudioNode for BeatNode {
|
|||
let base_time = if paused { self.free_run_time } else { self.playback_time };
|
||||
|
||||
for i in 0..len {
|
||||
let time = base_time + i as f64 * sample_period;
|
||||
let beat_pos = time * beats_per_second;
|
||||
// base_time is already in beats; advance by beats_per_second per second
|
||||
let beat_pos = base_time.0 + i as f64 * beats_per_second * sample_period;
|
||||
|
||||
// Beat subdivision phase: 0→1 sawtooth
|
||||
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32;
|
||||
|
|
@ -188,13 +189,13 @@ impl AudioNode for BeatNode {
|
|||
}
|
||||
|
||||
// Advance free-run time (always ticks, so it's ready when playback stops)
|
||||
self.free_run_time += len as f64 * sample_period;
|
||||
self.free_run_time += Beats(len as f64 * beats_per_second * sample_period);
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.playback_time = 0.0;
|
||||
self.prev_playback_time = -1.0;
|
||||
self.free_run_time = 0.0;
|
||||
self.playback_time = Beats::ZERO;
|
||||
self.prev_playback_time = Beats(-1.0);
|
||||
self.free_run_time = Beats::ZERO;
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
|
|
@ -211,9 +212,9 @@ impl AudioNode for BeatNode {
|
|||
bpm: self.bpm,
|
||||
beats_per_bar: self.beats_per_bar,
|
||||
resolution: self.resolution,
|
||||
playback_time: 0.0,
|
||||
prev_playback_time: -1.0,
|
||||
free_run_time: 0.0,
|
||||
playback_time: Beats::ZERO,
|
||||
prev_playback_time: Beats(-1.0),
|
||||
free_run_time: Beats::ZERO,
|
||||
inputs: self.inputs.clone(),
|
||||
outputs: self.outputs.clone(),
|
||||
parameters: self.parameters.clone(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use crate::time::Beats;
|
||||
|
||||
const PARAM_MODE: u32 = 0;
|
||||
const PARAM_STEPS: u32 = 1;
|
||||
|
|
@ -244,14 +245,14 @@ impl AudioNode for SequencerNode {
|
|||
// Note-off for notes no longer active
|
||||
for ¬e in &self.prev_active_notes {
|
||||
if !new_notes.contains(¬e) {
|
||||
midi_outputs[0].push(MidiEvent::note_off(0.0, 0, note, 0));
|
||||
midi_outputs[0].push(MidiEvent::note_off(Beats::ZERO, 0, note, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// Note-on for newly active notes
|
||||
for ¬e in &new_notes {
|
||||
if !self.prev_active_notes.contains(¬e) {
|
||||
midi_outputs[0].push(MidiEvent::note_on(0.0, 0, note, self.velocity));
|
||||
midi_outputs[0].push(MidiEvent::note_on(Beats::ZERO, 0, note, self.velocity));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -348,7 +348,7 @@ impl AudioNode for VoiceAllocatorNode {
|
|||
|
||||
// Process this voice's graph with its MIDI events
|
||||
// Note: playback_time is 0.0 since voice allocator doesn't track time
|
||||
self.voice_instances[voice_idx].process(mix_slice, &midi_events, 0.0);
|
||||
self.voice_instances[voice_idx].process(mix_slice, &midi_events, crate::time::Beats::ZERO);
|
||||
|
||||
// Auto-deactivate releasing voices that have gone silent
|
||||
if voice_state.releasing {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};
|
|||
use std::sync::Arc;
|
||||
use std::f32::consts::PI;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::time::Seconds;
|
||||
|
||||
/// Windowed sinc interpolation for high-quality time stretching
|
||||
/// This is stateless and can handle arbitrary fractional positions
|
||||
|
|
@ -442,19 +443,20 @@ impl AudioClipPool {
|
|||
}
|
||||
|
||||
/// Render audio from a file in the pool with high-quality windowed sinc interpolation
|
||||
/// start_time_seconds: position in the audio file to start reading from (in seconds)
|
||||
/// start_time: position in the audio file to start reading from (in seconds)
|
||||
/// clip_read_ahead: per-clip-instance read-ahead buffer for compressed audio streaming
|
||||
/// Returns the number of samples actually rendered
|
||||
pub fn render_from_file(
|
||||
&self,
|
||||
pool_index: usize,
|
||||
output: &mut [f32],
|
||||
start_time_seconds: f64,
|
||||
start_time: Seconds,
|
||||
gain: f32,
|
||||
engine_sample_rate: u32,
|
||||
engine_channels: u32,
|
||||
clip_read_ahead: Option<&super::disk_reader::ReadAheadBuffer>,
|
||||
) -> usize {
|
||||
let start_time_seconds = start_time.0;
|
||||
let Some(audio_file) = self.files.get(pool_index) else {
|
||||
return 0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ use super::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, Mi
|
|||
use super::midi_pool::MidiClipPool;
|
||||
use super::pool::AudioClipPool;
|
||||
use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode};
|
||||
use crate::tempo_map::TempoMap;
|
||||
use crate::time::{Beats, Seconds};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -216,46 +218,6 @@ impl Project {
|
|||
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
|
||||
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) {
|
||||
|
|
@ -328,9 +290,9 @@ impl Project {
|
|||
&mut self,
|
||||
track_id: TrackId,
|
||||
events: Vec<MidiEvent>,
|
||||
duration: f64,
|
||||
duration: Beats,
|
||||
name: String,
|
||||
external_start: f64,
|
||||
external_start: Beats,
|
||||
) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> {
|
||||
// Verify track exists and is a MIDI track
|
||||
if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) {
|
||||
|
|
@ -363,11 +325,11 @@ impl Project {
|
|||
|
||||
/// Legacy method for backwards compatibility - creates clip and instance from old MidiClip format
|
||||
pub fn add_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) -> Result<MidiClipInstanceId, &'static str> {
|
||||
self.add_midi_clip_at(track_id, clip, 0.0)
|
||||
self.add_midi_clip_at(track_id, clip, Beats::ZERO)
|
||||
}
|
||||
|
||||
/// Add a MIDI clip to the pool and create an instance at the given timeline position
|
||||
pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: f64) -> Result<MidiClipInstanceId, &'static str> {
|
||||
pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) -> Result<MidiClipInstanceId, &'static str> {
|
||||
// Add the clip to the pool (it already has events and duration)
|
||||
let duration = clip.duration;
|
||||
let clip_id = clip.id;
|
||||
|
|
@ -411,7 +373,8 @@ impl Project {
|
|||
output: &mut [f32],
|
||||
audio_pool: &AudioClipPool,
|
||||
buffer_pool: &mut BufferPool,
|
||||
playhead_seconds: f64,
|
||||
playhead_seconds: Seconds,
|
||||
tempo_map: &TempoMap,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
live_only: bool,
|
||||
|
|
@ -423,7 +386,7 @@ impl Project {
|
|||
// Create initial render context
|
||||
let ctx = RenderContext {
|
||||
live_only,
|
||||
..RenderContext::new(playhead_seconds, sample_rate, channels, output.len())
|
||||
..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len())
|
||||
};
|
||||
|
||||
// Render each root track (index-based to avoid clone)
|
||||
|
|
@ -497,7 +460,7 @@ impl Project {
|
|||
let mut track_buffer = buffer_pool.acquire();
|
||||
track_buffer.resize(output.len(), 0.0);
|
||||
track_buffer.fill(0.0);
|
||||
track.render(&mut track_buffer, audio_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels);
|
||||
track.render(&mut track_buffer, audio_pool, ctx);
|
||||
// Accumulate peak level for VU metering (max over meter interval)
|
||||
let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
|
||||
track.peak_level = track.peak_level.max(buffer_peak);
|
||||
|
|
@ -585,7 +548,7 @@ impl Project {
|
|||
let mut graph_output = buffer_pool.acquire();
|
||||
graph_output.resize(output.len(), 0.0);
|
||||
graph_output.fill(0.0);
|
||||
group.audio_graph.process(&mut graph_output, &[], ctx.playhead_seconds);
|
||||
group.audio_graph.process(&mut graph_output, &[], ctx.playhead_beats());
|
||||
|
||||
for (out_sample, graph_sample) in output.iter_mut().zip(graph_output.iter()) {
|
||||
*out_sample += graph_sample * group.volume;
|
||||
|
|
@ -680,7 +643,7 @@ impl Project {
|
|||
pub fn send_midi_note_on(&mut self, track_id: TrackId, note: u8, velocity: u8) {
|
||||
// Queue the MIDI note-on event to the track's live MIDI queue
|
||||
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
|
||||
let event = MidiEvent::note_on(0.0, 0, note, velocity);
|
||||
let event = MidiEvent::note_on(Beats::ZERO, 0, note, velocity);
|
||||
track.queue_live_midi(event);
|
||||
}
|
||||
}
|
||||
|
|
@ -689,7 +652,7 @@ impl Project {
|
|||
pub fn send_midi_note_off(&mut self, track_id: TrackId, note: u8) {
|
||||
// Queue the MIDI note-off event to the track's live MIDI queue
|
||||
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
|
||||
let event = MidiEvent::note_off(0.0, 0, note, 0);
|
||||
let event = MidiEvent::note_off(Beats::ZERO, 0, note, 0);
|
||||
track.queue_live_midi(event);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/// Audio recording system for capturing microphone input
|
||||
use crate::audio::{ClipId, MidiClipId, TrackId};
|
||||
use crate::io::{WavWriter, WaveformPeak};
|
||||
use crate::time::{Beats, Seconds};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
@ -18,8 +19,8 @@ pub struct RecordingState {
|
|||
pub sample_rate: u32,
|
||||
/// Number of channels
|
||||
pub channels: u32,
|
||||
/// Timeline start position in seconds
|
||||
pub start_time: f64,
|
||||
/// Timeline start position
|
||||
pub start_time: Beats,
|
||||
/// Total frames recorded
|
||||
pub frames_written: usize,
|
||||
/// Whether recording is currently paused
|
||||
|
|
@ -45,7 +46,7 @@ impl RecordingState {
|
|||
writer: WavWriter,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
start_time: f64,
|
||||
start_time: Beats,
|
||||
_flush_interval_seconds: f64, // No longer used - kept for API compatibility
|
||||
) -> Self {
|
||||
// Calculate frames per waveform peak
|
||||
|
|
@ -130,9 +131,9 @@ impl RecordingState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get current recording duration in seconds
|
||||
pub fn duration(&self) -> f64 {
|
||||
self.frames_written as f64 / self.sample_rate as f64
|
||||
/// Get current recording duration
|
||||
pub fn duration(&self) -> Seconds {
|
||||
Seconds(self.frames_written as f64 / self.sample_rate as f64)
|
||||
}
|
||||
|
||||
/// Finalize the recording and return the temp file path, waveform, and audio data
|
||||
|
|
@ -175,33 +176,23 @@ impl RecordingState {
|
|||
/// Active MIDI note waiting for its noteOff event
|
||||
#[derive(Debug, Clone)]
|
||||
struct ActiveMidiNote {
|
||||
/// MIDI note number (0-127)
|
||||
note: u8,
|
||||
/// Velocity (0-127)
|
||||
velocity: u8,
|
||||
/// Absolute time when note started (seconds)
|
||||
start_time: f64,
|
||||
start_time: Beats,
|
||||
}
|
||||
|
||||
/// State of an active MIDI recording session
|
||||
/// State of an active MIDI recording session.
|
||||
pub struct MidiRecordingState {
|
||||
/// Track being recorded to
|
||||
pub track_id: TrackId,
|
||||
/// MIDI clip ID
|
||||
pub clip_id: MidiClipId,
|
||||
/// Timeline start position in seconds
|
||||
pub start_time: f64,
|
||||
/// Currently active notes (noteOn without matching noteOff)
|
||||
/// Maps note number to ActiveMidiNote
|
||||
pub start_time: Beats,
|
||||
active_notes: HashMap<u8, ActiveMidiNote>,
|
||||
/// Completed notes ready to be added to clip
|
||||
/// Format: (time_offset, note, velocity, duration)
|
||||
pub completed_notes: Vec<(f64, u8, u8, f64)>,
|
||||
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats
|
||||
pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
|
||||
}
|
||||
|
||||
impl MidiRecordingState {
|
||||
/// Create a new MIDI recording state
|
||||
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: f64) -> Self {
|
||||
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: Beats) -> Self {
|
||||
Self {
|
||||
track_id,
|
||||
clip_id,
|
||||
|
|
@ -211,87 +202,62 @@ impl MidiRecordingState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle a MIDI note on event
|
||||
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: f64) {
|
||||
self.active_notes.insert(note, ActiveMidiNote {
|
||||
note,
|
||||
velocity,
|
||||
start_time: absolute_time,
|
||||
});
|
||||
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) {
|
||||
self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time });
|
||||
}
|
||||
|
||||
/// Handle a MIDI note off event
|
||||
pub fn note_off(&mut self, note: u8, absolute_time: f64) {
|
||||
// Find the matching noteOn
|
||||
pub fn note_off(&mut self, note: u8, absolute_time: Beats) {
|
||||
if let Some(active_note) = self.active_notes.remove(¬e) {
|
||||
// If the note was fully released before the recording start (e.g. during count-in
|
||||
// pre-roll), discard it — only notes still held at the clip start are kept.
|
||||
if absolute_time <= self.start_time {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp note start to clip start: notes held across the recording boundary
|
||||
// are treated as starting at the clip position.
|
||||
let note_start = active_note.start_time.max(self.start_time);
|
||||
let time_offset = note_start - self.start_time;
|
||||
let duration = absolute_time - note_start;
|
||||
|
||||
eprintln!("[MIDI_RECORDING_STATE] Completing note {}: note_start={:.3}s, note_end={:.3}s, recording_start={:.3}s, time_offset={:.3}s, duration={:.3}s",
|
||||
note, note_start, absolute_time, self.start_time, time_offset, duration);
|
||||
|
||||
self.completed_notes.push((
|
||||
time_offset,
|
||||
note_start - self.start_time,
|
||||
active_note.note,
|
||||
active_note.velocity,
|
||||
duration,
|
||||
absolute_time - note_start,
|
||||
));
|
||||
}
|
||||
// If no matching noteOn found, ignore the noteOff
|
||||
}
|
||||
|
||||
/// Get all completed notes
|
||||
pub fn get_notes(&self) -> &[(f64, u8, u8, f64)] {
|
||||
pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] {
|
||||
&self.completed_notes
|
||||
}
|
||||
|
||||
/// Get the number of completed notes
|
||||
pub fn note_count(&self) -> usize {
|
||||
self.completed_notes.len()
|
||||
}
|
||||
|
||||
/// Get all completed notes plus currently-held notes with a provisional duration.
|
||||
/// Used for live preview during recording so held notes appear immediately.
|
||||
pub fn get_notes_with_active(&self, current_time: f64) -> Vec<(f64, u8, u8, f64)> {
|
||||
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
|
||||
let mut notes = self.completed_notes.clone();
|
||||
for active in self.active_notes.values() {
|
||||
let note_start = active.start_time.max(self.start_time);
|
||||
let time_offset = note_start - self.start_time;
|
||||
let provisional_dur = (current_time - note_start).max(0.0);
|
||||
notes.push((time_offset, active.note, active.velocity, provisional_dur));
|
||||
notes.push((
|
||||
note_start - self.start_time,
|
||||
active.note,
|
||||
active.velocity,
|
||||
(current_time - note_start).max(Beats::ZERO),
|
||||
));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
/// Get the note numbers of all currently held (active) notes
|
||||
pub fn active_note_numbers(&self) -> Vec<u8> {
|
||||
self.active_notes.keys().copied().collect()
|
||||
}
|
||||
|
||||
/// Close out all active notes at the given time
|
||||
/// This should be called when stopping recording to end any held notes
|
||||
pub fn close_active_notes(&mut self, end_time: f64) {
|
||||
pub fn close_active_notes(&mut self, end_time: Beats) {
|
||||
let active_notes: Vec<_> = self.active_notes.drain().collect();
|
||||
|
||||
for (_note_num, active_note) in active_notes {
|
||||
let note_start = active_note.start_time.max(self.start_time);
|
||||
let time_offset = note_start - self.start_time;
|
||||
let duration = end_time - note_start;
|
||||
|
||||
self.completed_notes.push((
|
||||
time_offset,
|
||||
note_start - self.start_time,
|
||||
active_note.note,
|
||||
active_note.velocity,
|
||||
duration,
|
||||
end_time - note_start,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use super::node_graph::AudioGraph;
|
|||
use super::node_graph::nodes::{AudioInputNode, AudioOutputNode};
|
||||
use super::node_graph::preset::GraphPreset;
|
||||
use super::pool::AudioClipPool;
|
||||
use crate::tempo_map::TempoMap;
|
||||
use crate::time::{Beats, Seconds};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
|
|
@ -23,10 +25,12 @@ pub type Track = AudioTrack;
|
|||
/// Rendering context that carries timing information through the track hierarchy
|
||||
///
|
||||
/// This allows metatracks to transform time for their children (time stretch, offset, etc.)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RenderContext {
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RenderContext<'a> {
|
||||
/// Current playhead position in seconds (in transformed time)
|
||||
pub playhead_seconds: f64,
|
||||
pub playhead_seconds: Seconds,
|
||||
/// Tempo map for beat ↔ second conversion
|
||||
pub tempo_map: &'a TempoMap,
|
||||
/// Audio sample rate
|
||||
pub sample_rate: u32,
|
||||
/// Number of channels
|
||||
|
|
@ -41,16 +45,17 @@ pub struct RenderContext {
|
|||
pub live_only: bool,
|
||||
}
|
||||
|
||||
impl RenderContext {
|
||||
/// Create a new render context
|
||||
impl<'a> RenderContext<'a> {
|
||||
pub fn new(
|
||||
playhead_seconds: f64,
|
||||
playhead_seconds: Seconds,
|
||||
tempo_map: &'a TempoMap,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
buffer_size: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
playhead_seconds,
|
||||
tempo_map,
|
||||
sample_rate,
|
||||
channels,
|
||||
buffer_size,
|
||||
|
|
@ -59,15 +64,21 @@ impl RenderContext {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get the duration of the buffer in seconds
|
||||
pub fn buffer_duration(&self) -> f64 {
|
||||
self.buffer_size as f64 / (self.sample_rate as f64 * self.channels as f64)
|
||||
pub fn buffer_duration(&self) -> Seconds {
|
||||
Seconds(self.buffer_size as f64 / (self.sample_rate as f64 * self.channels as f64))
|
||||
}
|
||||
|
||||
/// Get the end time of the buffer
|
||||
pub fn buffer_end(&self) -> f64 {
|
||||
pub fn buffer_end(&self) -> Seconds {
|
||||
self.playhead_seconds + self.buffer_duration()
|
||||
}
|
||||
|
||||
pub fn playhead_beats(&self) -> Beats {
|
||||
self.tempo_map.seconds_to_beats(self.playhead_seconds)
|
||||
}
|
||||
|
||||
pub fn buffer_end_beats(&self) -> Beats {
|
||||
self.tempo_map.seconds_to_beats(self.buffer_end())
|
||||
}
|
||||
}
|
||||
|
||||
/// Node in the track hierarchy - can be an audio track, MIDI track, or a metatrack
|
||||
|
|
@ -170,14 +181,14 @@ pub struct Metatrack {
|
|||
pub time_stretch: f32,
|
||||
/// Pitch shift in semitones (for future implementation)
|
||||
pub pitch_shift: f32,
|
||||
/// Time offset in seconds (shift content forward/backward in time)
|
||||
pub offset: f64,
|
||||
/// Trim start: offset into the metatrack's internal content (seconds)
|
||||
/// Time offset (shift content forward/backward in time)
|
||||
pub offset: Seconds,
|
||||
/// Trim start: offset into the metatrack's internal content
|
||||
/// Children will see time starting from this point
|
||||
pub trim_start: f64,
|
||||
/// Trim end: offset into the metatrack's internal content (seconds)
|
||||
pub trim_start: Seconds,
|
||||
/// Trim end: offset into the metatrack's internal content
|
||||
/// None means no end trim (play until content ends)
|
||||
pub trim_end: Option<f64>,
|
||||
pub trim_end: Option<Seconds>,
|
||||
/// Automation lanes for this metatrack
|
||||
pub automation_lanes: HashMap<AutomationLaneId, AutomationLane>,
|
||||
next_automation_id: AutomationLaneId,
|
||||
|
|
@ -231,8 +242,8 @@ impl Metatrack {
|
|||
solo: false,
|
||||
time_stretch: 1.0,
|
||||
pitch_shift: 0.0,
|
||||
offset: 0.0,
|
||||
trim_start: 0.0,
|
||||
offset: Seconds::ZERO,
|
||||
trim_start: Seconds::ZERO,
|
||||
trim_end: None,
|
||||
automation_lanes: HashMap::new(),
|
||||
next_automation_id: 0,
|
||||
|
|
@ -252,7 +263,11 @@ impl Metatrack {
|
|||
graph
|
||||
}
|
||||
|
||||
/// Build the explicit subtrack mixing graph: SubtrackInputs → Mixer → AudioOutput.
|
||||
/// Build the default subtrack mixing graph: SubtrackInputs → Mixer → Gain → AudioOutput,
|
||||
/// with an AutomationInput ("Volume", range 0..2) feeding the Gain's CV port.
|
||||
///
|
||||
/// Existing Volume keyframes are preserved across rebuilds so that adding/removing
|
||||
/// a child track doesn't reset the automation.
|
||||
///
|
||||
/// `subtracks` is an ordered list of (backend TrackId, display name) for each child.
|
||||
/// Replaces the current graph and marks `graph_is_default = true`.
|
||||
|
|
@ -262,35 +277,60 @@ impl Metatrack {
|
|||
sample_rate: u32,
|
||||
buffer_size: usize,
|
||||
) {
|
||||
use super::node_graph::nodes::{SubtrackInputsNode, MixerNode};
|
||||
use super::node_graph::nodes::{SubtrackInputsNode, MixerNode, GainNode, AutomationInputNode};
|
||||
use super::node_graph::nodes::AutomationKeyframe;
|
||||
use crate::time::Beats;
|
||||
|
||||
// Preserve existing Volume keyframes before rebuilding.
|
||||
let existing_volume_kfs = self.get_volume_automation_keyframes();
|
||||
|
||||
let n = subtracks.len();
|
||||
let mut graph = AudioGraph::new(sample_rate, buffer_size);
|
||||
|
||||
// SubtrackInputs node (N outputs, one per child)
|
||||
// NOTE: `new()` initialises buffers as zero-length; call `update_subtracks` immediately
|
||||
// to allocate stereo interleaved buffers (buffer_size * 2 samples each).
|
||||
let mut inputs_node = SubtrackInputsNode::new("Subtrack Inputs", subtracks);
|
||||
let subtracks_copy = inputs_node.subtracks().to_vec();
|
||||
inputs_node.update_subtracks(subtracks_copy, buffer_size);
|
||||
let inputs_id = graph.add_node(Box::new(inputs_node));
|
||||
graph.set_node_position(inputs_id, 100.0, 150.0);
|
||||
|
||||
// Mixer node (starts with 1 spare; grows as connections are made)
|
||||
// Mixer node
|
||||
let mixer_node = Box::new(MixerNode::new("Mixer"));
|
||||
let mixer_id = graph.add_node(mixer_node);
|
||||
graph.set_node_position(mixer_id, 350.0, 150.0);
|
||||
graph.set_node_position(mixer_id, 330.0, 150.0);
|
||||
|
||||
// Gain node — group volume control
|
||||
let gain_id = graph.add_node(Box::new(GainNode::new("Volume")));
|
||||
graph.set_node_position(gain_id, 520.0, 150.0);
|
||||
|
||||
// AutomationInput — drives the Gain's CV port
|
||||
let mut auto_node = AutomationInputNode::new("Volume CV");
|
||||
auto_node.set_display_name("Volume".to_string());
|
||||
auto_node.value_min = 0.0;
|
||||
auto_node.value_max = 2.0;
|
||||
auto_node.clear_keyframes();
|
||||
if existing_volume_kfs.is_empty() {
|
||||
auto_node.add_keyframe(AutomationKeyframe::new(Beats::ZERO, 1.0));
|
||||
} else {
|
||||
for kf in existing_volume_kfs {
|
||||
auto_node.add_keyframe(kf);
|
||||
}
|
||||
}
|
||||
let auto_id = graph.add_node(Box::new(auto_node));
|
||||
graph.set_node_position(auto_id, 520.0, 320.0);
|
||||
|
||||
// AudioOutput node
|
||||
let output_node = Box::new(AudioOutputNode::new("Audio Output"));
|
||||
let output_id = graph.add_node(output_node);
|
||||
graph.set_node_position(output_id, 600.0, 150.0);
|
||||
graph.set_node_position(output_id, 720.0, 150.0);
|
||||
|
||||
// Connect SubtrackInputs[i] → Mixer[i] for each subtrack
|
||||
for i in 0..n {
|
||||
let _ = graph.connect(inputs_id, i, mixer_id, i);
|
||||
}
|
||||
let _ = graph.connect(mixer_id, 0, output_id, 0);
|
||||
let _ = graph.connect(mixer_id, 0, gain_id, 0); // Mixer → Gain audio
|
||||
let _ = graph.connect(auto_id, 0, gain_id, 1); // AutomationInput → Gain CV
|
||||
let _ = graph.connect(gain_id, 0, output_id, 0); // Gain → Audio Out
|
||||
graph.set_output_node(Some(output_id));
|
||||
|
||||
self.audio_graph = graph;
|
||||
|
|
@ -298,6 +338,22 @@ impl Metatrack {
|
|||
self.graph_is_default = true;
|
||||
}
|
||||
|
||||
/// Extract Volume AutomationInput keyframes from the current graph (if any),
|
||||
/// so they can be preserved across `set_subtrack_graph` rebuilds.
|
||||
fn get_volume_automation_keyframes(&self) -> Vec<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.
|
||||
///
|
||||
/// 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
|
||||
pub fn evaluate_automation_at_time(&self, time: f64) -> (f32, f32, f64) {
|
||||
pub fn evaluate_automation_at_time(&self, time: Beats) -> (f32, f32, Seconds) {
|
||||
let mut volume = self.volume;
|
||||
let mut time_stretch = self.time_stretch;
|
||||
let mut offset = self.offset;
|
||||
|
|
@ -517,7 +573,7 @@ impl Metatrack {
|
|||
}
|
||||
ParameterId::TimeOffset => {
|
||||
if let Some(automated_value) = lane.evaluate(time) {
|
||||
offset = automated_value as f64;
|
||||
offset = Seconds(automated_value as f64);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -561,7 +617,7 @@ impl Metatrack {
|
|||
|
||||
/// Check whether this metatrack should produce audio at the given parent time.
|
||||
/// Returns false if the playhead is outside the trim window.
|
||||
pub fn is_active_at_time(&self, parent_playhead: f64) -> bool {
|
||||
pub fn is_active_at_time(&self, parent_playhead: Seconds) -> bool {
|
||||
let local_time = (parent_playhead - self.offset) * self.time_stretch as f64;
|
||||
if local_time < self.trim_start {
|
||||
return false;
|
||||
|
|
@ -580,7 +636,7 @@ impl Metatrack {
|
|||
/// Time stretch affects how fast content plays: 0.5 = half speed, 2.0 = double speed
|
||||
/// Offset shifts content forward/backward in time
|
||||
/// Trim start offsets into the internal content
|
||||
pub fn transform_context(&self, ctx: RenderContext) -> RenderContext {
|
||||
pub fn transform_context<'a>(&self, ctx: RenderContext<'a>) -> RenderContext<'a> {
|
||||
let mut transformed = ctx;
|
||||
|
||||
// Apply transformations in order:
|
||||
|
|
@ -596,7 +652,7 @@ impl Metatrack {
|
|||
let stretched = adjusted_playhead * self.time_stretch as f64;
|
||||
|
||||
// 3. Add trim_start so children see time starting from the trim point
|
||||
// If trim_start=2.0, children start seeing time 2.0 when parent reaches offset
|
||||
// If trim_start=2.0s, children start seeing time 2.0s when parent reaches offset
|
||||
transformed.playhead_seconds = stretched + self.trim_start;
|
||||
|
||||
// Accumulate time stretch for nested metatracks
|
||||
|
|
@ -772,13 +828,13 @@ impl MidiTrack {
|
|||
// Send note-off for all 128 possible MIDI notes to silence the instrument
|
||||
let mut note_offs = Vec::new();
|
||||
for note in 0..128 {
|
||||
note_offs.push(MidiEvent::note_off(0.0, 0, note, 0));
|
||||
note_offs.push(MidiEvent::note_off(Beats::ZERO, 0, note, 0));
|
||||
}
|
||||
|
||||
// Create a silent buffer to process the note-offs
|
||||
let buffer_size = 512 * 2; // stereo
|
||||
let mut silent_buffer = vec![0.0f32; buffer_size];
|
||||
self.instrument_graph.process(&mut silent_buffer, ¬e_offs, 0.0);
|
||||
self.instrument_graph.process(&mut silent_buffer, ¬e_offs, Beats::ZERO);
|
||||
}
|
||||
|
||||
/// Queue a live MIDI event (from virtual keyboard or MIDI controller)
|
||||
|
|
@ -805,17 +861,17 @@ impl MidiTrack {
|
|||
let mut midi_events = Vec::new();
|
||||
|
||||
if !ctx.live_only {
|
||||
let buffer_duration_seconds = output.len() as f64 / (ctx.sample_rate as f64 * ctx.channels as f64);
|
||||
let buffer_end_seconds = ctx.playhead_seconds + buffer_duration_seconds;
|
||||
let playhead_beats = ctx.playhead_beats();
|
||||
let buffer_end_beats = ctx.buffer_end_beats();
|
||||
|
||||
// Collect MIDI events from all clip instances that overlap with current time range
|
||||
// Collect MIDI events from all clip instances that overlap with current beat range
|
||||
let mut currently_active = HashSet::new();
|
||||
for instance in &self.clip_instances {
|
||||
if instance.overlaps_range(ctx.playhead_seconds, buffer_end_seconds) {
|
||||
if instance.overlaps_range(playhead_beats, buffer_end_beats) {
|
||||
currently_active.insert(instance.id);
|
||||
}
|
||||
if let Some(clip) = midi_pool.get_clip(instance.clip_id) {
|
||||
let events = instance.get_events_in_range(clip, ctx.playhead_seconds, buffer_end_seconds);
|
||||
let events = instance.get_events_in_range(clip, playhead_beats, buffer_end_beats);
|
||||
midi_events.extend(events);
|
||||
}
|
||||
}
|
||||
|
|
@ -824,7 +880,7 @@ impl MidiTrack {
|
|||
for prev_id in &self.prev_active_instances {
|
||||
if !currently_active.contains(prev_id) {
|
||||
for note in 0..128u8 {
|
||||
midi_events.push(MidiEvent::note_off(ctx.playhead_seconds, 0, note, 0));
|
||||
midi_events.push(MidiEvent::note_off(playhead_beats, 0, note, 0));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -836,10 +892,10 @@ impl MidiTrack {
|
|||
midi_events.extend(self.live_midi_queue.drain(..));
|
||||
|
||||
// Generate audio using instrument graph
|
||||
self.instrument_graph.process(output, &midi_events, ctx.playhead_seconds);
|
||||
self.instrument_graph.process(output, &midi_events, ctx.playhead_beats());
|
||||
|
||||
// Evaluate and apply automation (skip automation in live_only mode — no playhead to evaluate at)
|
||||
let effective_volume = if ctx.live_only { self.volume } else { self.evaluate_automation_at_time(ctx.playhead_seconds) };
|
||||
let effective_volume = if ctx.live_only { self.volume } else { self.evaluate_automation_at_time(ctx.playhead_beats()) };
|
||||
|
||||
// Apply track volume
|
||||
for sample in output.iter_mut() {
|
||||
|
|
@ -848,7 +904,7 @@ impl MidiTrack {
|
|||
}
|
||||
|
||||
/// Evaluate automation at a specific time and return the effective volume
|
||||
fn evaluate_automation_at_time(&self, time: f64) -> f32 {
|
||||
fn evaluate_automation_at_time(&self, time: Beats) -> f32 {
|
||||
let mut volume = self.volume;
|
||||
|
||||
// Check for volume automation
|
||||
|
|
@ -1081,12 +1137,9 @@ impl AudioTrack {
|
|||
&mut self,
|
||||
output: &mut [f32],
|
||||
pool: &AudioClipPool,
|
||||
playhead_seconds: f64,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
ctx: RenderContext<'_>,
|
||||
) -> usize {
|
||||
let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64);
|
||||
let buffer_end_seconds = playhead_seconds + buffer_duration_seconds;
|
||||
let buffer_end = ctx.buffer_end();
|
||||
|
||||
// Split borrow: take clip_render_buffer out to avoid borrow conflict with &self methods
|
||||
let mut clip_buffer = std::mem::take(&mut self.clip_render_buffer);
|
||||
|
|
@ -1096,16 +1149,8 @@ impl AudioTrack {
|
|||
|
||||
// Render all active clip instances into the buffer
|
||||
for clip in &self.clips {
|
||||
// Check if clip overlaps with current buffer time range
|
||||
if clip.external_start < buffer_end_seconds && clip.external_end() > playhead_seconds {
|
||||
rendered += self.render_clip(
|
||||
clip,
|
||||
&mut clip_buffer,
|
||||
pool,
|
||||
playhead_seconds,
|
||||
sample_rate,
|
||||
channels,
|
||||
);
|
||||
if clip.external_start_secs(ctx.tempo_map) < buffer_end && clip.external_end_secs(ctx.tempo_map) > ctx.playhead_seconds {
|
||||
rendered += self.render_clip(clip, &mut clip_buffer, pool, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1123,13 +1168,13 @@ impl AudioTrack {
|
|||
}
|
||||
|
||||
// Process through the effects graph (this will write to output buffer)
|
||||
self.effects_graph.process(output, &[], playhead_seconds);
|
||||
self.effects_graph.process(output, &[], ctx.playhead_beats());
|
||||
|
||||
// Put the buffer back for reuse next callback
|
||||
self.clip_render_buffer = clip_buffer;
|
||||
|
||||
// Evaluate and apply automation
|
||||
let effective_volume = self.evaluate_automation_at_time(playhead_seconds);
|
||||
let effective_volume = self.evaluate_automation_at_time(ctx.playhead_beats());
|
||||
|
||||
// Apply track volume
|
||||
for sample in output.iter_mut() {
|
||||
|
|
@ -1140,7 +1185,7 @@ impl AudioTrack {
|
|||
}
|
||||
|
||||
/// Evaluate automation at a specific time and return the effective volume
|
||||
fn evaluate_automation_at_time(&self, time: f64) -> f32 {
|
||||
fn evaluate_automation_at_time(&self, time: Beats) -> f32 {
|
||||
let mut volume = self.volume;
|
||||
|
||||
// Check for volume automation
|
||||
|
|
@ -1169,49 +1214,40 @@ impl AudioTrack {
|
|||
clip: &AudioClipInstance,
|
||||
output: &mut [f32],
|
||||
pool: &AudioClipPool,
|
||||
playhead_seconds: f64,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
ctx: RenderContext<'_>,
|
||||
) -> usize {
|
||||
let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64);
|
||||
let buffer_end_seconds = playhead_seconds + buffer_duration_seconds;
|
||||
let playhead = ctx.playhead_seconds;
|
||||
let buffer_end = ctx.buffer_end();
|
||||
let tempo_map = ctx.tempo_map;
|
||||
let sample_rate = ctx.sample_rate;
|
||||
let channels = ctx.channels;
|
||||
|
||||
// Determine the time range we need to render (intersection of buffer and clip external bounds)
|
||||
let render_start_seconds = playhead_seconds.max(clip.external_start);
|
||||
let render_end_seconds = buffer_end_seconds.min(clip.external_end());
|
||||
let render_start = playhead.max(clip.external_start_secs(tempo_map));
|
||||
let render_end = buffer_end.min(clip.external_end_secs(tempo_map));
|
||||
|
||||
// If no overlap, return early
|
||||
if render_start_seconds >= render_end_seconds {
|
||||
if render_start >= render_end {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let internal_duration = clip.internal_duration();
|
||||
if internal_duration <= 0.0 {
|
||||
if internal_duration <= Seconds::ZERO {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate combined gain
|
||||
let combined_gain = clip.gain;
|
||||
|
||||
let mut total_rendered = 0;
|
||||
|
||||
// Process the render range sample by sample (or in chunks for efficiency)
|
||||
// For looping clips, we need to handle wrap-around at the loop boundary
|
||||
let samples_per_second = sample_rate as f64 * channels as f64;
|
||||
|
||||
// For now, render in a simpler way - iterate through the timeline range
|
||||
// and use get_content_position for each sample position
|
||||
let output_start_offset = ((render_start_seconds - playhead_seconds) * samples_per_second + 0.5) as usize;
|
||||
let output_end_offset = ((render_end_seconds - playhead_seconds) * samples_per_second + 0.5) as usize;
|
||||
let output_start_offset = ((render_start - playhead).0 * samples_per_second + 0.5) as usize;
|
||||
let output_end_offset = ((render_end - playhead).0 * samples_per_second + 0.5) as usize;
|
||||
|
||||
if output_end_offset > output.len() || output_start_offset > output.len() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If not looping, we can render in one chunk (more efficient)
|
||||
if !clip.is_looping() {
|
||||
// Simple case: no looping
|
||||
let content_start = clip.get_content_position(render_start_seconds).unwrap_or(clip.internal_start);
|
||||
if !clip.is_looping(tempo_map) {
|
||||
let content_start = clip.get_content_position(render_start, tempo_map).unwrap_or(clip.internal_start);
|
||||
let output_len = output.len();
|
||||
let output_slice = &mut output[output_start_offset..output_end_offset.min(output_len)];
|
||||
|
||||
|
|
@ -1225,23 +1261,20 @@ impl AudioTrack {
|
|||
clip.read_ahead.as_deref(),
|
||||
);
|
||||
} else {
|
||||
// Looping case: need to handle wrap-around at loop boundaries
|
||||
// Render in segments, one per loop iteration
|
||||
let mut timeline_pos = render_start_seconds;
|
||||
// Looping case: handle wrap-around at loop boundaries
|
||||
let mut timeline_pos = render_start;
|
||||
let mut output_offset = output_start_offset;
|
||||
|
||||
while timeline_pos < render_end_seconds && output_offset < output.len() {
|
||||
// Calculate position within the loop
|
||||
let relative_pos = timeline_pos - clip.external_start;
|
||||
let loop_offset = relative_pos % internal_duration;
|
||||
let content_pos = clip.internal_start + loop_offset;
|
||||
while timeline_pos < render_end && output_offset < output.len() {
|
||||
let relative_pos = timeline_pos - clip.external_start_secs(tempo_map);
|
||||
let loop_offset = relative_pos.0 % internal_duration.0;
|
||||
let content_pos = clip.internal_start + Seconds(loop_offset);
|
||||
|
||||
// Calculate how much we can render before hitting the loop boundary
|
||||
let time_to_loop_end = internal_duration - loop_offset;
|
||||
let time_to_render_end = render_end_seconds - timeline_pos;
|
||||
let time_to_loop_end = Seconds(internal_duration.0 - loop_offset);
|
||||
let time_to_render_end = render_end - timeline_pos;
|
||||
let chunk_duration = time_to_loop_end.min(time_to_render_end);
|
||||
|
||||
let chunk_samples = (chunk_duration * samples_per_second) as usize;
|
||||
let chunk_samples = (chunk_duration.0 * samples_per_second) as usize;
|
||||
let chunk_samples = chunk_samples.min(output.len() - output_offset);
|
||||
|
||||
if chunk_samples == 0 {
|
||||
|
|
@ -1262,7 +1295,7 @@ impl AudioTrack {
|
|||
|
||||
total_rendered += rendered;
|
||||
output_offset += chunk_samples;
|
||||
timeline_pos += chunk_duration;
|
||||
timeline_pos = timeline_pos + chunk_duration;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use crate::audio::midi::MidiEvent;
|
|||
use crate::audio::buffer_pool::BufferPoolStats;
|
||||
use crate::audio::node_graph::nodes::LoopMode;
|
||||
use crate::io::WaveformPeak;
|
||||
use crate::time::{Beats, Seconds};
|
||||
|
||||
/// Commands sent from UI/control thread to audio thread
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -113,7 +114,7 @@ pub enum Command {
|
|||
|
||||
// Recording commands
|
||||
/// Start recording on a track (track_id, start_time)
|
||||
StartRecording(TrackId, f64),
|
||||
StartRecording(TrackId, Beats),
|
||||
/// Stop the current recording
|
||||
StopRecording,
|
||||
/// Pause the current recording
|
||||
|
|
@ -123,7 +124,7 @@ pub enum Command {
|
|||
|
||||
// MIDI Recording commands
|
||||
/// Start MIDI recording on a track (track_id, clip_id, start_time)
|
||||
StartMidiRecording(TrackId, MidiClipId, f64),
|
||||
StartMidiRecording(TrackId, MidiClipId, Beats),
|
||||
/// Stop the current MIDI recording
|
||||
StopMidiRecording,
|
||||
|
||||
|
|
@ -144,10 +145,8 @@ pub enum Command {
|
|||
SetMetronomeEnabled(bool),
|
||||
/// Set project tempo and time signature (bpm, (numerator, denominator))
|
||||
SetTempo(f32, (u32, u32)),
|
||||
/// After a BPM change: update MIDI clip durations and sync all clip beats/frames from seconds.
|
||||
/// (bpm, fps, midi_durations: Vec<(clip_id, new_duration_seconds)>)
|
||||
ApplyBpmChange(f64, f64, Vec<(MidiClipId, f64)>),
|
||||
|
||||
/// Replace the entire tempo map (multi-entry variable tempo support)
|
||||
SetTempoMap(crate::TempoMap),
|
||||
// Node graph commands
|
||||
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
|
||||
GraphAddNode(TrackId, String, f32, f32),
|
||||
|
|
@ -289,7 +288,7 @@ pub enum AudioEvent {
|
|||
/// Recording started (track_id, clip_id, sample_rate, channels)
|
||||
RecordingStarted(TrackId, ClipId, u32, u32),
|
||||
/// Recording progress update (clip_id, current_duration)
|
||||
RecordingProgress(ClipId, f64),
|
||||
RecordingProgress(ClipId, Seconds),
|
||||
/// Recording stopped (clip_id, pool_index, waveform)
|
||||
RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
|
||||
/// Recording error (error_message)
|
||||
|
|
@ -297,8 +296,8 @@ pub enum AudioEvent {
|
|||
/// MIDI recording stopped (track_id, clip_id, note_count)
|
||||
MidiRecordingStopped(TrackId, MidiClipId, usize),
|
||||
/// MIDI recording progress (track_id, clip_id, duration, notes)
|
||||
/// Notes format: (start_time, note, velocity, duration)
|
||||
MidiRecordingProgress(TrackId, MidiClipId, f64, Vec<(f64, u8, u8, f64)>),
|
||||
/// Notes format: (start_time, note, velocity, duration) — all times in beats
|
||||
MidiRecordingProgress(TrackId, MidiClipId, Beats, Vec<(Beats, u8, u8, Beats)>),
|
||||
/// Project has been reset
|
||||
ProjectReset,
|
||||
/// MIDI note started playing (note, velocity)
|
||||
|
|
|
|||
|
|
@ -1,164 +1,69 @@
|
|||
use crate::audio::midi::{MidiClip, MidiClipId, MidiEvent};
|
||||
use crate::time::Beats;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load a MIDI file and convert it to a MidiClip
|
||||
/// Load a MIDI file and convert it to a MidiClip.
|
||||
///
|
||||
/// Event timestamps are stored as beat positions: `tick / ticks_per_beat`.
|
||||
/// Tempo events in the MIDI file only affect wall-clock playback speed — not the
|
||||
/// beat grid — so they are ignored here.
|
||||
pub fn load_midi_file<P: AsRef<Path>>(
|
||||
path: P,
|
||||
clip_id: MidiClipId,
|
||||
_sample_rate: u32,
|
||||
) -> Result<MidiClip, String> {
|
||||
// Read the MIDI file
|
||||
let data = fs::read(path.as_ref()).map_err(|e| format!("Failed to read MIDI file: {}", e))?;
|
||||
|
||||
// Parse with midly
|
||||
let smf = midly::Smf::parse(&data).map_err(|e| format!("Failed to parse MIDI file: {}", e))?;
|
||||
|
||||
// Convert timing to ticks per second
|
||||
let ticks_per_beat = match smf.header.timing {
|
||||
midly::Timing::Metrical(tpb) => tpb.as_int() as f64,
|
||||
midly::Timing::Timecode(fps, subframe) => {
|
||||
// For timecode, calculate equivalent ticks per second
|
||||
// Timecode-based MIDI: treat subframes as ticks per beat
|
||||
(fps.as_f32() * subframe as f32) as f64
|
||||
}
|
||||
};
|
||||
|
||||
// First pass: collect all events with their tick positions and tempo changes
|
||||
#[derive(Debug)]
|
||||
enum RawEvent {
|
||||
Midi {
|
||||
tick: u64,
|
||||
channel: u8,
|
||||
message: midly::MidiMessage,
|
||||
},
|
||||
Tempo {
|
||||
tick: u64,
|
||||
microseconds_per_beat: f64,
|
||||
},
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let mut max_tick = 0u64;
|
||||
|
||||
let mut raw_events = Vec::new();
|
||||
let mut max_time_ticks = 0u64;
|
||||
|
||||
// Collect all events from all tracks with their absolute tick positions
|
||||
for track in &smf.tracks {
|
||||
let mut current_tick = 0u64;
|
||||
|
||||
for event in track {
|
||||
current_tick += event.delta.as_int() as u64;
|
||||
max_time_ticks = max_time_ticks.max(current_tick);
|
||||
max_tick = max_tick.max(current_tick);
|
||||
|
||||
let timestamp = Beats(current_tick as f64 / ticks_per_beat);
|
||||
|
||||
match event.kind {
|
||||
midly::TrackEventKind::Midi { channel, message } => {
|
||||
raw_events.push(RawEvent::Midi {
|
||||
tick: current_tick,
|
||||
channel: channel.as_int(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
midly::TrackEventKind::Meta(midly::MetaMessage::Tempo(tempo)) => {
|
||||
raw_events.push(RawEvent::Tempo {
|
||||
tick: current_tick,
|
||||
microseconds_per_beat: tempo.as_int() as f64,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// Ignore other meta events
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort all events by tick position
|
||||
raw_events.sort_by_key(|e| match e {
|
||||
RawEvent::Midi { tick, .. } => *tick,
|
||||
RawEvent::Tempo { tick, .. } => *tick,
|
||||
});
|
||||
|
||||
// Second pass: convert ticks to timestamps with proper tempo tracking
|
||||
let mut events = Vec::new();
|
||||
let mut microseconds_per_beat = 500000.0; // Default: 120 BPM
|
||||
let mut last_tick = 0u64;
|
||||
let mut accumulated_time = 0.0; // Time in seconds
|
||||
|
||||
for raw_event in raw_events {
|
||||
match raw_event {
|
||||
RawEvent::Tempo {
|
||||
tick,
|
||||
microseconds_per_beat: new_tempo,
|
||||
} => {
|
||||
// Update accumulated time up to this tempo change
|
||||
let delta_ticks = tick - last_tick;
|
||||
let delta_time = (delta_ticks as f64 / ticks_per_beat)
|
||||
* (microseconds_per_beat / 1_000_000.0);
|
||||
accumulated_time += delta_time;
|
||||
last_tick = tick;
|
||||
|
||||
// Update tempo for future events
|
||||
microseconds_per_beat = new_tempo;
|
||||
}
|
||||
RawEvent::Midi {
|
||||
tick,
|
||||
channel,
|
||||
message,
|
||||
} => {
|
||||
// Calculate time for this event
|
||||
let delta_ticks = tick - last_tick;
|
||||
let delta_time = (delta_ticks as f64 / ticks_per_beat)
|
||||
* (microseconds_per_beat / 1_000_000.0);
|
||||
accumulated_time += delta_time;
|
||||
last_tick = tick;
|
||||
|
||||
// Store timestamp in seconds (sample-rate independent)
|
||||
let timestamp = accumulated_time;
|
||||
|
||||
match message {
|
||||
midly::MidiMessage::NoteOn { key, vel } => {
|
||||
let velocity = vel.as_int();
|
||||
if velocity > 0 {
|
||||
events.push(MidiEvent::note_on(
|
||||
timestamp,
|
||||
channel,
|
||||
key.as_int(),
|
||||
velocity,
|
||||
));
|
||||
} else {
|
||||
events.push(MidiEvent::note_off(timestamp, channel, key.as_int(), 64));
|
||||
let ch = channel.as_int();
|
||||
match message {
|
||||
midly::MidiMessage::NoteOn { key, vel } => {
|
||||
let velocity = vel.as_int();
|
||||
if velocity > 0 {
|
||||
events.push(MidiEvent::note_on(timestamp, ch, key.as_int(), velocity));
|
||||
} else {
|
||||
events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), 64));
|
||||
}
|
||||
}
|
||||
}
|
||||
midly::MidiMessage::NoteOff { key, vel } => {
|
||||
events.push(MidiEvent::note_off(
|
||||
timestamp,
|
||||
channel,
|
||||
key.as_int(),
|
||||
vel.as_int(),
|
||||
));
|
||||
}
|
||||
midly::MidiMessage::Controller { controller, value } => {
|
||||
let status = 0xB0 | channel;
|
||||
events.push(MidiEvent::new(
|
||||
timestamp,
|
||||
status,
|
||||
controller.as_int(),
|
||||
value.as_int(),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
// Ignore other MIDI messages
|
||||
midly::MidiMessage::NoteOff { key, vel } => {
|
||||
events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), vel.as_int()));
|
||||
}
|
||||
midly::MidiMessage::Controller { controller, value } => {
|
||||
let status = 0xB0 | ch;
|
||||
events.push(MidiEvent::new(timestamp, status, controller.as_int(), value.as_int()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {} // Tempo and other meta events don't affect beat positions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate final clip duration
|
||||
let final_delta_ticks = max_time_ticks - last_tick;
|
||||
let final_delta_time =
|
||||
(final_delta_ticks as f64 / ticks_per_beat) * (microseconds_per_beat / 1_000_000.0);
|
||||
let duration_seconds = accumulated_time + final_delta_time;
|
||||
|
||||
// Create the MIDI clip (content only, positioning happens when creating instance)
|
||||
let clip = MidiClip::new(clip_id, events, duration_seconds, "Imported MIDI".to_string());
|
||||
|
||||
let duration = Beats(max_tick as f64 / ticks_per_beat);
|
||||
let clip = MidiClip::new(clip_id, events, duration, "Imported MIDI".to_string());
|
||||
Ok(clip)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
|
||||
pub mod audio;
|
||||
pub mod command;
|
||||
pub mod time;
|
||||
pub mod tempo_map;
|
||||
pub mod dsp;
|
||||
pub mod effects;
|
||||
pub mod io;
|
||||
|
|
@ -18,6 +20,8 @@ pub use audio::{
|
|||
TrackNode,
|
||||
};
|
||||
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
|
||||
pub use time::{Beats, Seconds};
|
||||
pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
|
||||
pub use command::{AudioEvent, Command, OscilloscopeData};
|
||||
pub use command::types::AutomationKeyframeData;
|
||||
pub use io::{load_midi_file, AudioFile, WaveformChunk, WaveformChunkKey, WaveformPeak, WavWriter};
|
||||
|
|
@ -90,7 +94,7 @@ impl AudioSystem {
|
|||
// Create input ringbuffer for recording (large buffer for audio samples)
|
||||
// Buffer size: 10 seconds of audio at 48kHz stereo = 48000 * 2 * 10 = 960000 samples
|
||||
let input_buffer_size = (sample_rate * channels * 10) as usize;
|
||||
let (mut input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size);
|
||||
let (input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size);
|
||||
|
||||
// Create mirror ringbuffer for streaming recorded audio to UI (live waveform)
|
||||
let (mirror_tx, mirror_rx) = rtrb::RingBuffer::new(input_buffer_size);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -859,7 +859,7 @@ fn execute_command(
|
|||
|
||||
for event in &midi_clip.events {
|
||||
let status = event.status & 0xF0;
|
||||
let time_seconds = event.timestamp as f64 / sample_rate;
|
||||
let time_seconds = event.timestamp.beats_to_f64() / sample_rate;
|
||||
|
||||
match status {
|
||||
0x90 if event.data2 > 0 => {
|
||||
|
|
@ -878,7 +878,7 @@ fn execute_command(
|
|||
}
|
||||
|
||||
// Add to local UI state with note data
|
||||
app.add_clip(track_id, clip_id, start_time, duration, file_path.to_string(), notes);
|
||||
app.add_clip(track_id, clip_id, start_time, duration.beats_to_f64(), file_path.to_string(), notes);
|
||||
app.next_clip_id += 1;
|
||||
|
||||
// Send to audio engine with the start_time (clip content is separate from timeline position)
|
||||
|
|
|
|||
|
|
@ -79,8 +79,6 @@ impl Action for AddClipInstanceAction {
|
|||
if let Some(valid_start) = adjusted_start {
|
||||
// Update instance to use the valid position
|
||||
self.clip_instance.timeline_start = valid_start;
|
||||
let (bpm, fps) = (document.bpm, document.framerate);
|
||||
self.clip_instance.sync_from_seconds(bpm, fps);
|
||||
} else {
|
||||
// No valid position found - reject the operation
|
||||
return Err("Cannot add clip: no valid position found on layer (layer is full)".to_string());
|
||||
|
|
@ -210,10 +208,10 @@ impl Action for AddClipInstanceAction {
|
|||
let instance = daw_backend::MidiClipInstance::new(
|
||||
0, // Instance ID will be assigned by backend
|
||||
*midi_clip_id,
|
||||
internal_start,
|
||||
internal_end,
|
||||
external_start,
|
||||
external_duration,
|
||||
daw_backend::Beats(internal_start),
|
||||
daw_backend::Beats(internal_end),
|
||||
daw_backend::Beats(external_start),
|
||||
daw_backend::Beats(external_duration),
|
||||
);
|
||||
|
||||
// Send query to add instance and get instance ID
|
||||
|
|
|
|||
|
|
@ -1,248 +1,47 @@
|
|||
//! Change BPM action
|
||||
//! Change BPM / Tempo action
|
||||
//!
|
||||
//! Atomically changes the document BPM and rescales all clip instance positions and
|
||||
//! MIDI event timestamps so that beat positions are preserved (Measures mode behaviour).
|
||||
//! Atomically swaps the document `TempoMap`, sending the new map to the engine.
|
||||
//! All clip and note positions are stored in **beats** so no position rescaling
|
||||
//! is required — only the TempoMap entry changes.
|
||||
|
||||
use crate::action::{Action, BackendContext};
|
||||
use crate::clip::ClipInstance;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use crate::tempo_map::TempoMap;
|
||||
|
||||
/// Snapshot of all timing fields on a `ClipInstance`
|
||||
/// Action that changes the global BPM by replacing the tempo map.
|
||||
#[derive(Clone)]
|
||||
struct TimingFields {
|
||||
timeline_start: f64,
|
||||
timeline_start_beats: f64,
|
||||
timeline_start_frames: f64,
|
||||
trim_start: f64,
|
||||
trim_start_beats: f64,
|
||||
trim_start_frames: f64,
|
||||
trim_end: Option<f64>,
|
||||
trim_end_beats: Option<f64>,
|
||||
trim_end_frames: Option<f64>,
|
||||
timeline_duration: Option<f64>,
|
||||
timeline_duration_beats: Option<f64>,
|
||||
timeline_duration_frames: Option<f64>,
|
||||
}
|
||||
|
||||
impl TimingFields {
|
||||
fn from_instance(ci: &ClipInstance) -> Self {
|
||||
Self {
|
||||
timeline_start: ci.timeline_start,
|
||||
timeline_start_beats: ci.timeline_start_beats,
|
||||
timeline_start_frames: ci.timeline_start_frames,
|
||||
trim_start: ci.trim_start,
|
||||
trim_start_beats: ci.trim_start_beats,
|
||||
trim_start_frames: ci.trim_start_frames,
|
||||
trim_end: ci.trim_end,
|
||||
trim_end_beats: ci.trim_end_beats,
|
||||
trim_end_frames: ci.trim_end_frames,
|
||||
timeline_duration: ci.timeline_duration,
|
||||
timeline_duration_beats: ci.timeline_duration_beats,
|
||||
timeline_duration_frames: ci.timeline_duration_frames,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_to(&self, ci: &mut ClipInstance) {
|
||||
ci.timeline_start = self.timeline_start;
|
||||
ci.timeline_start_beats = self.timeline_start_beats;
|
||||
ci.timeline_start_frames = self.timeline_start_frames;
|
||||
ci.trim_start = self.trim_start;
|
||||
ci.trim_start_beats = self.trim_start_beats;
|
||||
ci.trim_start_frames = self.trim_start_frames;
|
||||
ci.trim_end = self.trim_end;
|
||||
ci.trim_end_beats = self.trim_end_beats;
|
||||
ci.trim_end_frames = self.trim_end_frames;
|
||||
ci.timeline_duration = self.timeline_duration;
|
||||
ci.timeline_duration_beats = self.timeline_duration_beats;
|
||||
ci.timeline_duration_frames = self.timeline_duration_frames;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ClipTimingSnapshot {
|
||||
layer_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
old_fields: TimingFields,
|
||||
new_fields: TimingFields,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MidiClipSnapshot {
|
||||
layer_id: Uuid,
|
||||
midi_clip_id: u32,
|
||||
clip_id: Uuid,
|
||||
old_clip_duration: f64,
|
||||
new_clip_duration: f64,
|
||||
old_events: Vec<daw_backend::audio::midi::MidiEvent>,
|
||||
new_events: Vec<daw_backend::audio::midi::MidiEvent>,
|
||||
}
|
||||
|
||||
/// Action that atomically changes BPM and rescales all clip/note positions to preserve beats
|
||||
pub struct ChangeBpmAction {
|
||||
old_bpm: f64,
|
||||
new_bpm: f64,
|
||||
time_sig: (u32, u32),
|
||||
clip_snapshots: Vec<ClipTimingSnapshot>,
|
||||
midi_snapshots: Vec<MidiClipSnapshot>,
|
||||
old_map: TempoMap,
|
||||
new_map: TempoMap,
|
||||
}
|
||||
|
||||
impl ChangeBpmAction {
|
||||
/// Build the action, computing new positions for all clip instances and MIDI events.
|
||||
///
|
||||
/// `midi_event_cache` maps backend MIDI clip ID → current event list.
|
||||
pub fn new(
|
||||
old_bpm: f64,
|
||||
new_bpm: f64,
|
||||
document: &Document,
|
||||
midi_event_cache: &HashMap<u32, Vec<daw_backend::audio::midi::MidiEvent>>,
|
||||
) -> Self {
|
||||
let fps = document.framerate;
|
||||
let time_sig = (
|
||||
document.time_signature.numerator,
|
||||
document.time_signature.denominator,
|
||||
);
|
||||
|
||||
let mut clip_snapshots: Vec<ClipTimingSnapshot> = Vec::new();
|
||||
let mut midi_snapshots: Vec<MidiClipSnapshot> = Vec::new();
|
||||
|
||||
// Collect MIDI clip IDs we've already snapshotted (avoid duplicates)
|
||||
let mut seen_midi_clips: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
|
||||
for layer in document.all_layers() {
|
||||
let layer_id = layer.id();
|
||||
|
||||
let clip_instances: &[ClipInstance] = match layer {
|
||||
AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &al.clip_instances,
|
||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
|
||||
};
|
||||
|
||||
for ci in clip_instances {
|
||||
let old_fields = TimingFields::from_instance(ci);
|
||||
|
||||
// Compute new fields: beats are canonical, recompute seconds + frames.
|
||||
// Guard: if timeline_start_beats was never populated (clips added without
|
||||
// sync_from_seconds), derive beats from seconds before applying.
|
||||
let mut new_ci = ci.clone();
|
||||
if new_ci.timeline_start_beats == 0.0 && new_ci.timeline_start.abs() > 1e-9 {
|
||||
new_ci.sync_from_seconds(old_bpm, fps);
|
||||
}
|
||||
new_ci.apply_beats(new_bpm, fps);
|
||||
let new_fields = TimingFields::from_instance(&new_ci);
|
||||
|
||||
clip_snapshots.push(ClipTimingSnapshot {
|
||||
layer_id,
|
||||
instance_id: ci.id,
|
||||
old_fields,
|
||||
new_fields,
|
||||
});
|
||||
|
||||
// If this is a MIDI clip on an audio layer, collect MIDI events + rescale duration.
|
||||
// Always snapshot the clip (even if empty) so clip.duration is rescaled.
|
||||
if let AnyLayer::Audio(_) = layer {
|
||||
if let Some(audio_clip) = document.get_audio_clip(&ci.clip_id) {
|
||||
use crate::clip::AudioClipType;
|
||||
if let AudioClipType::Midi { midi_clip_id } = &audio_clip.clip_type {
|
||||
let midi_id = *midi_clip_id;
|
||||
if !seen_midi_clips.contains(&midi_id) {
|
||||
seen_midi_clips.insert(midi_id);
|
||||
|
||||
let old_clip_duration = audio_clip.duration;
|
||||
let new_clip_duration = old_clip_duration * old_bpm / new_bpm;
|
||||
|
||||
// Use cached events if present; empty vec for clips with no events yet.
|
||||
let old_events = midi_event_cache.get(&midi_id).cloned().unwrap_or_default();
|
||||
let new_events: Vec<_> = old_events.iter().map(|ev| {
|
||||
let mut e = ev.clone();
|
||||
// Ensure beats are populated before using them as canonical.
|
||||
// Events created before triple-rep (e.g. from recording)
|
||||
// have timestamp_beats == 0.0 — sync from seconds first.
|
||||
if e.timestamp_beats == 0.0 && e.timestamp.abs() > 1e-9 {
|
||||
e.sync_from_seconds(old_bpm, fps);
|
||||
}
|
||||
e.apply_beats(new_bpm, fps);
|
||||
e
|
||||
}).collect();
|
||||
|
||||
midi_snapshots.push(MidiClipSnapshot {
|
||||
layer_id,
|
||||
midi_clip_id: midi_id,
|
||||
clip_id: ci.clip_id,
|
||||
old_clip_duration,
|
||||
new_clip_duration,
|
||||
old_events,
|
||||
new_events,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
old_bpm,
|
||||
new_bpm,
|
||||
time_sig,
|
||||
clip_snapshots,
|
||||
midi_snapshots,
|
||||
}
|
||||
/// Build the action from the current document state and a desired new BPM.
|
||||
pub fn new(new_bpm: f64, document: &Document) -> Self {
|
||||
let old_map = document.tempo_map().clone();
|
||||
let mut new_map = old_map.clone();
|
||||
new_map.set_global_bpm(new_bpm);
|
||||
Self { old_map, new_map }
|
||||
}
|
||||
|
||||
/// Return the new MIDI event lists for each affected clip (for immediate cache update).
|
||||
pub fn new_midi_events(&self) -> impl Iterator<Item = (u32, &Vec<daw_backend::audio::midi::MidiEvent>)> {
|
||||
self.midi_snapshots.iter().map(|s| (s.midi_clip_id, &s.new_events))
|
||||
/// Build from explicit old/new maps (used when the map already changed in-place,
|
||||
/// e.g., after live drag preview; the caller provides start and end states).
|
||||
pub fn from_maps(old_map: TempoMap, new_map: TempoMap) -> Self {
|
||||
Self { old_map, new_map }
|
||||
}
|
||||
|
||||
fn apply_clips(document: &mut Document, snapshots: &[ClipTimingSnapshot], use_new: bool) {
|
||||
for snap in snapshots {
|
||||
let fields = if use_new { &snap.new_fields } else { &snap.old_fields };
|
||||
|
||||
let layer = match document.get_layer_mut(&snap.layer_id) {
|
||||
Some(l) => l,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let clip_instances = match layer {
|
||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(ci) = clip_instances.iter_mut().find(|ci| ci.id == snap.instance_id) {
|
||||
fields.apply_to(ci);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_midi_durations(document: &mut Document, snapshots: &[MidiClipSnapshot], use_new: bool) {
|
||||
for snap in snapshots {
|
||||
if let Some(clip) = document.get_audio_clip_mut(&snap.clip_id) {
|
||||
clip.duration = if use_new { snap.new_clip_duration } else { snap.old_clip_duration };
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn new_bpm(&self) -> f64 { self.new_map.global_bpm() }
|
||||
pub fn old_bpm(&self) -> f64 { self.old_map.global_bpm() }
|
||||
}
|
||||
|
||||
impl Action for ChangeBpmAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
document.bpm = self.new_bpm;
|
||||
Self::apply_clips(document, &self.clip_snapshots, true);
|
||||
Self::apply_midi_durations(document, &self.midi_snapshots, true);
|
||||
*document.tempo_map_mut() = self.new_map.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
document.bpm = self.old_bpm;
|
||||
Self::apply_clips(document, &self.clip_snapshots, false);
|
||||
Self::apply_midi_durations(document, &self.midi_snapshots, false);
|
||||
*document.tempo_map_mut() = self.old_map.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -253,109 +52,26 @@ impl Action for ChangeBpmAction {
|
|||
fn execute_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
_document: &Document,
|
||||
) -> Result<(), String> {
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Update tempo
|
||||
controller.set_tempo(self.new_bpm as f32, self.time_sig);
|
||||
|
||||
// Update MIDI clip events and positions
|
||||
for snap in &self.midi_snapshots {
|
||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
||||
Some(&id) => id,
|
||||
None => continue,
|
||||
};
|
||||
controller.update_midi_clip_events(track_id, snap.midi_clip_id, snap.new_events.clone());
|
||||
}
|
||||
|
||||
// Move clip instances in the backend
|
||||
for snap in &self.clip_snapshots {
|
||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
||||
Some(&id) => id,
|
||||
None => continue,
|
||||
};
|
||||
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
|
||||
match backend_id {
|
||||
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
|
||||
controller.move_clip(track_id, *audio_id, snap.new_fields.timeline_start);
|
||||
}
|
||||
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
|
||||
controller.move_clip(track_id, *midi_id, snap.new_fields.timeline_start);
|
||||
}
|
||||
None => {} // Vector/video clips — no backend move needed
|
||||
}
|
||||
}
|
||||
|
||||
// Sync beat/frame representations 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);
|
||||
|
||||
controller.set_tempo_map(self.new_map.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
_document: &Document,
|
||||
) -> Result<(), String> {
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
controller.set_tempo(self.old_bpm as f32, self.time_sig);
|
||||
|
||||
for snap in &self.midi_snapshots {
|
||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
||||
Some(&id) => id,
|
||||
None => continue,
|
||||
};
|
||||
controller.update_midi_clip_events(track_id, snap.midi_clip_id, snap.old_events.clone());
|
||||
}
|
||||
|
||||
for snap in &self.clip_snapshots {
|
||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
||||
Some(&id) => id,
|
||||
None => continue,
|
||||
};
|
||||
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
|
||||
match backend_id {
|
||||
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
|
||||
controller.move_clip(track_id, *audio_id, snap.old_fields.timeline_start);
|
||||
}
|
||||
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
|
||||
controller.move_clip(track_id, *midi_id, snap.old_fields.timeline_start);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync beat/frame representations 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);
|
||||
|
||||
controller.set_tempo_map(self.old_map.clone());
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,156 +1,33 @@
|
|||
//! Change FPS action
|
||||
//!
|
||||
//! Atomically changes the document framerate and rescales all clip instance positions
|
||||
//! so that frame positions are preserved (Frames mode behaviour).
|
||||
//! Atomically changes the document framerate.
|
||||
//! All clip positions are stored in **beats** so no position rescaling is
|
||||
//! required — only the framerate field changes.
|
||||
|
||||
use crate::action::{Action, BackendContext};
|
||||
use crate::clip::ClipInstance;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Snapshot of all timing fields on a `ClipInstance`
|
||||
#[derive(Clone)]
|
||||
struct TimingFields {
|
||||
timeline_start: f64,
|
||||
timeline_start_beats: f64,
|
||||
timeline_start_frames: f64,
|
||||
trim_start: f64,
|
||||
trim_start_beats: f64,
|
||||
trim_start_frames: f64,
|
||||
trim_end: Option<f64>,
|
||||
trim_end_beats: Option<f64>,
|
||||
trim_end_frames: Option<f64>,
|
||||
timeline_duration: Option<f64>,
|
||||
timeline_duration_beats: Option<f64>,
|
||||
timeline_duration_frames: Option<f64>,
|
||||
}
|
||||
|
||||
impl TimingFields {
|
||||
fn from_instance(ci: &ClipInstance) -> Self {
|
||||
Self {
|
||||
timeline_start: ci.timeline_start,
|
||||
timeline_start_beats: ci.timeline_start_beats,
|
||||
timeline_start_frames: ci.timeline_start_frames,
|
||||
trim_start: ci.trim_start,
|
||||
trim_start_beats: ci.trim_start_beats,
|
||||
trim_start_frames: ci.trim_start_frames,
|
||||
trim_end: ci.trim_end,
|
||||
trim_end_beats: ci.trim_end_beats,
|
||||
trim_end_frames: ci.trim_end_frames,
|
||||
timeline_duration: ci.timeline_duration,
|
||||
timeline_duration_beats: ci.timeline_duration_beats,
|
||||
timeline_duration_frames: ci.timeline_duration_frames,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_to(&self, ci: &mut ClipInstance) {
|
||||
ci.timeline_start = self.timeline_start;
|
||||
ci.timeline_start_beats = self.timeline_start_beats;
|
||||
ci.timeline_start_frames = self.timeline_start_frames;
|
||||
ci.trim_start = self.trim_start;
|
||||
ci.trim_start_beats = self.trim_start_beats;
|
||||
ci.trim_start_frames = self.trim_start_frames;
|
||||
ci.trim_end = self.trim_end;
|
||||
ci.trim_end_beats = self.trim_end_beats;
|
||||
ci.trim_end_frames = self.trim_end_frames;
|
||||
ci.timeline_duration = self.timeline_duration;
|
||||
ci.timeline_duration_beats = self.timeline_duration_beats;
|
||||
ci.timeline_duration_frames = self.timeline_duration_frames;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ClipTimingSnapshot {
|
||||
layer_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
old_fields: TimingFields,
|
||||
new_fields: TimingFields,
|
||||
}
|
||||
|
||||
/// Action that atomically changes framerate and rescales all clip positions to preserve frames
|
||||
/// Action that changes the document framerate.
|
||||
pub struct ChangeFpsAction {
|
||||
old_fps: f64,
|
||||
new_fps: f64,
|
||||
clip_snapshots: Vec<ClipTimingSnapshot>,
|
||||
}
|
||||
|
||||
impl ChangeFpsAction {
|
||||
/// Build the action, computing new positions for all clip instances.
|
||||
pub fn new(old_fps: f64, new_fps: f64, document: &Document) -> Self {
|
||||
let bpm = document.bpm;
|
||||
|
||||
let mut clip_snapshots: Vec<ClipTimingSnapshot> = Vec::new();
|
||||
|
||||
for layer in document.all_layers() {
|
||||
let layer_id = layer.id();
|
||||
|
||||
let clip_instances: &[ClipInstance] = match layer {
|
||||
AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &al.clip_instances,
|
||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
|
||||
};
|
||||
|
||||
for ci in clip_instances {
|
||||
let old_fields = TimingFields::from_instance(ci);
|
||||
|
||||
// Compute new fields: frames are canonical, recompute seconds + beats
|
||||
let mut new_ci = ci.clone();
|
||||
new_ci.apply_frames(new_fps, bpm);
|
||||
let new_fields = TimingFields::from_instance(&new_ci);
|
||||
|
||||
clip_snapshots.push(ClipTimingSnapshot {
|
||||
layer_id,
|
||||
instance_id: ci.id,
|
||||
old_fields,
|
||||
new_fields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
old_fps,
|
||||
new_fps,
|
||||
clip_snapshots,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_clips(document: &mut Document, snapshots: &[ClipTimingSnapshot], use_new: bool) {
|
||||
for snap in snapshots {
|
||||
let fields = if use_new { &snap.new_fields } else { &snap.old_fields };
|
||||
|
||||
let layer = match document.get_layer_mut(&snap.layer_id) {
|
||||
Some(l) => l,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let clip_instances = match layer {
|
||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(ci) = clip_instances.iter_mut().find(|ci| ci.id == snap.instance_id) {
|
||||
fields.apply_to(ci);
|
||||
}
|
||||
}
|
||||
/// Build the action from old and new framerates.
|
||||
pub fn new(old_fps: f64, new_fps: f64, _document: &Document) -> Self {
|
||||
Self { old_fps, new_fps }
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for ChangeFpsAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
document.framerate = self.new_fps;
|
||||
Self::apply_clips(document, &self.clip_snapshots, true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
document.framerate = self.old_fps;
|
||||
Self::apply_clips(document, &self.clip_snapshots, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -160,68 +37,18 @@ impl Action for ChangeFpsAction {
|
|||
|
||||
fn execute_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
_backend: &mut BackendContext,
|
||||
_document: &Document,
|
||||
) -> Result<(), String> {
|
||||
// FPS change does not affect audio timing — only move clips that changed position
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
for snap in &self.clip_snapshots {
|
||||
if (snap.new_fields.timeline_start - snap.old_fields.timeline_start).abs() < 1e-9 {
|
||||
continue; // No movement, skip
|
||||
}
|
||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
||||
Some(&id) => id,
|
||||
None => continue,
|
||||
};
|
||||
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
|
||||
match backend_id {
|
||||
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
|
||||
controller.move_clip(track_id, *audio_id, snap.new_fields.timeline_start);
|
||||
}
|
||||
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
|
||||
controller.move_clip(track_id, *midi_id, snap.new_fields.timeline_start);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// FPS does not affect audio scheduling — nothing to do in the backend.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
_backend: &mut BackendContext,
|
||||
_document: &Document,
|
||||
) -> Result<(), String> {
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
for snap in &self.clip_snapshots {
|
||||
if (snap.new_fields.timeline_start - snap.old_fields.timeline_start).abs() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
||||
Some(&id) => id,
|
||||
None => continue,
|
||||
};
|
||||
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
|
||||
match backend_id {
|
||||
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
|
||||
controller.move_clip(track_id, *audio_id, snap.old_fields.timeline_start);
|
||||
}
|
||||
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
|
||||
controller.move_clip(track_id, *midi_id, snap.old_fields.timeline_start);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,9 +119,6 @@ impl Action for MoveClipInstancesAction {
|
|||
// Store adjusted moves for rollback
|
||||
self.layer_moves = adjusted_moves.clone();
|
||||
|
||||
let bpm = document.bpm;
|
||||
let fps = document.framerate;
|
||||
|
||||
// Apply all adjusted moves
|
||||
for (layer_id, moves) in &adjusted_moves {
|
||||
let layer = document.get_layer_mut(layer_id)
|
||||
|
|
@ -142,7 +139,6 @@ impl Action for MoveClipInstancesAction {
|
|||
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
|
||||
{
|
||||
clip_instance.timeline_start = *new;
|
||||
clip_instance.sync_from_seconds(bpm, fps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,8 +147,6 @@ impl Action for MoveClipInstancesAction {
|
|||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let bpm = document.bpm;
|
||||
let fps = document.framerate;
|
||||
for (layer_id, moves) in &self.layer_moves {
|
||||
let layer = document.get_layer_mut(layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
||||
|
|
@ -172,7 +166,6 @@ impl Action for MoveClipInstancesAction {
|
|||
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
|
||||
{
|
||||
clip_instance.timeline_start = *old;
|
||||
clip_instance.sync_from_seconds(bpm, fps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,10 +177,10 @@ impl Action for RemoveClipInstancesAction {
|
|||
let midi_instance = daw_backend::MidiClipInstance::new(
|
||||
0,
|
||||
*midi_clip_id,
|
||||
internal_start,
|
||||
internal_end,
|
||||
external_start,
|
||||
external_duration,
|
||||
daw_backend::Beats(internal_start),
|
||||
daw_backend::Beats(internal_end),
|
||||
daw_backend::Beats(external_start),
|
||||
daw_backend::Beats(external_duration),
|
||||
);
|
||||
|
||||
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
|
||||
|
|
|
|||
|
|
@ -126,8 +126,8 @@ impl Action for SplitClipInstanceAction {
|
|||
.get_clip_duration(&instance.clip_id)
|
||||
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
||||
|
||||
// Calculate the effective duration and timeline end
|
||||
let effective_duration = instance.effective_duration(clip_duration);
|
||||
// Calculate the effective duration and timeline end (both in beats)
|
||||
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
|
||||
let timeline_end = instance.timeline_start + effective_duration;
|
||||
|
||||
// Validate: split_time must be strictly within the clip's timeline span
|
||||
|
|
@ -179,7 +179,6 @@ impl Action for SplitClipInstanceAction {
|
|||
}
|
||||
|
||||
self.new_instance_id = Some(right_instance.id);
|
||||
right_instance.sync_from_seconds(document.bpm, document.framerate);
|
||||
|
||||
// Now modify the original (left) instance and add the new (right) instance
|
||||
let layer_mut = document
|
||||
|
|
@ -239,21 +238,6 @@ impl Action for SplitClipInstanceAction {
|
|||
}
|
||||
}
|
||||
|
||||
// Sync derived fields on the left (original) instance
|
||||
let (bpm, fps) = (document.bpm, document.framerate);
|
||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||
let cis: &mut Vec<crate::clip::ClipInstance> = match layer {
|
||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
_ => return { self.executed = true; Ok(()) },
|
||||
};
|
||||
if let Some(inst) = cis.iter_mut().find(|ci| ci.id == self.instance_id) {
|
||||
inst.sync_from_seconds(bpm, fps);
|
||||
}
|
||||
}
|
||||
|
||||
self.executed = true;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -406,10 +390,10 @@ impl Action for SplitClipInstanceAction {
|
|||
let instance = daw_backend::MidiClipInstance::new(
|
||||
0,
|
||||
*midi_clip_id,
|
||||
internal_start,
|
||||
internal_end,
|
||||
external_start,
|
||||
external_duration,
|
||||
daw_backend::Beats(internal_start),
|
||||
daw_backend::Beats(internal_end),
|
||||
daw_backend::Beats(external_start),
|
||||
daw_backend::Beats(external_duration),
|
||||
);
|
||||
|
||||
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ impl Action for TrimClipInstancesAction {
|
|||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
AnyLayer::Raster(_) => &[],
|
||||
};
|
||||
};
|
||||
|
||||
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
|
||||
let member_old_trim = instance.trim_start;
|
||||
|
|
@ -138,7 +138,7 @@ impl Action for TrimClipInstancesAction {
|
|||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
AnyLayer::Raster(_) => &[],
|
||||
};
|
||||
};
|
||||
|
||||
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
|
||||
let member_old_trim_end = instance.trim_end;
|
||||
|
|
@ -182,7 +182,7 @@ impl Action for TrimClipInstancesAction {
|
|||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
AnyLayer::Raster(_) => &[],
|
||||
};
|
||||
};
|
||||
|
||||
let instance = clip_instances.iter()
|
||||
.find(|ci| &ci.id == instance_id)
|
||||
|
|
@ -260,9 +260,6 @@ impl Action for TrimClipInstancesAction {
|
|||
// Store clamped trims for rollback
|
||||
self.layer_trims = clamped_trims.clone();
|
||||
|
||||
let bpm = document.bpm;
|
||||
let fps = document.framerate;
|
||||
|
||||
// Apply all clamped trims
|
||||
for (layer_id, trims) in &clamped_trims {
|
||||
let layer = match document.get_layer_mut(layer_id) {
|
||||
|
|
@ -297,7 +294,6 @@ impl Action for TrimClipInstancesAction {
|
|||
clip_instance.trim_end = new.trim_value;
|
||||
}
|
||||
}
|
||||
clip_instance.sync_from_seconds(bpm, fps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -305,8 +301,6 @@ impl Action for TrimClipInstancesAction {
|
|||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let bpm = document.bpm;
|
||||
let fps = document.framerate;
|
||||
for (layer_id, trims) in &self.layer_trims {
|
||||
let layer = match document.get_layer_mut(layer_id) {
|
||||
Some(l) => l,
|
||||
|
|
@ -340,7 +334,6 @@ impl Action for TrimClipInstancesAction {
|
|||
clip_instance.trim_end = old.trim_value;
|
||||
}
|
||||
}
|
||||
clip_instance.sync_from_seconds(bpm, fps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Beat/measure ↔ seconds conversion utilities
|
||||
|
||||
use crate::document::TimeSignature;
|
||||
use crate::tempo_map::TempoMap;
|
||||
|
||||
/// Position expressed as measure, beat, tick
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
|
@ -11,9 +12,8 @@ pub struct MeasurePosition {
|
|||
}
|
||||
|
||||
/// Convert a time in seconds to a measure position
|
||||
pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> MeasurePosition {
|
||||
let beats_per_second = bpm / 60.0;
|
||||
let total_beats = (time * beats_per_second).max(0.0);
|
||||
pub fn time_to_measure(time: f64, tempo_map: &TempoMap, time_sig: &TimeSignature) -> MeasurePosition {
|
||||
let total_beats = tempo_map.inverse_transform(time).max(0.0);
|
||||
let beats_per_measure = time_sig.numerator as f64;
|
||||
|
||||
let measure = (total_beats / beats_per_measure).floor() as u32 + 1;
|
||||
|
|
@ -24,21 +24,20 @@ pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> Measure
|
|||
}
|
||||
|
||||
/// Convert a measure position to seconds
|
||||
pub fn measure_to_time(pos: MeasurePosition, bpm: f64, time_sig: &TimeSignature) -> f64 {
|
||||
pub fn measure_to_time(pos: MeasurePosition, tempo_map: &TempoMap, time_sig: &TimeSignature) -> f64 {
|
||||
let beats_per_measure = time_sig.numerator as f64;
|
||||
let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure
|
||||
+ (pos.beat as f64 - 1.0)
|
||||
+ (pos.tick as f64 / 1000.0);
|
||||
let beats_per_second = bpm / 60.0;
|
||||
total_beats / beats_per_second
|
||||
tempo_map.transform(total_beats)
|
||||
}
|
||||
|
||||
/// Get the duration of one beat in seconds
|
||||
pub fn beat_duration(bpm: f64) -> f64 {
|
||||
60.0 / bpm
|
||||
/// Get the duration of one beat in seconds at the given beat position
|
||||
pub fn beat_duration(beat: f64, tempo_map: &TempoMap) -> f64 {
|
||||
60.0 / tempo_map.bpm_at(daw_backend::Beats(beat))
|
||||
}
|
||||
|
||||
/// Get the duration of one measure in seconds
|
||||
pub fn measure_duration(bpm: f64, time_sig: &TimeSignature) -> f64 {
|
||||
beat_duration(bpm) * time_sig.numerator as f64
|
||||
/// Get the duration of one measure in seconds at the given beat position
|
||||
pub fn measure_duration(beat: f64, tempo_map: &TempoMap, time_sig: &TimeSignature) -> f64 {
|
||||
beat_duration(beat, tempo_map) * time_sig.numerator as f64
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,15 +98,20 @@ impl VectorClip {
|
|||
///
|
||||
/// The `clip_duration_fn` resolves referenced clip durations for non-vector layers.
|
||||
/// Falls back to the stored `duration` field if no content exists.
|
||||
pub fn content_duration(&self, framerate: f64) -> f64 {
|
||||
self.content_duration_with(framerate, |_| None)
|
||||
pub fn content_duration(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
||||
self.content_duration_with(framerate, tempo_map, |_| None)
|
||||
}
|
||||
|
||||
/// Like `content_duration`, but with a closure that resolves clip durations
|
||||
/// for audio/video/effect clip instances inside this movie clip.
|
||||
pub fn content_duration_with(&self, framerate: f64, clip_duration_fn: impl Fn(&Uuid) -> Option<f64>) -> f64 {
|
||||
///
|
||||
/// `clip_duration_fn` returns clip content duration **in seconds**.
|
||||
/// Result is in **seconds**.
|
||||
pub fn content_duration_with(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap, clip_duration_fn: impl Fn(&Uuid) -> Option<f64>) -> f64 {
|
||||
let frame_duration = 1.0 / framerate;
|
||||
let mut last_time: Option<f64> = None;
|
||||
// Work in beats, convert to seconds at the end.
|
||||
let mut last_beats: Option<f64> = None;
|
||||
let mut last_secs: Option<f64> = None;
|
||||
|
||||
for layer_node in self.layers.iter() {
|
||||
// Check clip instances on ALL layer types (vector, audio, video, effect)
|
||||
|
|
@ -119,33 +124,38 @@ impl VectorClip {
|
|||
AnyLayer::Raster(_) => &[],
|
||||
};
|
||||
for ci in clip_instances {
|
||||
let end = if let Some(td) = ci.timeline_duration {
|
||||
ci.timeline_start + td
|
||||
// Compute end position of this clip instance in beats
|
||||
let end_beats = if let Some(td_beats) = ci.timeline_duration {
|
||||
ci.timeline_start + td_beats
|
||||
} else if let Some(te) = ci.trim_end {
|
||||
ci.timeline_start + (te - ci.trim_start).max(0.0)
|
||||
} else if let Some(clip_dur) = clip_duration_fn(&ci.clip_id) {
|
||||
ci.timeline_start + (clip_dur - ci.trim_start).max(0.0)
|
||||
let secs = (te - ci.trim_start).max(0.0);
|
||||
ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start
|
||||
} else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
|
||||
let secs = (clip_dur_secs - ci.trim_start).max(0.0);
|
||||
ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
last_time = Some(match last_time {
|
||||
Some(t) => t.max(end),
|
||||
None => end,
|
||||
});
|
||||
last_beats = Some(last_beats.map_or(end_beats, |t: f64| t.max(end_beats)));
|
||||
}
|
||||
|
||||
// Also check vector layer keyframes
|
||||
// Vector layer keyframes are in seconds
|
||||
if let AnyLayer::Vector(vector_layer) = &layer_node.data {
|
||||
if let Some(last_kf) = vector_layer.keyframes.last() {
|
||||
last_time = Some(match last_time {
|
||||
Some(t) => t.max(last_kf.time),
|
||||
None => last_kf.time,
|
||||
});
|
||||
last_secs = Some(last_secs.map_or(last_kf.time, |t: f64| t.max(last_kf.time)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match last_time {
|
||||
let from_clips = last_beats.map(|b| tempo_map.transform(b));
|
||||
let combined = match (from_clips, last_secs) {
|
||||
(Some(a), Some(b)) => Some(a.max(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(b)) => Some(b),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
match combined {
|
||||
Some(t) => t + frame_duration,
|
||||
None => self.duration,
|
||||
}
|
||||
|
|
@ -186,9 +196,10 @@ impl VectorClip {
|
|||
|
||||
// Handle nested clip instances recursively
|
||||
for clip_instance in &vector_layer.clip_instances {
|
||||
// Convert parent clip time to nested clip local time
|
||||
// Apply timeline offset and playback speed, then add trim offset
|
||||
let nested_clip_time = ((clip_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
// Convert parent clip time (seconds) to nested clip local time (seconds).
|
||||
// timeline_start is in beats; convert to seconds using document BPM.
|
||||
let start_secs = document.tempo_map().transform(clip_instance.timeline_start);
|
||||
let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
// Look up the nested clip definition
|
||||
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||
|
|
@ -485,12 +496,12 @@ impl AudioClip {
|
|||
pub fn new_midi(
|
||||
name: impl Into<String>,
|
||||
midi_clip_id: u32,
|
||||
duration: f64,
|
||||
duration: daw_backend::Beats,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.into(),
|
||||
duration,
|
||||
duration: duration.beats_to_f64(),
|
||||
clip_type: AudioClipType::Midi { midi_clip_id },
|
||||
folder_id: None,
|
||||
}
|
||||
|
|
@ -589,6 +600,12 @@ impl AnyClip {
|
|||
/// - Timeline placement (when this instance appears on the parent layer's timeline)
|
||||
/// - Trimming (trim_start, trim_end within the clip's internal content)
|
||||
/// - Playback speed (time remapping)
|
||||
///
|
||||
/// ## Coordinate systems
|
||||
/// - `timeline_start` / `timeline_duration` are in **beats** (quarter-note beats).
|
||||
/// Use [`crate::tempo_map::TempoMap::beats_to_seconds`] to convert to seconds.
|
||||
/// - `trim_start` / `trim_end` are in **seconds** (audio/video file seek offsets;
|
||||
/// not affected by BPM changes).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ClipInstance {
|
||||
/// Unique identifier for this instance
|
||||
|
|
@ -606,52 +623,25 @@ pub struct ClipInstance {
|
|||
/// Optional name for this instance
|
||||
pub name: Option<String>,
|
||||
|
||||
/// When this instance starts on the timeline (in seconds, relative to parent layer)
|
||||
/// This is the external positioning - where the instance appears on the timeline
|
||||
/// Default: 0.0 (start at beginning of layer)
|
||||
/// When this instance starts on the timeline, in **beats**.
|
||||
/// Default: 0.0
|
||||
pub timeline_start: f64,
|
||||
/// timeline_start in beats (quarter-note beats); derived from timeline_start
|
||||
#[serde(default)]
|
||||
pub timeline_start_beats: f64,
|
||||
/// timeline_start in frames; derived from timeline_start
|
||||
#[serde(default)]
|
||||
pub timeline_start_frames: f64,
|
||||
|
||||
/// How long this instance appears on the timeline (in seconds)
|
||||
/// If timeline_duration > (trim_end - trim_start), the trimmed content will loop
|
||||
/// How long this instance appears on the timeline, in **beats**.
|
||||
/// If set and longer than the trimmed content, the content will loop.
|
||||
/// Default: None (use trimmed clip duration, no looping)
|
||||
pub timeline_duration: Option<f64>,
|
||||
/// timeline_duration in beats; derived from timeline_duration
|
||||
#[serde(default)]
|
||||
pub timeline_duration_beats: Option<f64>,
|
||||
/// timeline_duration in frames; derived from timeline_duration
|
||||
#[serde(default)]
|
||||
pub timeline_duration_frames: Option<f64>,
|
||||
|
||||
/// Trim start: offset into the clip's internal content (in seconds)
|
||||
/// Allows trimming the beginning of the clip
|
||||
/// - For audio: offset into the audio file
|
||||
/// - For video: offset into the video file
|
||||
/// - For vector: offset into the animation timeline
|
||||
/// Default: 0.0 (start at beginning of clip)
|
||||
/// Trim start: offset into the clip's internal content, in **seconds**.
|
||||
/// - For audio: byte-offset into the audio file
|
||||
/// - For video: seek position in the video file
|
||||
/// - For vector: time offset into the animation
|
||||
/// Default: 0.0
|
||||
pub trim_start: f64,
|
||||
/// trim_start in beats; derived from trim_start
|
||||
#[serde(default)]
|
||||
pub trim_start_beats: f64,
|
||||
/// trim_start in frames; derived from trim_start
|
||||
#[serde(default)]
|
||||
pub trim_start_frames: f64,
|
||||
|
||||
/// Trim end: offset into the clip's internal content (in seconds)
|
||||
/// Allows trimming the end of the clip
|
||||
/// Trim end: offset into the clip's internal content, in **seconds**.
|
||||
/// Default: None (use full clip duration)
|
||||
pub trim_end: Option<f64>,
|
||||
/// trim_end in beats; derived from trim_end
|
||||
#[serde(default)]
|
||||
pub trim_end_beats: Option<f64>,
|
||||
/// trim_end in frames; derived from trim_end
|
||||
#[serde(default)]
|
||||
pub trim_end_frames: Option<f64>,
|
||||
|
||||
/// Playback speed multiplier
|
||||
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
|
||||
|
|
@ -663,7 +653,7 @@ pub struct ClipInstance {
|
|||
/// Default: 1.0
|
||||
pub gain: f32,
|
||||
|
||||
/// How far (in seconds) the looped content extends before timeline_start.
|
||||
/// How far (in beats) the looped content extends before timeline_start.
|
||||
/// When set, loop iterations are drawn/played before the content start.
|
||||
/// Default: None (no pre-loop)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -720,17 +710,9 @@ impl ClipInstance {
|
|||
opacity: 1.0,
|
||||
name: None,
|
||||
timeline_start: 0.0,
|
||||
timeline_start_beats: 0.0,
|
||||
timeline_start_frames: 0.0,
|
||||
timeline_duration: None,
|
||||
timeline_duration_beats: None,
|
||||
timeline_duration_frames: None,
|
||||
trim_start: 0.0,
|
||||
trim_start_beats: 0.0,
|
||||
trim_start_frames: 0.0,
|
||||
trim_end: None,
|
||||
trim_end_beats: None,
|
||||
trim_end_frames: None,
|
||||
playback_speed: 1.0,
|
||||
gain: 1.0,
|
||||
loop_before: None,
|
||||
|
|
@ -746,71 +728,15 @@ impl ClipInstance {
|
|||
opacity: 1.0,
|
||||
name: None,
|
||||
timeline_start: 0.0,
|
||||
timeline_start_beats: 0.0,
|
||||
timeline_start_frames: 0.0,
|
||||
timeline_duration: None,
|
||||
timeline_duration_beats: None,
|
||||
timeline_duration_frames: None,
|
||||
trim_start: 0.0,
|
||||
trim_start_beats: 0.0,
|
||||
trim_start_frames: 0.0,
|
||||
trim_end: None,
|
||||
trim_end_beats: None,
|
||||
trim_end_frames: None,
|
||||
playback_speed: 1.0,
|
||||
gain: 1.0,
|
||||
loop_before: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync beats and frames from the seconds fields (call after any seconds-based write).
|
||||
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
|
||||
self.timeline_start_beats = self.timeline_start * bpm / 60.0;
|
||||
self.timeline_start_frames = self.timeline_start * fps;
|
||||
self.trim_start_beats = self.trim_start * bpm / 60.0;
|
||||
self.trim_start_frames = self.trim_start * fps;
|
||||
self.trim_end_beats = self.trim_end.map(|v| v * bpm / 60.0);
|
||||
self.trim_end_frames = self.trim_end.map(|v| v * fps);
|
||||
self.timeline_duration_beats = self.timeline_duration.map(|v| v * bpm / 60.0);
|
||||
self.timeline_duration_frames = self.timeline_duration.map(|v| v * fps);
|
||||
}
|
||||
|
||||
/// Recompute seconds and frames from beats (call when BPM changes in Measures mode).
|
||||
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
|
||||
self.timeline_start = self.timeline_start_beats * 60.0 / bpm;
|
||||
self.timeline_start_frames = self.timeline_start * fps;
|
||||
self.trim_start = self.trim_start_beats * 60.0 / bpm;
|
||||
self.trim_start_frames = self.trim_start * fps;
|
||||
if let Some(b) = self.trim_end_beats {
|
||||
let s = b * 60.0 / bpm;
|
||||
self.trim_end = Some(s);
|
||||
self.trim_end_frames = Some(s * fps);
|
||||
}
|
||||
if let Some(b) = self.timeline_duration_beats {
|
||||
let s = b * 60.0 / bpm;
|
||||
self.timeline_duration = Some(s);
|
||||
self.timeline_duration_frames = Some(s * fps);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute seconds and beats from frames (call when FPS changes in Frames mode).
|
||||
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
|
||||
self.timeline_start = self.timeline_start_frames / fps;
|
||||
self.timeline_start_beats = self.timeline_start * bpm / 60.0;
|
||||
self.trim_start = self.trim_start_frames / fps;
|
||||
self.trim_start_beats = self.trim_start * bpm / 60.0;
|
||||
if let Some(f) = self.trim_end_frames {
|
||||
let s = f / fps;
|
||||
self.trim_end = Some(s);
|
||||
self.trim_end_beats = Some(s * bpm / 60.0);
|
||||
}
|
||||
if let Some(f) = self.timeline_duration_frames {
|
||||
let s = f / fps;
|
||||
self.timeline_duration = Some(s);
|
||||
self.timeline_duration_beats = Some(s * bpm / 60.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the transform
|
||||
pub fn with_transform(mut self, transform: Transform) -> Self {
|
||||
self.transform = transform;
|
||||
|
|
@ -861,83 +787,83 @@ impl ClipInstance {
|
|||
self
|
||||
}
|
||||
|
||||
/// Set explicit timeline duration by setting trim_end
|
||||
///
|
||||
/// For effect instances, this effectively sets the duration since
|
||||
/// effects have infinite internal duration (trim_start defaults to 0).
|
||||
pub fn with_timeline_duration(mut self, duration: f64) -> Self {
|
||||
self.trim_end = Some(self.trim_start + duration);
|
||||
/// Set explicit timeline duration (in beats) by directly setting `timeline_duration`.
|
||||
pub fn with_timeline_duration(mut self, duration_beats: f64) -> Self {
|
||||
self.timeline_duration = Some(duration_beats);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the effective duration of this instance (accounting for trimming and looping)
|
||||
/// If timeline_duration is set, returns that (enabling content looping)
|
||||
/// Otherwise returns the trimmed content duration
|
||||
pub fn effective_duration(&self, clip_duration: f64) -> f64 {
|
||||
// If timeline_duration is explicitly set, use that (for looping)
|
||||
if let Some(timeline_dur) = self.timeline_duration {
|
||||
return timeline_dur;
|
||||
}
|
||||
|
||||
// Otherwise, return the trimmed content duration
|
||||
let end = self.trim_end.unwrap_or(clip_duration);
|
||||
/// Content window size in seconds: `trim_end - trim_start`.
|
||||
/// Used for internal looping calculations.
|
||||
pub fn content_window_secs(&self, clip_duration_secs: f64) -> f64 {
|
||||
let end = self.trim_end.unwrap_or(clip_duration_secs);
|
||||
(end - self.trim_start).max(0.0)
|
||||
}
|
||||
|
||||
/// Get the effective start position on the timeline, accounting for loop_before.
|
||||
/// This is the left edge of the clip's visual extent.
|
||||
/// How long this instance appears on the timeline, in **beats**.
|
||||
///
|
||||
/// If `timeline_duration` is set, returns that (enabling content looping).
|
||||
/// Otherwise converts the content window from seconds to beats using the tempo map.
|
||||
pub fn effective_duration_beats(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
||||
if let Some(td) = self.timeline_duration {
|
||||
return td;
|
||||
}
|
||||
let window_secs = self.content_window_secs(clip_duration_secs);
|
||||
let start_secs = tempo_map.transform(self.timeline_start);
|
||||
tempo_map.inverse_transform(start_secs + window_secs) - self.timeline_start
|
||||
}
|
||||
|
||||
/// Left edge of the clip's visual extent on the timeline, in **beats**.
|
||||
pub fn effective_start(&self) -> f64 {
|
||||
self.timeline_start - self.loop_before.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Get the total visual duration including both loop_before and effective_duration.
|
||||
pub fn total_duration(&self, clip_duration: f64) -> f64 {
|
||||
self.loop_before.unwrap_or(0.0) + self.effective_duration(clip_duration)
|
||||
/// Total visual duration (loop_before + effective_duration), in **beats**.
|
||||
pub fn total_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
||||
self.loop_before.unwrap_or(0.0) + self.effective_duration_beats(clip_duration_secs, tempo_map)
|
||||
}
|
||||
|
||||
/// Remap timeline time to clip content time
|
||||
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
|
||||
///
|
||||
/// Takes a global timeline time and returns the corresponding time within this
|
||||
/// clip's content, accounting for:
|
||||
/// - Instance position (timeline_start)
|
||||
/// - Playback speed
|
||||
/// - Trimming (trim_start, trim_end)
|
||||
/// - Looping (if timeline_duration > content window)
|
||||
///
|
||||
/// Returns None if the clip instance is not active at the given timeline time.
|
||||
pub fn remap_time(&self, timeline_time: f64, clip_duration: f64) -> Option<f64> {
|
||||
// Check if clip instance is active at this time
|
||||
let instance_end = self.timeline_start + self.effective_duration(clip_duration);
|
||||
if timeline_time < self.timeline_start || timeline_time >= instance_end {
|
||||
/// Returns `None` if the clip instance is not active at `time_secs`.
|
||||
pub fn remap_time_secs(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option<f64> {
|
||||
let start_secs = tempo_map.transform(self.timeline_start);
|
||||
let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map);
|
||||
let end_secs = tempo_map.transform(self.timeline_start + dur_beats);
|
||||
|
||||
if time_secs < start_secs || time_secs >= end_secs {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate relative time within the instance (0.0 = start of instance)
|
||||
let relative_time = timeline_time - self.timeline_start;
|
||||
let relative_secs = time_secs - start_secs;
|
||||
let content_time = relative_secs * self.playback_speed;
|
||||
let content_window = self.content_window_secs(clip_duration_secs);
|
||||
|
||||
// Account for playback speed
|
||||
let content_time = relative_time * self.playback_speed;
|
||||
|
||||
// Get the content window size (the portion of clip we're sampling)
|
||||
let trim_end = self.trim_end.unwrap_or(clip_duration);
|
||||
let content_window = (trim_end - self.trim_start).max(0.0);
|
||||
|
||||
// If content_window is zero, can't sample anything
|
||||
if content_window == 0.0 {
|
||||
return Some(self.trim_start);
|
||||
}
|
||||
|
||||
// Apply looping if content exceeds the window
|
||||
let looped_time = if content_time > content_window {
|
||||
content_time % content_window
|
||||
} else {
|
||||
content_time
|
||||
};
|
||||
|
||||
// Add trim_start offset to get final clip time
|
||||
Some(self.trim_start + looped_time)
|
||||
}
|
||||
|
||||
/// Alias for `remap_time_secs`.
|
||||
#[inline]
|
||||
pub fn remap_time(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option<f64> {
|
||||
self.remap_time_secs(time_secs, clip_duration_secs, tempo_map)
|
||||
}
|
||||
|
||||
/// Alias for `effective_duration_beats`.
|
||||
#[inline]
|
||||
pub fn effective_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
||||
self.effective_duration_beats(clip_duration_secs, tempo_map)
|
||||
}
|
||||
|
||||
/// Convert to affine transform
|
||||
pub fn to_affine(&self) -> vello::kurbo::Affine {
|
||||
self.transform.to_affine()
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@
|
|||
use crate::asset_folder::AssetFolderTree;
|
||||
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
|
||||
use crate::effect::EffectDefinition;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::layer::{AnyLayer, GroupLayer};
|
||||
use crate::script::ScriptDefinition;
|
||||
use crate::layout::LayoutNode;
|
||||
use crate::shape::ShapeColor;
|
||||
use crate::tempo_map::TempoMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -131,6 +132,8 @@ impl Default for TimeSignature {
|
|||
}
|
||||
}
|
||||
|
||||
// Keep for backward serde compat (old files may have a `bpm` field); no longer used.
|
||||
#[allow(dead_code)]
|
||||
fn default_bpm() -> f64 { 120.0 }
|
||||
|
||||
/// How time is displayed in the timeline
|
||||
|
|
@ -173,14 +176,16 @@ pub struct Document {
|
|||
/// Framerate (frames per second)
|
||||
pub framerate: f64,
|
||||
|
||||
/// Tempo in beats per minute
|
||||
#[serde(default = "default_bpm")]
|
||||
pub bpm: f64,
|
||||
|
||||
/// Time signature
|
||||
#[serde(default)]
|
||||
pub time_signature: TimeSignature,
|
||||
|
||||
/// Master track (master bus + tempo automation lane).
|
||||
/// Stored separately from the root layer tree; shown in timeline when
|
||||
/// `show_master_track` is enabled in the editor state.
|
||||
#[serde(default)]
|
||||
pub master_layer: GroupLayer,
|
||||
|
||||
/// Duration in seconds
|
||||
pub duration: f64,
|
||||
|
||||
|
|
@ -266,8 +271,12 @@ impl Default for Document {
|
|||
width: 1920.0,
|
||||
height: 1080.0,
|
||||
framerate: 60.0,
|
||||
bpm: 120.0,
|
||||
time_signature: TimeSignature::default(),
|
||||
master_layer: {
|
||||
let mut ml = GroupLayer::new_master(120.0);
|
||||
ml.layer.id = uuid::Uuid::new_v4();
|
||||
ml
|
||||
},
|
||||
duration: 10.0,
|
||||
root: GraphicsObject::default(),
|
||||
vector_clips: HashMap::new(),
|
||||
|
|
@ -311,6 +320,28 @@ impl Document {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reference to the document's tempo map (stored on the master layer).
|
||||
pub fn tempo_map(&self) -> &TempoMap {
|
||||
self.master_layer.tempo_map.as_ref()
|
||||
.expect("master_layer always has a tempo_map")
|
||||
}
|
||||
|
||||
/// Mutable reference to the document's tempo map.
|
||||
pub fn tempo_map_mut(&mut self) -> &mut TempoMap {
|
||||
self.master_layer.tempo_map.as_mut()
|
||||
.expect("master_layer always has a tempo_map")
|
||||
}
|
||||
|
||||
/// Convenience accessor for the global BPM (first entry of the tempo map).
|
||||
pub fn bpm(&self) -> f64 {
|
||||
self.tempo_map().global_bpm()
|
||||
}
|
||||
|
||||
/// Set the global BPM. Rebuilds the tempo map's seconds cache.
|
||||
pub fn set_bpm(&mut self, bpm: f64) {
|
||||
self.tempo_map_mut().set_global_bpm(bpm);
|
||||
}
|
||||
|
||||
/// Rebuild the layer→clip reverse lookup map from all vector clips.
|
||||
/// Call after deserialization or bulk clip modifications.
|
||||
pub fn rebuild_layer_to_clip_map(&mut self) {
|
||||
|
|
@ -619,56 +650,6 @@ impl Document {
|
|||
layers
|
||||
}
|
||||
|
||||
/// Migrate old documents: compute beats/frames from seconds for any ClipInstance whose
|
||||
/// derived fields are still zero (i.e., documents saved before triple-representation).
|
||||
/// Call once after loading a document.
|
||||
pub fn sync_all_clip_positions(&mut self) {
|
||||
let bpm = self.bpm;
|
||||
let fps = self.framerate;
|
||||
|
||||
fn sync_list(list: &mut [crate::layer::AnyLayer], bpm: f64, fps: f64) {
|
||||
for layer in list.iter_mut() {
|
||||
match layer {
|
||||
crate::layer::AnyLayer::Vector(vl) => {
|
||||
for ci in &mut vl.clip_instances {
|
||||
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
|
||||
}
|
||||
}
|
||||
crate::layer::AnyLayer::Audio(al) => {
|
||||
for ci in &mut al.clip_instances {
|
||||
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
|
||||
}
|
||||
}
|
||||
crate::layer::AnyLayer::Video(vl) => {
|
||||
for ci in &mut vl.clip_instances {
|
||||
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
|
||||
}
|
||||
}
|
||||
crate::layer::AnyLayer::Effect(el) => {
|
||||
for ci in &mut el.clip_instances {
|
||||
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
|
||||
}
|
||||
}
|
||||
crate::layer::AnyLayer::Group(g) => {
|
||||
sync_list(&mut g.children, bpm, fps);
|
||||
}
|
||||
crate::layer::AnyLayer::Raster(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sync_list(&mut self.root.children, bpm, fps);
|
||||
for clip in self.vector_clips.values_mut() {
|
||||
for node in &mut clip.layers.roots {
|
||||
if let crate::layer::AnyLayer::Vector(vl) = &mut node.data {
|
||||
for ci in &mut vl.clip_instances {
|
||||
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === CLIP LIBRARY METHODS ===
|
||||
|
||||
/// Add a vector clip to the library
|
||||
|
|
@ -866,11 +847,12 @@ impl Document {
|
|||
if clip.is_group {
|
||||
Some(clip.duration)
|
||||
} else {
|
||||
Some(clip.content_duration_with(self.framerate, |id| {
|
||||
let tempo_map = self.tempo_map();
|
||||
Some(clip.content_duration_with(self.framerate, tempo_map, |id| {
|
||||
// Resolve nested clip durations (audio, video, other vector clips)
|
||||
if let Some(vc) = self.vector_clips.get(id) {
|
||||
// Avoid deep recursion — use stored duration for nested vector clips
|
||||
Some(vc.content_duration(self.framerate))
|
||||
Some(vc.content_duration(self.framerate, tempo_map))
|
||||
} else if let Some(ac) = self.audio_clips.get(id) {
|
||||
Some(ac.duration)
|
||||
} else if let Some(vc) = self.video_clips.get(id) {
|
||||
|
|
@ -961,7 +943,7 @@ impl Document {
|
|||
};
|
||||
|
||||
let instance_start = instance.effective_start();
|
||||
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration);
|
||||
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map());
|
||||
|
||||
// Check overlap: start_a < end_b AND start_b < end_a
|
||||
if start_time < instance_end && instance_start < end_time {
|
||||
|
|
@ -1019,7 +1001,7 @@ impl Document {
|
|||
|
||||
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
|
||||
let inst_start = instance.effective_start();
|
||||
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur);
|
||||
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
|
||||
occupied_ranges.push((inst_start, inst_end, instance.id));
|
||||
}
|
||||
}
|
||||
|
|
@ -1114,7 +1096,7 @@ impl Document {
|
|||
}
|
||||
if let Some(dur) = self.get_clip_duration(&inst.clip_id) {
|
||||
let start = inst.effective_start();
|
||||
let end = inst.timeline_start + inst.effective_duration(dur);
|
||||
let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
|
||||
non_group.push((start, end));
|
||||
}
|
||||
}
|
||||
|
|
@ -1184,7 +1166,7 @@ impl Document {
|
|||
|
||||
// Calculate other clip's extent (accounting for loop_before)
|
||||
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
||||
let other_end = other.timeline_start + other.effective_duration(clip_duration);
|
||||
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
|
||||
|
||||
// If this clip is to the left and closer than current nearest
|
||||
if other_end <= current_timeline_start && other_end > nearest_end {
|
||||
|
|
@ -1280,7 +1262,7 @@ impl Document {
|
|||
}
|
||||
|
||||
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
||||
let other_end = other.timeline_start + other.effective_duration(clip_duration);
|
||||
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
|
||||
|
||||
if other_end <= current_effective_start && other_end > nearest_end {
|
||||
nearest_end = other_end;
|
||||
|
|
|
|||
|
|
@ -142,16 +142,18 @@ impl EffectLayer {
|
|||
self.clip_instances.iter_mut().find(|e| &e.id == id)
|
||||
}
|
||||
|
||||
/// Get all clip instances (effects) that are active at a given time
|
||||
/// Get all clip instances (effects) that are active at a given time.
|
||||
///
|
||||
/// Uses `EFFECT_DURATION` to calculate effective duration for each instance.
|
||||
pub fn active_clip_instances_at(&self, time: f64) -> Vec<&ClipInstance> {
|
||||
/// `time_secs` is the playback time in seconds; `bpm` is used to convert
|
||||
/// `timeline_start` (beats) to seconds for comparison.
|
||||
pub fn active_clip_instances_at(&self, time_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Vec<&ClipInstance> {
|
||||
use crate::effect::EFFECT_DURATION;
|
||||
let time_beats = tempo_map.inverse_transform(time_secs);
|
||||
self.clip_instances
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
let end = e.timeline_start + e.effective_duration(EFFECT_DURATION);
|
||||
time >= e.timeline_start && time < end
|
||||
let end = e.timeline_start + e.effective_duration(EFFECT_DURATION, tempo_map);
|
||||
time_beats >= e.timeline_start && time_beats < end
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -239,13 +241,13 @@ mod tests {
|
|||
layer.add_clip_instance(effect2);
|
||||
|
||||
// At time 2: only effect1 active
|
||||
assert_eq!(layer.active_clip_instances_at(2.0).len(), 1);
|
||||
assert_eq!(layer.active_clip_instances_at(2.0, 60.0).len(), 1);
|
||||
|
||||
// At time 4: both effects active
|
||||
assert_eq!(layer.active_clip_instances_at(4.0).len(), 2);
|
||||
assert_eq!(layer.active_clip_instances_at(4.0, 60.0).len(), 2);
|
||||
|
||||
// At time 7: only effect2 active
|
||||
assert_eq!(layer.active_clip_instances_at(7.0).len(), 1);
|
||||
assert_eq!(layer.active_clip_instances_at(7.0, 60.0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ pub struct AudioExportSettings {
|
|||
|
||||
/// End time in seconds
|
||||
pub end_time: f64,
|
||||
|
||||
/// Project BPM (for beat-position scheduling during export)
|
||||
pub bpm: f64,
|
||||
}
|
||||
|
||||
impl Default for AudioExportSettings {
|
||||
|
|
@ -88,6 +91,7 @@ impl Default for AudioExportSettings {
|
|||
bitrate_kbps: 320,
|
||||
start_time: 0.0,
|
||||
end_time: 60.0,
|
||||
bpm: 120.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -491,6 +491,8 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
|
|||
// 5. Extract document and audio backend state
|
||||
let step5_start = std::time::Instant::now();
|
||||
let mut document = beam_project.ui_state;
|
||||
// Rebuild derived seconds cache on all TempoMap entries after deserialization.
|
||||
document.tempo_map_mut().rebuild_seconds();
|
||||
let mut audio_project = beam_project.audio_backend.project;
|
||||
let audio_pool_entries = beam_project.audio_backend.audio_pool_entries;
|
||||
let layer_to_track_map = beam_project.audio_backend.layer_to_track_map;
|
||||
|
|
|
|||
|
|
@ -256,15 +256,20 @@ pub fn hit_test_clip_instances(
|
|||
parent_transform: Affine,
|
||||
timeline_time: f64,
|
||||
) -> Option<Uuid> {
|
||||
let tempo_map = document.tempo_map();
|
||||
for clip_instance in clip_instances.iter().rev() {
|
||||
// Check time bounds: skip clip instances not active at this time
|
||||
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration);
|
||||
if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end {
|
||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
||||
let timeline_beats = tempo_map.inverse_transform(timeline_time);
|
||||
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
||||
continue;
|
||||
}
|
||||
|
||||
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
// clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds)
|
||||
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
||||
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||
vector_clip.calculate_content_bounds(document, clip_time)
|
||||
|
|
@ -294,16 +299,20 @@ pub fn hit_test_clip_instances_in_rect(
|
|||
timeline_time: f64,
|
||||
) -> Vec<Uuid> {
|
||||
let mut hits = Vec::new();
|
||||
let tempo_map = document.tempo_map();
|
||||
|
||||
for clip_instance in clip_instances {
|
||||
// Check time bounds: skip clip instances not active at this time
|
||||
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration);
|
||||
if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end {
|
||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
||||
let timeline_beats = tempo_map.inverse_transform(timeline_time);
|
||||
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
||||
continue;
|
||||
}
|
||||
|
||||
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
||||
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||
vector_clip.calculate_content_bounds(document, clip_time)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
//! Layers organize objects and shapes, and contain animation data.
|
||||
|
||||
use crate::animation::AnimationData;
|
||||
use crate::tempo_map::TempoMap;
|
||||
use crate::clip::ClipInstance;
|
||||
use crate::vector_graph::VectorGraph;
|
||||
use crate::effect_layer::EffectLayer;
|
||||
|
|
@ -720,6 +721,12 @@ pub struct GroupLayer {
|
|||
/// Whether the group is expanded in the timeline
|
||||
#[serde(default = "default_true")]
|
||||
pub expanded: bool,
|
||||
|
||||
/// Optional tempo map for this group. When `Some`, the group's children use
|
||||
/// this tempo map for beat-to-parent-time conversion. The master group always
|
||||
/// has `Some`; regular groups default to `None` (inherit parent tempo).
|
||||
#[serde(default)]
|
||||
pub tempo_map: Option<TempoMap>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
|
|
@ -746,6 +753,12 @@ impl LayerTrait for GroupLayer {
|
|||
fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; }
|
||||
}
|
||||
|
||||
impl Default for GroupLayer {
|
||||
fn default() -> Self {
|
||||
Self::new("Group")
|
||||
}
|
||||
}
|
||||
|
||||
impl GroupLayer {
|
||||
/// Create a new group layer
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
|
|
@ -753,6 +766,21 @@ impl GroupLayer {
|
|||
layer: Layer::new(LayerType::Group, name),
|
||||
children: Vec::new(),
|
||||
expanded: true,
|
||||
tempo_map: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a master group layer with a constant tempo.
|
||||
pub fn new_master(bpm: f64) -> Self {
|
||||
Self {
|
||||
layer: Layer::with_id(
|
||||
uuid::Uuid::nil(), // replaced by Document::new
|
||||
LayerType::Group,
|
||||
"Master",
|
||||
),
|
||||
children: Vec::new(),
|
||||
expanded: 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::Group(_) => &[], // no nested groups
|
||||
AnyLayer::Raster(_) => &[], // raster layers have no clip instances
|
||||
};
|
||||
};
|
||||
for ci in instances {
|
||||
result.push((child_id, ci));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Shared data structures and types
|
||||
|
||||
pub mod beat_time;
|
||||
pub mod tempo_map;
|
||||
pub mod gpu;
|
||||
pub mod layout;
|
||||
pub mod pane;
|
||||
|
|
|
|||
|
|
@ -391,9 +391,10 @@ pub fn render_layer_isolated(
|
|||
let mut video_mgr = video_manager.lock().unwrap();
|
||||
let mut instances = Vec::new();
|
||||
|
||||
let tempo_map = document.tempo_map();
|
||||
for clip_instance in &video_layer.clip_instances {
|
||||
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
||||
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else { continue };
|
||||
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else { continue };
|
||||
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time) else { continue };
|
||||
|
||||
// Evaluate animated transform properties.
|
||||
|
|
@ -458,8 +459,9 @@ pub fn render_layer_isolated(
|
|||
AnyLayer::Effect(effect_layer) => {
|
||||
// Effect layers are processed during compositing, not rendered to scene
|
||||
// Return early with a dedicated effect layer type
|
||||
let tempo_map = document.tempo_map();
|
||||
let active_effects: Vec<ClipInstance> = effect_layer
|
||||
.active_clip_instances_at(time)
|
||||
.active_clip_instances_at(time, tempo_map)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
|
@ -728,16 +730,19 @@ fn render_clip_instance(
|
|||
};
|
||||
|
||||
// Remap timeline time to clip's internal time
|
||||
let tempo_map = document.tempo_map();
|
||||
let clip_time = if vector_clip.is_group {
|
||||
// Groups are static — visible from timeline_start to the next keyframe boundary
|
||||
let end = group_end_time.unwrap_or(clip_instance.timeline_start);
|
||||
if time < clip_instance.timeline_start || time >= end {
|
||||
// Groups are static — visible from timeline_start to the next keyframe boundary.
|
||||
// timeline_start is in beats; group_end_time is in seconds (render time).
|
||||
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
||||
let end = group_end_time.unwrap_or(start_secs);
|
||||
if time < start_secs || time >= end {
|
||||
return;
|
||||
}
|
||||
0.0
|
||||
} else {
|
||||
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration);
|
||||
let Some(t) = clip_instance.remap_time(time, clip_dur) else {
|
||||
let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else {
|
||||
return; // Clip instance not active at this time
|
||||
};
|
||||
t
|
||||
|
|
@ -889,7 +894,8 @@ fn render_video_layer(
|
|||
};
|
||||
|
||||
// Remap timeline time to clip's internal time
|
||||
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else {
|
||||
let tempo_map = document.tempo_map();
|
||||
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else {
|
||||
continue; // Clip instance not active at this time
|
||||
};
|
||||
|
||||
|
|
@ -1553,13 +1559,15 @@ fn render_clip_instance_cpu(
|
|||
) {
|
||||
let Some(vector_clip) = document.vector_clips.get(&clip_instance.clip_id) else { return };
|
||||
|
||||
let tempo_map = document.tempo_map();
|
||||
let clip_time = if vector_clip.is_group {
|
||||
let end = group_end_time.unwrap_or(clip_instance.timeline_start);
|
||||
if time < clip_instance.timeline_start || time >= end { return; }
|
||||
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
||||
let end = group_end_time.unwrap_or(start_secs);
|
||||
if time < start_secs || time >= end { return; }
|
||||
0.0
|
||||
} else {
|
||||
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration);
|
||||
let Some(t) = clip_instance.remap_time(time, clip_dur) else { return };
|
||||
let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else { return };
|
||||
t
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
pub use daw_backend::tempo_map::{TempoEntry, TempoMap};
|
||||
|
|
@ -69,8 +69,9 @@ fn export_audio_daw_backend<P: AsRef<Path>>(
|
|||
channels: settings.channels,
|
||||
bit_depth: settings.bit_depth,
|
||||
mp3_bitrate: 320, // Not used for WAV/FLAC
|
||||
start_time: settings.start_time,
|
||||
end_time: settings.end_time,
|
||||
start_time: daw_backend::Seconds(settings.start_time),
|
||||
end_time: daw_backend::Seconds(settings.end_time),
|
||||
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
|
||||
};
|
||||
|
||||
// Use the existing DAW backend export function
|
||||
|
|
@ -105,8 +106,9 @@ fn export_audio_ffmpeg_mp3<P: AsRef<Path>>(
|
|||
channels: settings.channels,
|
||||
bit_depth: 16, // Unused
|
||||
mp3_bitrate: settings.bitrate_kbps,
|
||||
start_time: settings.start_time,
|
||||
end_time: settings.end_time,
|
||||
start_time: daw_backend::Seconds(settings.start_time),
|
||||
end_time: daw_backend::Seconds(settings.end_time),
|
||||
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
|
||||
};
|
||||
|
||||
// Step 1: Render audio to memory
|
||||
|
|
@ -294,8 +296,9 @@ fn export_audio_ffmpeg_aac<P: AsRef<Path>>(
|
|||
channels: settings.channels,
|
||||
bit_depth: 16, // Unused
|
||||
mp3_bitrate: settings.bitrate_kbps,
|
||||
start_time: settings.start_time,
|
||||
end_time: settings.end_time,
|
||||
start_time: daw_backend::Seconds(settings.start_time),
|
||||
end_time: daw_backend::Seconds(settings.end_time),
|
||||
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
|
||||
};
|
||||
|
||||
// Step 1: Render audio to memory
|
||||
|
|
|
|||
|
|
@ -696,8 +696,9 @@ impl ExportOrchestrator {
|
|||
channels: settings.channels,
|
||||
bit_depth: settings.bit_depth,
|
||||
mp3_bitrate: settings.bitrate_kbps,
|
||||
start_time: settings.start_time,
|
||||
end_time: settings.end_time,
|
||||
start_time: daw_backend::Seconds(settings.start_time),
|
||||
end_time: daw_backend::Seconds(settings.end_time),
|
||||
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
|
||||
};
|
||||
|
||||
// Use DAW backend export for all formats
|
||||
|
|
|
|||
|
|
@ -882,7 +882,7 @@ fn composite_document_to_hdr(
|
|||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||
effect_def,
|
||||
effect_instance.timeline_start,
|
||||
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
|
||||
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, document.tempo_map()),
|
||||
);
|
||||
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
||||
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
|
||||
|
|
|
|||
|
|
@ -330,6 +330,7 @@ impl From<MenuAction> for AppAction {
|
|||
MenuAction::AddTestClip => Self::AddTestClip,
|
||||
MenuAction::DeleteLayer => Self::DeleteLayer,
|
||||
MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility,
|
||||
MenuAction::ShowMasterTrack => Self::ToggleLayerVisibility, // not directly mappable
|
||||
MenuAction::NewKeyframe => Self::NewKeyframe,
|
||||
MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe,
|
||||
MenuAction::DeleteFrame => Self::DeleteFrame,
|
||||
|
|
|
|||
|
|
@ -1538,6 +1538,21 @@ impl EditorApp {
|
|||
fn sync_audio_layers_to_backend(&mut self) {
|
||||
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
|
||||
|
||||
// Ensure the master layer has a backend group track.
|
||||
let master_layer_id = self.action_executor.document().master_layer.layer.id;
|
||||
if !self.layer_to_track_map.contains_key(&master_layer_id) {
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let track_id_result = {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
controller.create_group_track_sync("[Master]".to_string(), None)
|
||||
};
|
||||
if let Ok(track_id) = track_id_result {
|
||||
self.layer_to_track_map.insert(master_layer_id, track_id);
|
||||
println!("✅ Created master bus track (TrackId: {})", track_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect audio layers from root and inside vector clips
|
||||
// Each entry: (layer_id, layer_name, audio_type, parent_clip_id)
|
||||
let mut audio_layers_to_sync: Vec<(uuid::Uuid, String, AudioLayerType, Option<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.
|
||||
|
|
@ -1844,7 +1870,7 @@ impl EditorApp {
|
|||
let mut result = Vec::new();
|
||||
for instance in clip_instances {
|
||||
if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) {
|
||||
let effective_duration = instance.effective_duration(clip_duration);
|
||||
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
|
||||
let timeline_end = instance.timeline_start + effective_duration;
|
||||
|
||||
const EPSILON: f64 = 0.001;
|
||||
|
|
@ -2698,7 +2724,7 @@ impl EditorApp {
|
|||
let mut duplicate = original.clone();
|
||||
duplicate.id = uuid::Uuid::new_v4();
|
||||
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(1.0);
|
||||
let effective_duration = original.effective_duration(clip_duration);
|
||||
let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
|
||||
duplicate.timeline_start = original.timeline_start + effective_duration;
|
||||
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
|
||||
duplicate.clip_id = *new_clip_def_id;
|
||||
|
|
@ -3483,6 +3509,14 @@ impl EditorApp {
|
|||
println!("Menu: Toggle Layer Visibility");
|
||||
// TODO: Implement toggle layer visibility
|
||||
}
|
||||
MenuAction::ShowMasterTrack => {
|
||||
// Toggle show_master_track on all Timeline pane instances
|
||||
for pane in self.pane_instances.values_mut() {
|
||||
if let panes::PaneInstance::Timeline(t) = pane {
|
||||
t.show_master_track = !t.show_master_track;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline menu
|
||||
MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => {
|
||||
|
|
@ -3771,7 +3805,7 @@ impl EditorApp {
|
|||
// Sync BPM/time signature to metronome
|
||||
let doc = self.action_executor.document();
|
||||
controller.set_tempo(
|
||||
doc.bpm as f32,
|
||||
doc.bpm() as f32,
|
||||
(doc.time_signature.numerator, doc.time_signature.denominator),
|
||||
);
|
||||
}
|
||||
|
|
@ -3834,9 +3868,6 @@ impl EditorApp {
|
|||
|
||||
// Rebuild MIDI event cache for all MIDI clips (needed for timeline/piano roll rendering)
|
||||
let step8_start = std::time::Instant::now();
|
||||
// Migrate old documents: compute beats/frames derived fields
|
||||
self.action_executor.document_mut().sync_all_clip_positions();
|
||||
|
||||
self.midi_event_cache.clear();
|
||||
let midi_clip_ids: Vec<u32> = self.action_executor.document()
|
||||
.audio_clips.values()
|
||||
|
|
@ -3859,19 +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);
|
||||
|
||||
// Sync beats/frames derived fields on MIDI events (migration for old documents)
|
||||
{
|
||||
let bpm = self.action_executor.document().bpm;
|
||||
let fps = self.action_executor.document().framerate;
|
||||
for events in self.midi_event_cache.values_mut() {
|
||||
for ev in events.iter_mut() {
|
||||
if ev.timestamp_beats == 0.0 && ev.timestamp.abs() > 1e-9 {
|
||||
ev.sync_from_seconds(bpm, fps);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset playback state
|
||||
self.playback_time = 0.0;
|
||||
self.is_playing = false;
|
||||
|
|
@ -4002,15 +4020,11 @@ impl EditorApp {
|
|||
/// Rebuild a MIDI event cache entry from backend note format.
|
||||
/// Called after undo/redo to keep the cache consistent with the backend.
|
||||
fn rebuild_midi_cache_entry(&mut self, clip_id: u32, notes: &[(f64, u8, u8, f64)]) {
|
||||
let bpm = self.action_executor.document().bpm;
|
||||
let fps = self.action_executor.document().framerate;
|
||||
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(notes.len() * 2);
|
||||
for &(start_time, note, velocity, duration) in notes {
|
||||
let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity);
|
||||
on.sync_from_seconds(bpm, fps);
|
||||
let on = daw_backend::audio::midi::MidiEvent::note_on(daw_backend::Beats(start_time), 0, note, velocity);
|
||||
events.push(on);
|
||||
let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + duration, 0, note, 0);
|
||||
off.sync_from_seconds(bpm, fps);
|
||||
let off = daw_backend::audio::midi::MidiEvent::note_off(daw_backend::Beats(start_time + duration), 0, note, 0);
|
||||
events.push(off);
|
||||
}
|
||||
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||
|
|
@ -4048,14 +4062,14 @@ impl EditorApp {
|
|||
let frontend_clip_id = self.action_executor.document_mut().add_audio_clip(clip);
|
||||
|
||||
println!("Imported MIDI '{}' ({:.1}s, {} total events) - Frontend ID: {}, Backend ID: {}",
|
||||
name, duration, event_count, frontend_clip_id, backend_clip_id);
|
||||
name, duration.beats_to_f64(), event_count, frontend_clip_id, backend_clip_id);
|
||||
|
||||
Some(ImportedAssetInfo {
|
||||
clip_id: frontend_clip_id,
|
||||
clip_type: panes::DragClipType::AudioMidi,
|
||||
name,
|
||||
dimensions: None,
|
||||
duration,
|
||||
duration: duration.beats_to_f64(),
|
||||
linked_audio_clip_id: None,
|
||||
})
|
||||
} else {
|
||||
|
|
@ -5032,7 +5046,7 @@ impl eframe::App for EditorApp {
|
|||
if let Some(doc_clip_id) = doc_clip_id {
|
||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
||||
if clip.is_recording() {
|
||||
clip.duration = duration;
|
||||
clip.duration = duration.seconds_to_f64();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5202,24 +5216,18 @@ impl eframe::App for EditorApp {
|
|||
}
|
||||
// Update the clip's duration so the timeline bar grows
|
||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
||||
clip.duration = duration;
|
||||
clip.duration = duration.beats_to_f64();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update midi_event_cache with notes captured so far
|
||||
// (inlined to avoid conflicting &mut self borrow)
|
||||
// Update midi_event_cache with notes captured so far.
|
||||
// Notes from get_notes_with_active() are already in beats.
|
||||
{
|
||||
let bpm = self.action_executor.document().bpm;
|
||||
let fps = self.action_executor.document().framerate;
|
||||
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(notes.len() * 2);
|
||||
for &(start_time, note, velocity, dur) in ¬es {
|
||||
let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity);
|
||||
on.sync_from_seconds(bpm, fps);
|
||||
events.push(on);
|
||||
let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + dur, 0, note, 0);
|
||||
off.sync_from_seconds(bpm, fps);
|
||||
events.push(off);
|
||||
for &(start_beats, note, velocity, dur_beats) in ¬es {
|
||||
events.push(daw_backend::audio::midi::MidiEvent::note_on(start_beats, 0, note, velocity));
|
||||
events.push(daw_backend::audio::midi::MidiEvent::note_off(start_beats + dur_beats, 0, note, 0));
|
||||
}
|
||||
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||
self.midi_event_cache.insert(clip_id, events);
|
||||
|
|
@ -5236,13 +5244,7 @@ impl eframe::App for EditorApp {
|
|||
match controller.query_midi_clip(track_id, clip_id) {
|
||||
Ok(midi_clip_data) => {
|
||||
drop(controller);
|
||||
let bpm = self.action_executor.document().bpm;
|
||||
let fps = self.action_executor.document().framerate;
|
||||
let mut final_events = midi_clip_data.events.clone();
|
||||
for ev in &mut final_events {
|
||||
ev.sync_from_seconds(bpm, fps);
|
||||
}
|
||||
self.midi_event_cache.insert(clip_id, final_events);
|
||||
self.midi_event_cache.insert(clip_id, midi_clip_data.events.clone());
|
||||
|
||||
// Update document clip with final duration and name
|
||||
let doc_clip_id = self.action_executor.document()
|
||||
|
|
@ -5655,10 +5657,14 @@ impl eframe::App for EditorApp {
|
|||
});
|
||||
|
||||
// Checked actions show "✔ Label"; hidden actions are not rendered at all
|
||||
let checked: &[crate::menu::MenuAction] = if self.count_in_enabled && self.metronome_enabled {
|
||||
&[crate::menu::MenuAction::ToggleCountIn]
|
||||
} else {
|
||||
&[]
|
||||
let master_track_shown = self.pane_instances.values().any(|p| {
|
||||
if let panes::PaneInstance::Timeline(t) = p { t.show_master_track } else { false }
|
||||
});
|
||||
let checked: &[crate::menu::MenuAction] = match (self.count_in_enabled && self.metronome_enabled, master_track_shown) {
|
||||
(true, true) => &[crate::menu::MenuAction::ToggleCountIn, crate::menu::MenuAction::ShowMasterTrack],
|
||||
(true, false) => &[crate::menu::MenuAction::ToggleCountIn],
|
||||
(false, true) => &[crate::menu::MenuAction::ShowMasterTrack],
|
||||
(false, false) => &[],
|
||||
};
|
||||
let hidden: &[crate::menu::MenuAction] = if timeline_is_measures && self.metronome_enabled {
|
||||
&[]
|
||||
|
|
|
|||
|
|
@ -313,6 +313,7 @@ pub enum MenuAction {
|
|||
AddTestClip, // For testing: adds a test clip to the asset library
|
||||
DeleteLayer,
|
||||
ToggleLayerVisibility,
|
||||
ShowMasterTrack,
|
||||
|
||||
// Timeline menu
|
||||
NewKeyframe,
|
||||
|
|
@ -412,6 +413,7 @@ impl MenuItemDef {
|
|||
const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip to Library", action: MenuAction::AddTestClip, shortcut: None };
|
||||
const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None };
|
||||
const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None };
|
||||
const SHOW_MASTER_TRACK: Self = Self { label: "Show Master Track", action: MenuAction::ShowMasterTrack, shortcut: None };
|
||||
|
||||
// Timeline menu items
|
||||
const NEW_KEYFRAME: Self = Self { label: "New Keyframe", action: MenuAction::NewKeyframe, shortcut: Some(Shortcut::new(ShortcutKey::K, NO_CTRL, NO_SHIFT, NO_ALT)) };
|
||||
|
|
@ -533,6 +535,8 @@ impl MenuItemDef {
|
|||
MenuDef::Separator,
|
||||
MenuDef::Item(&Self::DELETE_LAYER),
|
||||
MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY),
|
||||
MenuDef::Separator,
|
||||
MenuDef::Item(&Self::SHOW_MASTER_TRACK),
|
||||
],
|
||||
},
|
||||
// Timeline menu
|
||||
|
|
|
|||
|
|
@ -391,10 +391,10 @@ fn generate_midi_thumbnail(
|
|||
|
||||
// Draw note events
|
||||
for event in events {
|
||||
if !event.is_note_on() || event.timestamp > preview_duration {
|
||||
if !event.is_note_on() || event.timestamp.beats_to_f64() > preview_duration {
|
||||
continue;
|
||||
}
|
||||
let (timestamp, note_number) = (event.timestamp, event.data1);
|
||||
let (timestamp, note_number) = (event.timestamp.beats_to_f64(), event.data1);
|
||||
|
||||
let x = ((timestamp / preview_duration) * size as f64) as usize;
|
||||
|
||||
|
|
|
|||
|
|
@ -1147,7 +1147,7 @@ impl InfopanelPane {
|
|||
|
||||
let clip_dur = document.get_clip_duration(&ci.clip_id)
|
||||
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start);
|
||||
let total_dur = ci.total_duration(clip_dur);
|
||||
let total_dur = ci.total_duration(clip_dur, document.tempo_map());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Duration:");
|
||||
ui.label(format!("{:.2}s", total_dur));
|
||||
|
|
@ -1215,10 +1215,10 @@ impl InfopanelPane {
|
|||
let mut pending: std::collections::HashMap<u8, (f64, u8)> = std::collections::HashMap::new();
|
||||
for event in events {
|
||||
if event.is_note_on() {
|
||||
pending.insert(event.data1, (event.timestamp, event.data2));
|
||||
pending.insert(event.data1, (event.timestamp.beats_to_f64(), event.data2));
|
||||
} else if event.is_note_off() {
|
||||
if let Some((start, v)) = pending.remove(&event.data1) {
|
||||
notes.push((start, event.data1, v, event.timestamp - start));
|
||||
notes.push((start, event.data1, v, event.timestamp.beats_to_f64() - start));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,10 +255,10 @@ impl PianoRollPane {
|
|||
for event in events {
|
||||
let channel = event.status & 0x0F;
|
||||
if event.is_note_on() {
|
||||
active.insert(event.data1, (event.timestamp, event.data2, channel));
|
||||
active.insert(event.data1, (event.timestamp.beats_to_f64(), event.data2, channel));
|
||||
} else if event.is_note_off() {
|
||||
if let Some((start, vel, ch)) = active.remove(&event.data1) {
|
||||
let duration = (event.timestamp - start).max(MIN_NOTE_DURATION);
|
||||
let duration = (event.timestamp.beats_to_f64() - start).max(MIN_NOTE_DURATION);
|
||||
notes.push(ResolvedNote {
|
||||
note: event.data1,
|
||||
channel: ch,
|
||||
|
|
@ -292,13 +292,13 @@ impl PianoRollPane {
|
|||
|
||||
// ── Ruler interval calculation ───────────────────────────────────────
|
||||
|
||||
fn ruler_interval(&self, bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 {
|
||||
fn ruler_interval(&self, tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 {
|
||||
let min_pixel_gap = 80.0;
|
||||
let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64;
|
||||
|
||||
// Use beat-aligned intervals
|
||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
||||
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
|
||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
|
||||
let measure_dur = lightningbeam_core::beat_time::measure_duration(0.0, tempo_map, time_sig);
|
||||
let beat_intervals = [
|
||||
beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0,
|
||||
measure_dur, measure_dur * 2.0, measure_dur * 4.0,
|
||||
|
|
@ -315,8 +315,8 @@ impl PianoRollPane {
|
|||
|
||||
} // end impl PianoRollPane (snap helpers follow as free functions)
|
||||
|
||||
fn snap_to_value(t: f64, snap: SnapValue, bpm: f64) -> f64 {
|
||||
let beat = 60.0 / bpm;
|
||||
fn snap_to_value(t: f64, snap: SnapValue, tempo_map: &daw_backend::TempoMap) -> f64 {
|
||||
let beat = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
|
||||
match snap {
|
||||
SnapValue::None => t,
|
||||
SnapValue::Whole => round_to_grid(t, beat * 4.0),
|
||||
|
|
@ -347,7 +347,7 @@ fn snap_swing(t: f64, cell: f64, ratio: f64) -> f64 {
|
|||
*cands.iter().min_by(|&&a, &&b| (a - t).abs().partial_cmp(&(b - t).abs()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue {
|
||||
fn detect_snap(notes: &[&ResolvedNote], tempo_map: &daw_backend::TempoMap) -> SnapValue {
|
||||
const EPS: f64 = 0.002;
|
||||
if notes.is_empty() { return SnapValue::None; }
|
||||
let order = [
|
||||
|
|
@ -359,7 +359,7 @@ fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue {
|
|||
SnapValue::SixteenthTriplet, SnapValue::ThirtySecondTriplet,
|
||||
];
|
||||
for &sv in &order {
|
||||
if notes.iter().all(|n| (snap_to_value(n.start_time, sv, bpm) - n.start_time).abs() < EPS) {
|
||||
if notes.iter().all(|n| (snap_to_value(n.start_time, sv, tempo_map) - n.start_time).abs() < EPS) {
|
||||
return sv;
|
||||
}
|
||||
}
|
||||
|
|
@ -425,7 +425,7 @@ impl PianoRollPane {
|
|||
let clip_doc_id = clip_doc_id; // doc-side AudioClip uuid
|
||||
let duration = mc.external_duration;
|
||||
let instance_uuid = Uuid::nil(); // no doc-side instance uuid yet
|
||||
clip_data.push((mc.clip_id, mc.external_start, mc.internal_start, duration, instance_uuid));
|
||||
clip_data.push((mc.clip_id, mc.external_start.beats_to_f64(), mc.internal_start.beats_to_f64(), duration.beats_to_f64(), instance_uuid));
|
||||
let _ = clip_doc_id; // used above for the if-let pattern
|
||||
}
|
||||
}
|
||||
|
|
@ -435,7 +435,7 @@ impl PianoRollPane {
|
|||
for instance in &audio_layer.clip_instances {
|
||||
if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
|
||||
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type {
|
||||
let duration = instance.effective_duration(clip.duration);
|
||||
let duration = instance.effective_duration(clip.duration, document.tempo_map());
|
||||
clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id));
|
||||
}
|
||||
}
|
||||
|
|
@ -456,8 +456,8 @@ impl PianoRollPane {
|
|||
self.snap_user_changed = false;
|
||||
if self.snap_value != SnapValue::None && !self.selected_note_indices.is_empty() {
|
||||
if let Some(clip_id) = self.selected_clip_id {
|
||||
let bpm = shared.action_executor.document().bpm;
|
||||
self.quantize_selected_notes(clip_id, bpm, shared);
|
||||
let tempo_map = shared.action_executor.document().tempo_map().clone();
|
||||
self.quantize_selected_notes(clip_id, &tempo_map, shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -484,11 +484,11 @@ impl PianoRollPane {
|
|||
|
||||
// Render grid (clipped to grid area)
|
||||
let grid_painter = ui.painter_at(grid_rect);
|
||||
let (grid_bpm, grid_time_sig) = {
|
||||
let (grid_tempo_map, grid_time_sig) = {
|
||||
let doc = shared.action_executor.document();
|
||||
(doc.bpm, doc.time_signature.clone())
|
||||
(doc.tempo_map().clone(), doc.time_signature.clone())
|
||||
};
|
||||
self.render_grid(&grid_painter, grid_rect, grid_bpm, &grid_time_sig);
|
||||
self.render_grid(&grid_painter, grid_rect, &grid_tempo_map, &grid_time_sig);
|
||||
|
||||
// Render clip boundaries and notes
|
||||
for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data {
|
||||
|
|
@ -621,7 +621,7 @@ impl PianoRollPane {
|
|||
}
|
||||
|
||||
fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect,
|
||||
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||
tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||
// Horizontal lines (note separators)
|
||||
for note in MIN_NOTE..=MAX_NOTE {
|
||||
let y = self.note_to_y(note, grid_rect);
|
||||
|
|
@ -648,9 +648,9 @@ impl PianoRollPane {
|
|||
}
|
||||
|
||||
// Vertical lines (beat-aligned time grid)
|
||||
let interval = self.ruler_interval(bpm, time_sig);
|
||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
||||
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
|
||||
let interval = self.ruler_interval(tempo_map, time_sig);
|
||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
|
||||
let measure_dur = lightningbeam_core::beat_time::measure_duration(0.0, tempo_map, time_sig);
|
||||
|
||||
let start = (self.viewport_start_time / interval).floor() as i64;
|
||||
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
||||
|
|
@ -675,7 +675,7 @@ impl PianoRollPane {
|
|||
|
||||
// Labels at measure boundaries
|
||||
if is_measure && x > grid_rect.min.x + 20.0 {
|
||||
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
|
||||
let pos = lightningbeam_core::beat_time::time_to_measure(time, tempo_map, time_sig);
|
||||
painter.text(
|
||||
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
||||
Align2::LEFT_TOP,
|
||||
|
|
@ -685,7 +685,7 @@ impl PianoRollPane {
|
|||
);
|
||||
} else if is_beat && !is_measure && x > grid_rect.min.x + 20.0
|
||||
&& beat_dur as f32 * self.pixels_per_second > 40.0 {
|
||||
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
|
||||
let pos = lightningbeam_core::beat_time::time_to_measure(time, tempo_map, time_sig);
|
||||
painter.text(
|
||||
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
||||
Align2::LEFT_TOP,
|
||||
|
|
@ -708,8 +708,8 @@ impl PianoRollPane {
|
|||
) -> f32 {
|
||||
let mut peak = 0.0f32;
|
||||
for ev in events {
|
||||
if ev.timestamp > note_end + 0.01 { break; }
|
||||
if ev.timestamp >= note_start - 0.01
|
||||
if ev.timestamp.beats_to_f64() > note_end + 0.01 { break; }
|
||||
if ev.timestamp.beats_to_f64() >= note_start - 0.01
|
||||
&& (ev.status & 0xF0) == 0xE0
|
||||
&& (ev.status & 0x0F) == channel
|
||||
{
|
||||
|
|
@ -770,7 +770,7 @@ impl PianoRollPane {
|
|||
};
|
||||
let timestamp = note_start + t * note_duration;
|
||||
let (lsb, msb) = encode_bend(normalized);
|
||||
events.push(MidiEvent::new(timestamp, 0xE0 | channel, lsb, msb));
|
||||
events.push(MidiEvent::new(daw_backend::Beats(timestamp), 0xE0 | channel, lsb, msb));
|
||||
}
|
||||
events
|
||||
}
|
||||
|
|
@ -794,11 +794,11 @@ impl PianoRollPane {
|
|||
let ch = ev.status & 0x0F;
|
||||
let msg = ev.status & 0xF0;
|
||||
if msg == 0x90 && ev.data2 > 0 {
|
||||
active.insert((ev.data1, ch), ev.timestamp);
|
||||
active.insert((ev.data1, ch), ev.timestamp.beats_to_f64());
|
||||
} else if msg == 0x80 || (msg == 0x90 && ev.data2 == 0) {
|
||||
if let Some(start) = active.remove(&(ev.data1, ch)) {
|
||||
// Overlaps target range and is NOT the note we're assigning
|
||||
if start < note_end && ev.timestamp > note_start
|
||||
if start < note_end && ev.timestamp.beats_to_f64() > note_start
|
||||
&& !(ev.data1 == note_pitch && ch == current_channel)
|
||||
{
|
||||
used[ch as usize] = true;
|
||||
|
|
@ -828,9 +828,9 @@ impl PianoRollPane {
|
|||
fn find_cc1_for_note(events: &[daw_backend::audio::midi::MidiEvent], note_start: f64, note_end: f64, channel: u8) -> u8 {
|
||||
let mut cc1 = 0u8;
|
||||
for ev in events {
|
||||
if ev.timestamp > note_end { break; }
|
||||
if ev.timestamp.beats_to_f64() > note_end { break; }
|
||||
if (ev.status & 0xF0) == 0xB0 && (ev.status & 0x0F) == channel && ev.data1 == 1 {
|
||||
if ev.timestamp <= note_start {
|
||||
if ev.timestamp.beats_to_f64() <= note_start {
|
||||
cc1 = ev.data2;
|
||||
}
|
||||
}
|
||||
|
|
@ -950,7 +950,7 @@ impl PianoRollPane {
|
|||
let ts = note.start_time + t as f64 * note.duration;
|
||||
let mut existing_norm = 0.0f32;
|
||||
for ev in events {
|
||||
if ev.timestamp > ts { break; }
|
||||
if ev.timestamp.beats_to_f64() > ts { break; }
|
||||
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == drag_ch {
|
||||
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
|
||||
existing_norm = (raw - 8192) as f32 / 8192.0;
|
||||
|
|
@ -991,7 +991,7 @@ impl PianoRollPane {
|
|||
// Find last pitch bend event at or before ts
|
||||
let mut bend_norm = 0.0f32;
|
||||
for ev in events {
|
||||
if ev.timestamp > ts { break; }
|
||||
if ev.timestamp.beats_to_f64() > ts { break; }
|
||||
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == note.channel {
|
||||
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
|
||||
bend_norm = (raw - 8192) as f32 / 8192.0;
|
||||
|
|
@ -1037,9 +1037,9 @@ impl PianoRollPane {
|
|||
}
|
||||
|
||||
fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect,
|
||||
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||
tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||
// Collect visible time grid positions
|
||||
let interval = self.ruler_interval(bpm, time_sig);
|
||||
let interval = self.ruler_interval(tempo_map, time_sig);
|
||||
let start = (self.viewport_start_time / interval).floor() as i64;
|
||||
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
||||
let end = (end_time / interval).ceil() as i64;
|
||||
|
|
@ -1374,10 +1374,10 @@ impl PianoRollPane {
|
|||
if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) {
|
||||
let clip_start = selected_clip.1;
|
||||
let trim_start = selected_clip.2;
|
||||
let bpm = shared.action_executor.document().bpm;
|
||||
let tempo_map = shared.action_executor.document().tempo_map();
|
||||
let clip_local_time = snap_to_value(
|
||||
(time - clip_start).max(0.0) + trim_start,
|
||||
self.snap_value, bpm,
|
||||
self.snap_value, tempo_map,
|
||||
);
|
||||
self.creating_note = Some(TempNote {
|
||||
note,
|
||||
|
|
@ -1395,8 +1395,8 @@ impl PianoRollPane {
|
|||
self.selection_rect = Some((pos, pos));
|
||||
self.drag_mode = Some(DragMode::SelectRect);
|
||||
|
||||
let bpm = shared.action_executor.document().bpm;
|
||||
let seek_time = snap_to_value(time.max(0.0), self.snap_value, bpm);
|
||||
let tempo_map = shared.action_executor.document().tempo_map();
|
||||
let seek_time = snap_to_value(time.max(0.0), self.snap_value, tempo_map);
|
||||
*shared.playback_time = seek_time;
|
||||
if let Some(ctrl) = shared.audio_controller.as_ref() {
|
||||
if let Ok(mut c) = ctrl.lock() { c.seek(seek_time); }
|
||||
|
|
@ -1538,7 +1538,7 @@ impl PianoRollPane {
|
|||
let ts = note_start + t * note_duration;
|
||||
let mut bend = 0.0f32;
|
||||
for ev in &new_events {
|
||||
if ev.timestamp > ts { break; }
|
||||
if ev.timestamp.beats_to_f64() > ts { break; }
|
||||
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel {
|
||||
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
|
||||
bend = (raw - 8192) as f32 / 8192.0;
|
||||
|
|
@ -1550,7 +1550,8 @@ impl PianoRollPane {
|
|||
// Remove old bend events in range before writing combined
|
||||
new_events.retain(|ev| {
|
||||
let is_bend = (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel;
|
||||
let in_range = ev.timestamp >= note_start - 0.001 && ev.timestamp <= note_start + note_duration + 0.01;
|
||||
let ts = ev.timestamp.beats_to_f64();
|
||||
let in_range = ts >= note_start - 0.001 && ts <= note_start + note_duration + 0.01;
|
||||
!(is_bend && in_range)
|
||||
});
|
||||
|
||||
|
|
@ -1568,20 +1569,15 @@ impl PianoRollPane {
|
|||
let combined = (existing_norm[i] + zone_norm).clamp(-1.0, 1.0);
|
||||
let (lsb, msb) = encode_bend(combined);
|
||||
let ts = note_start + i as f64 / num_steps as f64 * note_duration;
|
||||
new_events.push(daw_backend::audio::midi::MidiEvent::new(ts, 0xE0 | target_channel, lsb, msb));
|
||||
new_events.push(daw_backend::audio::midi::MidiEvent::new(daw_backend::Beats(ts), 0xE0 | target_channel, lsb, msb));
|
||||
}
|
||||
// For End zone: reset just after note ends so it doesn't bleed into next note
|
||||
if zone == PitchBendZone::End {
|
||||
let (lsb, msb) = encode_bend(0.0);
|
||||
new_events.push(daw_backend::audio::midi::MidiEvent::new(note_start + note_duration + 0.005, 0xE0 | target_channel, lsb, msb));
|
||||
new_events.push(daw_backend::audio::midi::MidiEvent::new(daw_backend::Beats(note_start + note_duration + 0.005), 0xE0 | target_channel, lsb, msb));
|
||||
}
|
||||
|
||||
new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal));
|
||||
{
|
||||
let doc = shared.action_executor.document();
|
||||
let bpm = doc.bpm; let fps = doc.framerate;
|
||||
for ev in &mut new_events { ev.sync_from_seconds(bpm, fps); }
|
||||
}
|
||||
self.push_events_action("Set pitch bend", clip_id, old_events, new_events.clone(), shared);
|
||||
shared.midi_event_cache.insert(clip_id, new_events);
|
||||
}
|
||||
|
|
@ -1737,8 +1733,8 @@ impl PianoRollPane {
|
|||
fn update_cache_from_resolved(clip_id: u32, resolved: &[ResolvedNote], shared: &mut SharedPaneState) {
|
||||
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(resolved.len() * 2);
|
||||
for n in resolved {
|
||||
events.push(daw_backend::audio::midi::MidiEvent::note_on(n.start_time, 0, n.note, n.velocity));
|
||||
events.push(daw_backend::audio::midi::MidiEvent::note_off(n.start_time + n.duration, 0, n.note, 0));
|
||||
events.push(daw_backend::audio::midi::MidiEvent::note_on(daw_backend::Beats(n.start_time), 0, n.note, n.velocity));
|
||||
events.push(daw_backend::audio::midi::MidiEvent::note_off(daw_backend::Beats(n.start_time + n.duration), 0, n.note, 0));
|
||||
}
|
||||
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||
shared.midi_event_cache.insert(clip_id, events);
|
||||
|
|
@ -1787,12 +1783,12 @@ impl PianoRollPane {
|
|||
let resolved = Self::resolve_notes(events);
|
||||
let old_notes = Self::notes_to_backend_format(&resolved);
|
||||
|
||||
let bpm = shared.action_executor.document().bpm;
|
||||
let tempo_map = shared.action_executor.document().tempo_map();
|
||||
let mut new_resolved = resolved.clone();
|
||||
for &idx in &self.selected_note_indices {
|
||||
if idx < new_resolved.len() {
|
||||
let raw_time = (new_resolved[idx].start_time + dt).max(0.0);
|
||||
new_resolved[idx].start_time = snap_to_value(raw_time, self.snap_value, bpm);
|
||||
new_resolved[idx].start_time = snap_to_value(raw_time, self.snap_value, tempo_map);
|
||||
new_resolved[idx].note = (new_resolved[idx].note as i32 + dn).clamp(0, 127) as u8;
|
||||
}
|
||||
}
|
||||
|
|
@ -1968,7 +1964,7 @@ impl PianoRollPane {
|
|||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
|
||||
fn quantize_selected_notes(&mut self, clip_id: u32, bpm: f64, shared: &mut SharedPaneState) {
|
||||
fn quantize_selected_notes(&mut self, clip_id: u32, tempo_map: &daw_backend::TempoMap, shared: &mut SharedPaneState) {
|
||||
let events = match shared.midi_event_cache.get(&clip_id) { Some(e) => e, None => return };
|
||||
let resolved = Self::resolve_notes(events);
|
||||
let old_notes = Self::notes_to_backend_format(&resolved);
|
||||
|
|
@ -1976,7 +1972,7 @@ impl PianoRollPane {
|
|||
for &idx in &self.selected_note_indices {
|
||||
if idx < new_resolved.len() {
|
||||
new_resolved[idx].start_time =
|
||||
snap_to_value(new_resolved[idx].start_time, self.snap_value, bpm).max(0.0);
|
||||
snap_to_value(new_resolved[idx].start_time, self.snap_value, tempo_map).max(0.0);
|
||||
}
|
||||
}
|
||||
let new_notes = Self::notes_to_backend_format(&new_resolved);
|
||||
|
|
@ -2075,11 +2071,11 @@ impl PianoRollPane {
|
|||
// Dot grid background (visible where the spectrogram doesn't draw)
|
||||
let grid_painter = ui.painter_at(view_rect);
|
||||
{
|
||||
let (dot_bpm, dot_ts) = {
|
||||
let (dot_tempo_map, dot_ts) = {
|
||||
let doc = shared.action_executor.document();
|
||||
(doc.bpm, doc.time_signature.clone())
|
||||
(doc.tempo_map().clone(), doc.time_signature.clone())
|
||||
};
|
||||
self.render_dot_grid(&grid_painter, view_rect, dot_bpm, &dot_ts);
|
||||
self.render_dot_grid(&grid_painter, view_rect, &dot_tempo_map, &dot_ts);
|
||||
}
|
||||
|
||||
// Find audio pool index for the active layer's clips
|
||||
|
|
@ -2323,7 +2319,7 @@ impl PaneRenderer for PianoRollPane {
|
|||
for ev in cached.iter_mut() {
|
||||
if ev.is_note_on() && ev.data1 == sn.note
|
||||
&& (ev.status & 0x0F) == sn.channel
|
||||
&& (ev.timestamp - sn.start_time).abs() < 1e-6
|
||||
&& (ev.timestamp.beats_to_f64() - sn.start_time).abs() < 1e-6
|
||||
{
|
||||
ev.data2 = new_vel;
|
||||
}
|
||||
|
|
@ -2360,21 +2356,16 @@ impl PaneRenderer for PianoRollPane {
|
|||
let is_cc1 = (ev.status & 0xF0) == 0xB0
|
||||
&& (ev.status & 0x0F) == sn.channel
|
||||
&& ev.data1 == 1;
|
||||
let at_start = (ev.timestamp - sn.start_time).abs() < 0.001;
|
||||
let at_start = (ev.timestamp.beats_to_f64() - sn.start_time).abs() < 0.001;
|
||||
!(is_cc1 && at_start)
|
||||
});
|
||||
if new_cc1 > 0 {
|
||||
new_events.push(daw_backend::audio::midi::MidiEvent::new(
|
||||
sn.start_time, 0xB0 | sn.channel, 1, new_cc1,
|
||||
daw_backend::Beats(sn.start_time), 0xB0 | sn.channel, 1, new_cc1,
|
||||
));
|
||||
}
|
||||
}
|
||||
new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal));
|
||||
{
|
||||
let doc = shared.action_executor.document();
|
||||
let bpm = doc.bpm; let fps = doc.framerate;
|
||||
for ev in &mut new_events { ev.sync_from_seconds(bpm, fps); }
|
||||
}
|
||||
self.push_events_action("Set modulation", clip_id, old_events, new_events.clone(), shared);
|
||||
shared.midi_event_cache.insert(clip_id, new_events);
|
||||
}
|
||||
|
|
@ -2416,7 +2407,7 @@ impl PaneRenderer for PianoRollPane {
|
|||
// Snap-to dropdown — only in Measures mode
|
||||
let doc = shared.action_executor.document();
|
||||
let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures;
|
||||
let bpm = doc.bpm;
|
||||
let tempo_map = doc.tempo_map();
|
||||
drop(doc);
|
||||
|
||||
if is_measures {
|
||||
|
|
@ -2429,7 +2420,7 @@ impl PaneRenderer for PianoRollPane {
|
|||
let sel: Vec<&ResolvedNote> = self.selected_note_indices.iter()
|
||||
.filter_map(|&i| resolved.get(i))
|
||||
.collect();
|
||||
self.snap_value = detect_snap(&sel, bpm);
|
||||
self.snap_value = detect_snap(&sel, tempo_map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1438,7 +1438,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||
effect_def,
|
||||
effect_instance.timeline_start,
|
||||
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
|
||||
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, self.ctx.document.tempo_map()),
|
||||
);
|
||||
|
||||
// Acquire temp buffer for effect output (HDR format)
|
||||
|
|
@ -1793,7 +1793,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
||||
// Skip clip instances not active at current time
|
||||
let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur);
|
||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur, self.ctx.document.tempo_map());
|
||||
if self.ctx.playback_time < clip_instance.timeline_start || self.ctx.playback_time >= instance_end {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -2214,7 +2214,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
// Find clip instance visible at playback time
|
||||
let visible_clip = video_layer.clip_instances.iter().find(|inst| {
|
||||
let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(0.0);
|
||||
let effective_duration = inst.effective_duration(clip_duration);
|
||||
let effective_duration = inst.effective_duration(clip_duration, self.ctx.document.tempo_map());
|
||||
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
|
||||
});
|
||||
|
||||
|
|
@ -10287,7 +10287,7 @@ impl StagePane {
|
|||
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
|
||||
video_layer.clip_instances.iter().find(|inst| {
|
||||
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(0.0);
|
||||
let effective_duration = inst.effective_duration(clip_duration);
|
||||
let effective_duration = inst.effective_duration(clip_duration, document.tempo_map());
|
||||
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
|
||||
}).map(|inst| inst.id)
|
||||
} else {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue