Compare commits
4 Commits
cfb8e4462b
...
54d5764bd0
| Author | SHA1 | Date |
|---|---|---|
|
|
54d5764bd0 | |
|
|
f372a84313 | |
|
|
ae146533d9 | |
|
|
3fc4773ec3 |
|
|
@ -1,5 +1,6 @@
|
||||||
/// Automation system for parameter modulation over time
|
/// Automation system for parameter modulation over time
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::time::Beats;
|
||||||
|
|
||||||
/// Unique identifier for automation lanes
|
/// Unique identifier for automation lanes
|
||||||
pub type AutomationLaneId = u32;
|
pub type AutomationLaneId = u32;
|
||||||
|
|
@ -35,17 +36,13 @@ pub enum CurveType {
|
||||||
/// A single automation point
|
/// A single automation point
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct AutomationPoint {
|
pub struct AutomationPoint {
|
||||||
/// Time in seconds
|
pub time: Beats,
|
||||||
pub time: f64,
|
|
||||||
/// Parameter value (normalized 0.0 to 1.0, or actual value depending on parameter)
|
|
||||||
pub value: f32,
|
pub value: f32,
|
||||||
/// Curve type to next point
|
|
||||||
pub curve: CurveType,
|
pub curve: CurveType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutomationPoint {
|
impl AutomationPoint {
|
||||||
/// Create a new automation point
|
pub fn new(time: Beats, value: f32, curve: CurveType) -> Self {
|
||||||
pub fn new(time: f64, value: f32, curve: CurveType) -> Self {
|
|
||||||
Self { time, value, curve }
|
Self { time, value, curve }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,8 +90,7 @@ impl AutomationLane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove point at specific time
|
pub fn remove_point_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
|
||||||
pub fn remove_point_at_time(&mut self, time: f64, tolerance: f64) -> bool {
|
|
||||||
if let Some(idx) = self.points.iter().position(|p| (p.time - time).abs() < tolerance) {
|
if let Some(idx) = self.points.iter().position(|p| (p.time - time).abs() < tolerance) {
|
||||||
self.points.remove(idx);
|
self.points.remove(idx);
|
||||||
true
|
true
|
||||||
|
|
@ -113,8 +109,7 @@ impl AutomationLane {
|
||||||
&self.points
|
&self.points
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get value at a specific time with interpolation
|
pub fn evaluate(&self, time: Beats) -> Option<f32> {
|
||||||
pub fn evaluate(&self, time: f64) -> Option<f32> {
|
|
||||||
if !self.enabled || self.points.is_empty() {
|
if !self.enabled || self.points.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -148,14 +143,12 @@ impl AutomationLane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interpolate between two automation points based on curve type
|
fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: Beats) -> f32 {
|
||||||
fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: f64) -> f32 {
|
|
||||||
// Calculate normalized position between points (0.0 to 1.0)
|
|
||||||
let t = if p2.time == p1.time {
|
let t = if p2.time == p1.time {
|
||||||
0.0
|
0.0f64
|
||||||
} else {
|
} else {
|
||||||
((time - p1.time) / (p2.time - p1.time)) as f32
|
(time - p1.time) / (p2.time - p1.time)
|
||||||
};
|
} as f32;
|
||||||
|
|
||||||
// Apply curve
|
// Apply curve
|
||||||
let curved_t = match p1.curve {
|
let curved_t = match p1.curve {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
|
use crate::time::{Beats, Seconds};
|
||||||
|
use crate::tempo_map::TempoMap;
|
||||||
|
|
||||||
/// Audio clip instance ID type
|
/// Audio clip instance ID type
|
||||||
pub type AudioClipInstanceId = u32;
|
pub type AudioClipInstanceId = u32;
|
||||||
|
|
@ -9,47 +11,31 @@ pub type ClipId = AudioClipInstanceId;
|
||||||
|
|
||||||
/// Audio clip instance that references content in the AudioClipPool
|
/// Audio clip instance that references content in the AudioClipPool
|
||||||
///
|
///
|
||||||
/// This represents a placed instance of audio content on the timeline.
|
|
||||||
/// The actual audio data is stored in the AudioClipPool and referenced by `audio_pool_index`.
|
|
||||||
///
|
|
||||||
/// ## Timing Model
|
/// ## Timing Model
|
||||||
/// - `internal_start` / `internal_end`: Define the region of the source audio to play (trimming)
|
/// - `internal_start` / `internal_end`: Region of the source audio to play (seconds — audio file seek positions)
|
||||||
/// - `external_start` / `external_duration`: Define where the clip appears on the timeline and how long
|
/// - `external_start` / `external_duration`: Where the clip appears on the timeline (**beats**)
|
||||||
/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display
|
|
||||||
///
|
///
|
||||||
/// ## Looping
|
/// ## Looping
|
||||||
/// If `external_duration` is greater than `internal_end - internal_start`,
|
/// If `external_duration_secs(bpm)` > `internal_end - internal_start`, the clip loops.
|
||||||
/// the clip will seamlessly loop back to `internal_start` when it reaches `internal_end`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AudioClipInstance {
|
pub struct AudioClipInstance {
|
||||||
pub id: AudioClipInstanceId,
|
pub id: AudioClipInstanceId,
|
||||||
pub audio_pool_index: usize,
|
pub audio_pool_index: usize,
|
||||||
|
|
||||||
/// Start position within the audio content (seconds)
|
/// Start position within the audio content
|
||||||
pub internal_start: f64,
|
pub internal_start: Seconds,
|
||||||
#[serde(default)] pub internal_start_beats: f64,
|
/// End position within the audio content
|
||||||
#[serde(default)] pub internal_start_frames: f64,
|
pub internal_end: Seconds,
|
||||||
/// End position within the audio content (seconds)
|
|
||||||
pub internal_end: f64,
|
|
||||||
#[serde(default)] pub internal_end_beats: f64,
|
|
||||||
#[serde(default)] pub internal_end_frames: f64,
|
|
||||||
|
|
||||||
/// Start position on the timeline (seconds)
|
/// Start position on the timeline
|
||||||
pub external_start: f64,
|
pub external_start: Beats,
|
||||||
#[serde(default)] pub external_start_beats: f64,
|
/// Duration on the timeline
|
||||||
#[serde(default)] pub external_start_frames: f64,
|
pub external_duration: Beats,
|
||||||
/// Duration on the timeline (seconds) - can be longer than internal duration for looping
|
|
||||||
pub external_duration: f64,
|
|
||||||
#[serde(default)] pub external_duration_beats: f64,
|
|
||||||
#[serde(default)] pub external_duration_frames: f64,
|
|
||||||
|
|
||||||
/// Clip-level gain
|
/// Clip-level gain
|
||||||
pub gain: f32,
|
pub gain: f32,
|
||||||
|
|
||||||
/// Per-instance read-ahead buffer for compressed audio streaming.
|
/// Per-instance read-ahead buffer for compressed audio streaming.
|
||||||
/// Each clip instance gets its own buffer so multiple instances of the
|
|
||||||
/// same file (on different tracks or at different positions) don't fight
|
|
||||||
/// over a single target_frame.
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>,
|
pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>,
|
||||||
}
|
}
|
||||||
|
|
@ -58,154 +44,71 @@ pub struct AudioClipInstance {
|
||||||
pub type Clip = AudioClipInstance;
|
pub type Clip = AudioClipInstance;
|
||||||
|
|
||||||
impl AudioClipInstance {
|
impl AudioClipInstance {
|
||||||
/// Create a new audio clip instance
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: AudioClipInstanceId,
|
id: AudioClipInstanceId,
|
||||||
audio_pool_index: usize,
|
audio_pool_index: usize,
|
||||||
internal_start: f64,
|
internal_start: Seconds,
|
||||||
internal_end: f64,
|
internal_end: Seconds,
|
||||||
external_start: f64,
|
external_start: Beats,
|
||||||
external_duration: f64,
|
external_duration: Beats,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
audio_pool_index,
|
audio_pool_index,
|
||||||
internal_start,
|
internal_start,
|
||||||
internal_start_beats: 0.0,
|
|
||||||
internal_start_frames: 0.0,
|
|
||||||
internal_end,
|
internal_end,
|
||||||
internal_end_beats: 0.0,
|
|
||||||
internal_end_frames: 0.0,
|
|
||||||
external_start,
|
external_start,
|
||||||
external_start_beats: 0.0,
|
|
||||||
external_start_frames: 0.0,
|
|
||||||
external_duration,
|
external_duration,
|
||||||
external_duration_beats: 0.0,
|
|
||||||
external_duration_frames: 0.0,
|
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
read_ahead: None,
|
read_ahead: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a clip instance from legacy parameters (for backwards compatibility)
|
pub fn external_end(&self) -> Beats {
|
||||||
/// Maps old start_time/duration/offset to new timing model
|
|
||||||
pub fn from_legacy(
|
|
||||||
id: AudioClipInstanceId,
|
|
||||||
audio_pool_index: usize,
|
|
||||||
start_time: f64,
|
|
||||||
duration: f64,
|
|
||||||
offset: f64,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
audio_pool_index,
|
|
||||||
internal_start: offset,
|
|
||||||
internal_start_beats: 0.0,
|
|
||||||
internal_start_frames: 0.0,
|
|
||||||
internal_end: offset + duration,
|
|
||||||
internal_end_beats: 0.0,
|
|
||||||
internal_end_frames: 0.0,
|
|
||||||
external_start: start_time,
|
|
||||||
external_start_beats: 0.0,
|
|
||||||
external_start_frames: 0.0,
|
|
||||||
external_duration: duration,
|
|
||||||
external_duration_beats: 0.0,
|
|
||||||
external_duration_frames: 0.0,
|
|
||||||
gain: 1.0,
|
|
||||||
read_ahead: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this clip instance is active at a given timeline position
|
|
||||||
pub fn is_active_at(&self, time_seconds: f64) -> bool {
|
|
||||||
time_seconds >= self.external_start && time_seconds < self.external_end()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the end time of this clip instance on the timeline
|
|
||||||
pub fn external_end(&self) -> f64 {
|
|
||||||
self.external_start + self.external_duration
|
self.external_start + self.external_duration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the end time of this clip instance on the timeline
|
pub fn external_start_secs(&self, tempo_map: &TempoMap) -> Seconds {
|
||||||
/// (Alias for external_end(), for backwards compatibility)
|
tempo_map.beats_to_seconds(self.external_start)
|
||||||
pub fn end_time(&self) -> f64 {
|
|
||||||
self.external_end()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the start time on the timeline
|
pub fn external_end_secs(&self, tempo_map: &TempoMap) -> Seconds {
|
||||||
/// (Alias for external_start, for backwards compatibility)
|
tempo_map.beats_to_seconds(self.external_end())
|
||||||
pub fn start_time(&self) -> f64 {
|
|
||||||
self.external_start
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the internal (content) duration
|
pub fn external_duration_secs(&self, tempo_map: &TempoMap) -> Seconds {
|
||||||
pub fn internal_duration(&self) -> f64 {
|
tempo_map.beats_to_seconds(self.external_end()) - tempo_map.beats_to_seconds(self.external_start)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_active_at(&self, time: Seconds, tempo_map: &TempoMap) -> bool {
|
||||||
|
time >= self.external_start_secs(tempo_map) && time < self.external_end_secs(tempo_map)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn internal_duration(&self) -> Seconds {
|
||||||
self.internal_end - self.internal_start
|
self.internal_end - self.internal_start
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this clip instance loops
|
pub fn is_looping(&self, tempo_map: &TempoMap) -> bool {
|
||||||
pub fn is_looping(&self) -> bool {
|
self.external_duration_secs(tempo_map) > self.internal_duration()
|
||||||
self.external_duration > self.internal_duration()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the position within the audio content for a given timeline position
|
/// Get the audio content position for a given timeline position. Handles looping.
|
||||||
/// Returns None if the timeline position is outside this clip instance
|
pub fn get_content_position(&self, timeline_pos: Seconds, tempo_map: &TempoMap) -> Option<Seconds> {
|
||||||
/// Handles looping automatically
|
let start_secs = self.external_start_secs(tempo_map);
|
||||||
pub fn get_content_position(&self, timeline_pos: f64) -> Option<f64> {
|
let end_secs = self.external_end_secs(tempo_map);
|
||||||
if timeline_pos < self.external_start || timeline_pos >= self.external_end() {
|
if timeline_pos < start_secs || timeline_pos >= end_secs {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let relative_pos = timeline_pos - start_secs;
|
||||||
let relative_pos = timeline_pos - self.external_start;
|
|
||||||
let internal_duration = self.internal_duration();
|
let internal_duration = self.internal_duration();
|
||||||
|
if internal_duration.0 <= 0.0 {
|
||||||
if internal_duration <= 0.0 {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap around for looping
|
|
||||||
let content_offset = relative_pos % internal_duration;
|
let content_offset = relative_pos % internal_duration;
|
||||||
Some(self.internal_start + content_offset)
|
Some(self.internal_start + content_offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set clip gain
|
|
||||||
pub fn set_gain(&mut self, gain: f32) {
|
pub fn set_gain(&mut self, gain: f32) {
|
||||||
self.gain = gain.max(0.0);
|
self.gain = gain.max(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Populate beats/frames from the current seconds values.
|
|
||||||
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
|
|
||||||
self.external_start_beats = self.external_start * bpm / 60.0;
|
|
||||||
self.external_start_frames = self.external_start * fps;
|
|
||||||
self.external_duration_beats = self.external_duration * bpm / 60.0;
|
|
||||||
self.external_duration_frames = self.external_duration * fps;
|
|
||||||
self.internal_start_beats = self.internal_start * bpm / 60.0;
|
|
||||||
self.internal_start_frames = self.internal_start * fps;
|
|
||||||
self.internal_end_beats = self.internal_end * bpm / 60.0;
|
|
||||||
self.internal_end_frames = self.internal_end * fps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BPM changed; recompute seconds/frames from the stored beats values.
|
|
||||||
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
|
|
||||||
self.external_start = self.external_start_beats * 60.0 / bpm;
|
|
||||||
self.external_start_frames = self.external_start * fps;
|
|
||||||
self.external_duration = self.external_duration_beats * 60.0 / bpm;
|
|
||||||
self.external_duration_frames = self.external_duration * fps;
|
|
||||||
self.internal_start = self.internal_start_beats * 60.0 / bpm;
|
|
||||||
self.internal_start_frames = self.internal_start * fps;
|
|
||||||
self.internal_end = self.internal_end_beats * 60.0 / bpm;
|
|
||||||
self.internal_end_frames = self.internal_end * fps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FPS changed; recompute seconds/beats from the stored frames values.
|
|
||||||
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
|
|
||||||
self.external_start = self.external_start_frames / fps;
|
|
||||||
self.external_start_beats = self.external_start * bpm / 60.0;
|
|
||||||
self.external_duration = self.external_duration_frames / fps;
|
|
||||||
self.external_duration_beats = self.external_duration * bpm / 60.0;
|
|
||||||
self.internal_start = self.internal_start_frames / fps;
|
|
||||||
self.internal_start_beats = self.internal_start * bpm / 60.0;
|
|
||||||
self.internal_end = self.internal_end_frames / fps;
|
|
||||||
self.internal_end_beats = self.internal_end * bpm / 60.0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use crate::audio::recording::{MidiRecordingState, RecordingState};
|
||||||
use crate::audio::track::{Track, TrackId, TrackNode};
|
use crate::audio::track::{Track, TrackId, TrackNode};
|
||||||
use crate::command::{AudioEvent, Command, Query, QueryResponse};
|
use crate::command::{AudioEvent, Command, Query, QueryResponse};
|
||||||
use crate::io::MidiInputManager;
|
use crate::io::MidiInputManager;
|
||||||
|
use crate::time::{Beats, Seconds};
|
||||||
use petgraph::stable_graph::NodeIndex;
|
use petgraph::stable_graph::NodeIndex;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||||
|
|
@ -109,10 +110,11 @@ pub struct Engine {
|
||||||
timing_sum_total_us: u64,
|
timing_sum_total_us: u64,
|
||||||
timing_overrun_count: u64,
|
timing_overrun_count: u64,
|
||||||
|
|
||||||
// Current tempo/framerate — kept in sync with SetTempo/ApplyBpmChange so that
|
// Current tempo map — kept in sync with SetTempo/SetTempoMap commands.
|
||||||
// newly-created clip instances can be immediately synced via sync_from_seconds.
|
tempo_map: crate::TempoMap,
|
||||||
current_bpm: f64,
|
|
||||||
current_fps: f64,
|
current_fps: f64,
|
||||||
|
// Current time signature — updated by SetTempo, used when SetTempoMap fires.
|
||||||
|
time_sig: (u32, u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
|
|
@ -189,8 +191,9 @@ impl Engine {
|
||||||
timing_worst_render_us: 0,
|
timing_worst_render_us: 0,
|
||||||
timing_sum_total_us: 0,
|
timing_sum_total_us: 0,
|
||||||
timing_overrun_count: 0,
|
timing_overrun_count: 0,
|
||||||
current_bpm: 120.0,
|
tempo_map: crate::TempoMap::constant(120.0),
|
||||||
current_fps: 30.0,
|
current_fps: 30.0,
|
||||||
|
time_sig: (4, 4),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,7 +382,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert playhead from frames to seconds for timeline-based rendering
|
// Convert playhead from frames to seconds for timeline-based rendering
|
||||||
let playhead_seconds = self.playhead as f64 / self.sample_rate as f64;
|
let playhead_seconds = Seconds(self.playhead as f64 / self.sample_rate as f64);
|
||||||
|
|
||||||
// Reset per-clip read-ahead targets before rendering.
|
// Reset per-clip read-ahead targets before rendering.
|
||||||
self.project.reset_read_ahead_targets();
|
self.project.reset_read_ahead_targets();
|
||||||
|
|
@ -390,6 +393,7 @@ impl Engine {
|
||||||
&self.audio_pool,
|
&self.audio_pool,
|
||||||
&mut self.buffer_pool,
|
&mut self.buffer_pool,
|
||||||
playhead_seconds,
|
playhead_seconds,
|
||||||
|
&self.tempo_map,
|
||||||
self.sample_rate,
|
self.sample_rate,
|
||||||
self.channels,
|
self.channels,
|
||||||
false,
|
false,
|
||||||
|
|
@ -427,7 +431,8 @@ impl Engine {
|
||||||
|
|
||||||
// Send MIDI recording progress if active
|
// Send MIDI recording progress if active
|
||||||
if let Some(recording) = &self.midi_recording_state {
|
if let Some(recording) = &self.midi_recording_state {
|
||||||
let current_time = self.playhead as f64 / self.sample_rate as f64;
|
let current_time_secs = Seconds(self.playhead as f64 / self.sample_rate as f64);
|
||||||
|
let current_time = self.tempo_map.seconds_to_beats(current_time_secs);
|
||||||
let duration = current_time - recording.start_time;
|
let duration = current_time - recording.start_time;
|
||||||
let notes = recording.get_notes_with_active(current_time);
|
let notes = recording.get_notes_with_active(current_time);
|
||||||
let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress(
|
let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress(
|
||||||
|
|
@ -451,7 +456,7 @@ impl Engine {
|
||||||
} else {
|
} else {
|
||||||
// Not playing: render live MIDI (keyboard input + note-off tails) through the
|
// Not playing: render live MIDI (keyboard input + note-off tails) through the
|
||||||
// normal group hierarchy so mixer gain is correctly applied.
|
// normal group hierarchy so mixer gain is correctly applied.
|
||||||
let playhead_seconds = self.playhead as f64 / self.sample_rate as f64;
|
let playhead_seconds = Seconds(self.playhead as f64 / self.sample_rate as f64);
|
||||||
if self.mix_buffer.len() != output.len() {
|
if self.mix_buffer.len() != output.len() {
|
||||||
self.mix_buffer.resize(output.len(), 0.0);
|
self.mix_buffer.resize(output.len(), 0.0);
|
||||||
}
|
}
|
||||||
|
|
@ -463,6 +468,7 @@ impl Engine {
|
||||||
&self.audio_pool,
|
&self.audio_pool,
|
||||||
&mut self.buffer_pool,
|
&mut self.buffer_pool,
|
||||||
playhead_seconds,
|
playhead_seconds,
|
||||||
|
&self.tempo_map,
|
||||||
self.sample_rate,
|
self.sample_rate,
|
||||||
self.channels,
|
self.channels,
|
||||||
true, // live_only
|
true, // live_only
|
||||||
|
|
@ -562,7 +568,8 @@ impl Engine {
|
||||||
if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) {
|
||||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||||
clip.internal_end = clip.internal_start + duration;
|
clip.internal_end = clip.internal_start + duration;
|
||||||
clip.external_duration = duration;
|
let duration_beats = self.tempo_map.seconds_to_beats(duration);
|
||||||
|
clip.external_duration = duration_beats;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -734,20 +741,16 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::MoveClip(track_id, clip_id, new_start_time) => {
|
Command::MoveClip(track_id, clip_id, new_start_time) => {
|
||||||
// Moving just changes external_start, external_duration stays the same
|
// Moving just changes external_start (beats), external_duration stays the same
|
||||||
let bpm = self.current_bpm;
|
|
||||||
let fps = self.current_fps;
|
|
||||||
match self.project.get_track_mut(track_id) {
|
match self.project.get_track_mut(track_id) {
|
||||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||||
clip.external_start = new_start_time;
|
clip.external_start = Beats(new_start_time);
|
||||||
clip.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||||
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) {
|
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) {
|
||||||
instance.external_start = new_start_time;
|
instance.external_start = Beats(new_start_time);
|
||||||
instance.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -757,24 +760,19 @@ impl Engine {
|
||||||
Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => {
|
Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => {
|
||||||
// Trim changes which portion of the source content is used
|
// Trim changes which portion of the source content is used
|
||||||
// Also updates external_duration to match internal duration (no looping after trim)
|
// Also updates external_duration to match internal duration (no looping after trim)
|
||||||
let bpm = self.current_bpm;
|
|
||||||
let fps = self.current_fps;
|
|
||||||
match self.project.get_track_mut(track_id) {
|
match self.project.get_track_mut(track_id) {
|
||||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||||
clip.internal_start = new_internal_start;
|
clip.internal_start = Seconds(new_internal_start);
|
||||||
clip.internal_end = new_internal_end;
|
clip.internal_end = Seconds(new_internal_end);
|
||||||
clip.external_duration = new_internal_end - new_internal_start;
|
clip.external_duration = Beats(new_internal_end - new_internal_start);
|
||||||
clip.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||||
// Note: clip_id here is the pool clip ID, not instance ID
|
|
||||||
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
|
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
|
||||||
instance.internal_start = new_internal_start;
|
instance.internal_start = Beats(new_internal_start);
|
||||||
instance.internal_end = new_internal_end;
|
instance.internal_end = Beats(new_internal_end);
|
||||||
instance.external_duration = new_internal_end - new_internal_start;
|
instance.external_duration = Beats(new_internal_end - new_internal_start);
|
||||||
instance.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -782,21 +780,16 @@ impl Engine {
|
||||||
self.refresh_clip_snapshot();
|
self.refresh_clip_snapshot();
|
||||||
}
|
}
|
||||||
Command::ExtendClip(track_id, clip_id, new_external_duration) => {
|
Command::ExtendClip(track_id, clip_id, new_external_duration) => {
|
||||||
// Extend changes the external duration (enables looping if > internal duration)
|
// Extend changes the external duration (beats; enables looping if > internal duration)
|
||||||
let bpm = self.current_bpm;
|
|
||||||
let fps = self.current_fps;
|
|
||||||
match self.project.get_track_mut(track_id) {
|
match self.project.get_track_mut(track_id) {
|
||||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||||
clip.external_duration = new_external_duration;
|
clip.external_duration = Beats(new_external_duration);
|
||||||
clip.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||||
// Note: clip_id here is the pool clip ID, not instance ID
|
|
||||||
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
|
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
|
||||||
instance.external_duration = new_external_duration;
|
instance.external_duration = Beats(new_external_duration);
|
||||||
instance.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -823,7 +816,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Command::SetOffset(track_id, offset) => {
|
Command::SetOffset(track_id, offset) => {
|
||||||
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||||
metatrack.offset = offset;
|
metatrack.offset = Seconds(offset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::SetPitchShift(track_id, semitones) => {
|
Command::SetPitchShift(track_id, semitones) => {
|
||||||
|
|
@ -833,12 +826,12 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Command::SetTrimStart(track_id, trim_start) => {
|
Command::SetTrimStart(track_id, trim_start) => {
|
||||||
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||||
metatrack.trim_start = trim_start.max(0.0);
|
metatrack.trim_start = Seconds(trim_start.max(0.0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::SetTrimEnd(track_id, trim_end) => {
|
Command::SetTrimEnd(track_id, trim_end) => {
|
||||||
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||||
metatrack.trim_end = trim_end.map(|t| t.max(0.0));
|
metatrack.trim_end = trim_end.map(|t| Seconds(t.max(0.0)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::CreateAudioTrack(name, parent_id) => {
|
Command::CreateAudioTrack(name, parent_id) => {
|
||||||
|
|
@ -915,14 +908,20 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => {
|
Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => {
|
||||||
// Create a new clip instance with the pre-assigned clip_id
|
// Create a new clip instance with the pre-assigned clip_id
|
||||||
let mut clip = AudioClipInstance::from_legacy(
|
// start_time and duration are in beats; offset (internal_start) is seconds
|
||||||
|
let start_beats = Beats(start_time);
|
||||||
|
let end_beats = Beats(start_time + duration);
|
||||||
|
let start_secs = self.tempo_map.beats_to_seconds(start_beats);
|
||||||
|
let end_secs = self.tempo_map.beats_to_seconds(end_beats);
|
||||||
|
let content_dur_secs = (end_secs - start_secs).seconds_to_f64();
|
||||||
|
let clip = AudioClipInstance::new(
|
||||||
clip_id,
|
clip_id,
|
||||||
pool_index,
|
pool_index,
|
||||||
start_time,
|
Seconds(offset),
|
||||||
duration,
|
Seconds(offset + content_dur_secs),
|
||||||
offset,
|
start_beats,
|
||||||
|
Beats(duration),
|
||||||
);
|
);
|
||||||
clip.sync_from_seconds(self.current_bpm, self.current_fps);
|
|
||||||
|
|
||||||
// Add clip to track
|
// Add clip to track
|
||||||
if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) {
|
||||||
|
|
@ -945,13 +944,12 @@ impl Engine {
|
||||||
let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
|
let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
// Create clip content in the pool
|
// Create clip content in the pool
|
||||||
let clip = MidiClip::empty(clip_id, duration, format!("MIDI Clip {}", clip_id));
|
let clip = MidiClip::empty(clip_id, Beats(duration), format!("MIDI Clip {}", clip_id));
|
||||||
self.project.midi_clip_pool.add_existing_clip(clip);
|
self.project.midi_clip_pool.add_existing_clip(clip);
|
||||||
|
|
||||||
// Create an instance for this clip on the track
|
// Create an instance for this clip on the track
|
||||||
let instance_id = self.project.next_midi_clip_instance_id();
|
let instance_id = self.project.next_midi_clip_instance_id();
|
||||||
let mut instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time);
|
let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time));
|
||||||
instance.sync_from_seconds(self.current_bpm, self.current_fps);
|
|
||||||
|
|
||||||
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||||
track.clip_instances.push(instance);
|
track.clip_instances.push(instance);
|
||||||
|
|
@ -965,12 +963,12 @@ impl Engine {
|
||||||
// Add a MIDI note event to the specified clip in the pool
|
// Add a MIDI note event to the specified clip in the pool
|
||||||
// Note: clip_id here refers to the clip in the pool, not the instance
|
// Note: clip_id here refers to the clip in the pool, not the instance
|
||||||
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) {
|
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) {
|
||||||
// Timestamp is now in seconds (sample-rate independent)
|
// Timestamp is in beats (canonical)
|
||||||
let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
|
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity);
|
||||||
clip.add_event(note_on);
|
clip.add_event(note_on);
|
||||||
|
|
||||||
// Add note off event
|
// Add note off event
|
||||||
let note_off_time = time_offset + duration;
|
let note_off_time = Beats(time_offset + duration);
|
||||||
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
||||||
clip.add_event(note_off);
|
clip.add_event(note_off);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -979,9 +977,9 @@ impl Engine {
|
||||||
if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) {
|
if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) {
|
||||||
let actual_clip_id = instance.clip_id;
|
let actual_clip_id = instance.clip_id;
|
||||||
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) {
|
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) {
|
||||||
let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
|
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity);
|
||||||
clip.add_event(note_on);
|
clip.add_event(note_on);
|
||||||
let note_off_time = time_offset + duration;
|
let note_off_time = Beats(time_offset + duration);
|
||||||
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
||||||
clip.add_event(note_off);
|
clip.add_event(note_off);
|
||||||
}
|
}
|
||||||
|
|
@ -991,14 +989,8 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Command::AddLoadedMidiClip(track_id, clip, start_time) => {
|
Command::AddLoadedMidiClip(track_id, clip, start_time) => {
|
||||||
// Add a pre-loaded MIDI clip to the track with the given start time
|
// Add a pre-loaded MIDI clip to the track with the given start time
|
||||||
let bpm = self.current_bpm;
|
if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) {
|
||||||
let fps = self.current_fps;
|
// instance positions are already in beats; nothing to sync
|
||||||
if let Ok(instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) {
|
|
||||||
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
|
||||||
if let Some(inst) = track.clip_instances.iter_mut().find(|i| i.id == instance_id) {
|
|
||||||
inst.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.refresh_clip_snapshot();
|
self.refresh_clip_snapshot();
|
||||||
}
|
}
|
||||||
|
|
@ -1009,13 +1001,13 @@ impl Engine {
|
||||||
clip.events.clear();
|
clip.events.clear();
|
||||||
|
|
||||||
// Add new events from the notes array
|
// Add new events from the notes array
|
||||||
// Timestamps are now stored in seconds (sample-rate independent)
|
// Timestamps are in beats (canonical)
|
||||||
for (start_time, note, velocity, duration) in notes {
|
for (start_time, note, velocity, duration) in notes {
|
||||||
let note_on = MidiEvent::note_on(start_time, 0, note, velocity);
|
let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity);
|
||||||
clip.events.push(note_on);
|
clip.events.push(note_on);
|
||||||
|
|
||||||
// Add note off event
|
// Add note off event
|
||||||
let note_off_time = start_time + duration;
|
let note_off_time = Beats(start_time + duration);
|
||||||
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
||||||
clip.events.push(note_off);
|
clip.events.push(note_off);
|
||||||
}
|
}
|
||||||
|
|
@ -1077,7 +1069,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => {
|
Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => {
|
||||||
// Add an automation point to the specified lane
|
// Add an automation point to the specified lane
|
||||||
let point = crate::audio::AutomationPoint::new(time, value, curve);
|
let point = crate::audio::AutomationPoint::new(Beats(time), value, curve);
|
||||||
|
|
||||||
match self.project.get_track_mut(track_id) {
|
match self.project.get_track_mut(track_id) {
|
||||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||||
|
|
@ -1103,17 +1095,17 @@ impl Engine {
|
||||||
match self.project.get_track_mut(track_id) {
|
match self.project.get_track_mut(track_id) {
|
||||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||||
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
|
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
|
||||||
lane.remove_point_at_time(time, tolerance);
|
lane.remove_point_at_time(Beats(time), Beats(tolerance));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||||
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
|
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
|
||||||
lane.remove_point_at_time(time, tolerance);
|
lane.remove_point_at_time(Beats(time), Beats(tolerance));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::audio::track::TrackNode::Group(group)) => {
|
Some(crate::audio::track::TrackNode::Group(group)) => {
|
||||||
if let Some(lane) = group.get_automation_lane_mut(lane_id) {
|
if let Some(lane) = group.get_automation_lane_mut(lane_id) {
|
||||||
lane.remove_point_at_time(time, tolerance);
|
lane.remove_point_at_time(Beats(time), Beats(tolerance));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {}
|
None => {}
|
||||||
|
|
@ -1250,9 +1242,9 @@ impl Engine {
|
||||||
// If MIDI recording is active on this track, capture the event
|
// If MIDI recording is active on this track, capture the event
|
||||||
if let Some(recording) = &mut self.midi_recording_state {
|
if let Some(recording) = &mut self.midi_recording_state {
|
||||||
if recording.track_id == track_id {
|
if recording.track_id == track_id {
|
||||||
let absolute_time = self.playhead as f64 / self.sample_rate as f64;
|
let absolute_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64));
|
||||||
eprintln!("[MIDI_RECORDING] NoteOn captured: note={}, velocity={}, absolute_time={:.3}s, playhead={}, sample_rate={}",
|
eprintln!("[MIDI_RECORDING] NoteOn captured: note={}, velocity={}, absolute_time={:.3} beats, playhead={}, sample_rate={}",
|
||||||
note, velocity, absolute_time, self.playhead, self.sample_rate);
|
note, velocity, absolute_time.0, self.playhead, self.sample_rate);
|
||||||
recording.note_on(note, velocity, absolute_time);
|
recording.note_on(note, velocity, absolute_time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1273,9 +1265,9 @@ impl Engine {
|
||||||
// If MIDI recording is active on this track, capture the event
|
// If MIDI recording is active on this track, capture the event
|
||||||
if let Some(recording) = &mut self.midi_recording_state {
|
if let Some(recording) = &mut self.midi_recording_state {
|
||||||
if recording.track_id == track_id {
|
if recording.track_id == track_id {
|
||||||
let absolute_time = self.playhead as f64 / self.sample_rate as f64;
|
let absolute_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64));
|
||||||
eprintln!("[MIDI_RECORDING] NoteOff captured: note={}, absolute_time={:.3}s, playhead={}, sample_rate={}",
|
eprintln!("[MIDI_RECORDING] NoteOff captured: note={}, absolute_time={:.3} beats, playhead={}, sample_rate={}",
|
||||||
note, absolute_time, self.playhead, self.sample_rate);
|
note, absolute_time.0, self.playhead, self.sample_rate);
|
||||||
recording.note_off(note, absolute_time);
|
recording.note_off(note, absolute_time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1301,16 +1293,17 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::SetTempo(bpm, time_sig) => {
|
Command::SetTempo(bpm, time_sig) => {
|
||||||
|
self.time_sig = time_sig;
|
||||||
self.metronome.update_timing(bpm, time_sig);
|
self.metronome.update_timing(bpm, time_sig);
|
||||||
self.project.set_tempo(bpm, time_sig.0);
|
self.project.set_tempo(bpm, time_sig.0);
|
||||||
self.current_bpm = bpm as f64;
|
self.tempo_map.set_global_bpm(bpm as f64);
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::ApplyBpmChange(bpm, fps, midi_durations) => {
|
Command::SetTempoMap(map) => {
|
||||||
self.current_bpm = bpm;
|
let bpm0 = map.global_bpm() as f32;
|
||||||
self.current_fps = fps;
|
self.metronome.update_timing(bpm0, self.time_sig);
|
||||||
self.project.apply_bpm_change(bpm, fps, &midi_durations);
|
self.project.set_tempo(bpm0, self.time_sig.0);
|
||||||
self.refresh_clip_snapshot();
|
self.tempo_map = map;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Node graph commands
|
// Node graph commands
|
||||||
|
|
@ -1864,9 +1857,17 @@ impl Engine {
|
||||||
let buffer_size = self.buffer_pool.buffer_size();
|
let buffer_size = self.buffer_pool.buffer_size();
|
||||||
if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||||
let current = metatrack.current_subtracks();
|
let current = metatrack.current_subtracks();
|
||||||
|
let has_subtrack_node = metatrack.audio_graph.node_indices().any(|idx| {
|
||||||
|
metatrack.audio_graph.get_graph_node(idx)
|
||||||
|
.map(|n| n.node.node_type() == "SubtrackInputs")
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
// No-op if subtrack list is unchanged (prevents every-frame graph rebuilds)
|
// No-op if subtrack list is unchanged AND the graph is already initialised.
|
||||||
if current == subtracks {
|
// Without the `has_subtrack_node` guard a freshly-created metatrack
|
||||||
|
// (empty graph, no SubtrackInputs) with zero subtracks would never get
|
||||||
|
// its default Volume automation node.
|
||||||
|
if current == subtracks && has_subtrack_node {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2174,15 +2175,20 @@ impl Engine {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
let graph = match self.project.get_track_mut(track_id) {
|
||||||
let graph = &mut track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||||
// Downcast to AutomationInputNode using as_any_mut
|
// Downcast to AutomationInputNode using as_any_mut
|
||||||
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
||||||
let keyframe = AutomationKeyframe {
|
let keyframe = AutomationKeyframe {
|
||||||
time,
|
time: Beats(time),
|
||||||
value,
|
value,
|
||||||
interpolation,
|
interpolation,
|
||||||
ease_out,
|
ease_out,
|
||||||
|
|
@ -2199,13 +2205,18 @@ impl Engine {
|
||||||
Command::AutomationRemoveKeyframe(track_id, node_id, time) => {
|
Command::AutomationRemoveKeyframe(track_id, node_id, time) => {
|
||||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
let graph = match self.project.get_track_mut(track_id) {
|
||||||
let graph = &mut track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||||
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
||||||
auto_node.remove_keyframe_at_time(time, 0.001); // 1ms tolerance
|
auto_node.remove_keyframe_at_time(Beats(time), Beats(0.001)); // 1ms tolerance
|
||||||
} else {
|
} else {
|
||||||
eprintln!("Node {} is not an AutomationInputNode", node_id);
|
eprintln!("Node {} is not an AutomationInputNode", node_id);
|
||||||
}
|
}
|
||||||
|
|
@ -2216,8 +2227,13 @@ impl Engine {
|
||||||
Command::AutomationSetName(track_id, node_id, name) => {
|
Command::AutomationSetName(track_id, node_id, name) => {
|
||||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
let graph = match self.project.get_track_mut(track_id) {
|
||||||
let graph = &mut track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&mut t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||||
|
|
@ -2514,7 +2530,7 @@ impl Engine {
|
||||||
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
|
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
|
||||||
use crate::command::MidiClipData;
|
use crate::command::MidiClipData;
|
||||||
QueryResponse::MidiClipData(Ok(MidiClipData {
|
QueryResponse::MidiClipData(Ok(MidiClipData {
|
||||||
duration: clip.duration,
|
duration: clip.duration.0,
|
||||||
events: clip.events.clone(),
|
events: clip.events.clone(),
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2526,12 +2542,15 @@ impl Engine {
|
||||||
use crate::audio::node_graph::nodes::{AutomationInputNode, InterpolationType};
|
use crate::audio::node_graph::nodes::{AutomationInputNode, InterpolationType};
|
||||||
use crate::command::types::AutomationKeyframeData;
|
use crate::command::types::AutomationKeyframeData;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
|
let graph = match self.project.get_track(track_id) {
|
||||||
let graph = &track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
||||||
// Downcast to AutomationInputNode
|
|
||||||
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||||
let keyframes: Vec<AutomationKeyframeData> = auto_node.keyframes()
|
let keyframes: Vec<AutomationKeyframeData> = auto_node.keyframes()
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -2542,9 +2561,8 @@ impl Engine {
|
||||||
InterpolationType::Step => "step",
|
InterpolationType::Step => "step",
|
||||||
InterpolationType::Hold => "hold",
|
InterpolationType::Hold => "hold",
|
||||||
}.to_string();
|
}.to_string();
|
||||||
|
|
||||||
AutomationKeyframeData {
|
AutomationKeyframeData {
|
||||||
time: kf.time,
|
time: kf.time.0,
|
||||||
value: kf.value,
|
value: kf.value,
|
||||||
interpolation: interpolation_str,
|
interpolation: interpolation_str,
|
||||||
ease_out: kf.ease_out,
|
ease_out: kf.ease_out,
|
||||||
|
|
@ -2552,7 +2570,6 @@ impl Engine {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
QueryResponse::AutomationKeyframes(Ok(keyframes))
|
QueryResponse::AutomationKeyframes(Ok(keyframes))
|
||||||
} else {
|
} else {
|
||||||
QueryResponse::AutomationKeyframes(Err(format!("Node {} is not an AutomationInputNode", node_id)))
|
QueryResponse::AutomationKeyframes(Err(format!("Node {} is not an AutomationInputNode", node_id)))
|
||||||
|
|
@ -2561,19 +2578,22 @@ impl Engine {
|
||||||
QueryResponse::AutomationKeyframes(Err(format!("Node {} not found in track {}", node_id, track_id)))
|
QueryResponse::AutomationKeyframes(Err(format!("Node {} not found in track {}", node_id, track_id)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
QueryResponse::AutomationKeyframes(Err(format!("Track {} not found or is not a MIDI track", track_id)))
|
QueryResponse::AutomationKeyframes(Err(format!("Track {} not found", track_id)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Query::GetAutomationName(track_id, node_id) => {
|
Query::GetAutomationName(track_id, node_id) => {
|
||||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
|
let graph = match self.project.get_track(track_id) {
|
||||||
let graph = &track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
||||||
// Downcast to AutomationInputNode
|
|
||||||
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||||
QueryResponse::AutomationName(Ok(auto_node.display_name().to_string()))
|
QueryResponse::AutomationName(Ok(auto_node.display_name().to_string()))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2583,17 +2603,21 @@ impl Engine {
|
||||||
QueryResponse::AutomationName(Err(format!("Node {} not found in track {}", node_id, track_id)))
|
QueryResponse::AutomationName(Err(format!("Node {} not found in track {}", node_id, track_id)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
QueryResponse::AutomationName(Err(format!("Track {} not found or is not a MIDI track", track_id)))
|
QueryResponse::AutomationName(Err(format!("Track {} not found", track_id)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Query::GetAutomationRange(track_id, node_id) => {
|
Query::GetAutomationRange(track_id, node_id) => {
|
||||||
use crate::audio::node_graph::nodes::AutomationInputNode;
|
use crate::audio::node_graph::nodes::AutomationInputNode;
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) {
|
let graph = match self.project.get_track(track_id) {
|
||||||
let graph = &track.instrument_graph;
|
Some(TrackNode::Midi(t)) => Some(&t.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(t)) => Some(&t.effects_graph),
|
||||||
|
Some(TrackNode::Group(t)) => Some(&t.audio_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node(node_idx) {
|
||||||
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
if let Some(auto_node) = graph_node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||||
QueryResponse::AutomationRange(Ok((auto_node.value_min, auto_node.value_max)))
|
QueryResponse::AutomationRange(Ok((auto_node.value_min, auto_node.value_max)))
|
||||||
|
|
@ -2604,7 +2628,7 @@ impl Engine {
|
||||||
QueryResponse::AutomationRange(Err(format!("Node {} not found", node_id)))
|
QueryResponse::AutomationRange(Err(format!("Node {} not found", node_id)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
QueryResponse::AutomationRange(Err(format!("Track {} not found or is not a MIDI track", track_id)))
|
QueryResponse::AutomationRange(Err(format!("Track {} not found", track_id)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2749,19 +2773,9 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Query::AddMidiClipSync(track_id, clip, start_time) => {
|
Query::AddMidiClipSync(track_id, clip, start_time) => {
|
||||||
// Add MIDI clip to track and return the instance ID
|
// Add MIDI clip to track and return the instance ID (positions already in beats)
|
||||||
let bpm = self.current_bpm;
|
let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) {
|
||||||
let fps = self.current_fps;
|
Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
|
||||||
let result = match self.project.add_midi_clip_at(track_id, clip, start_time) {
|
|
||||||
Ok(instance_id) => {
|
|
||||||
// Sync beats/frames on the newly created instance
|
|
||||||
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
|
||||||
if let Some(inst) = track.clip_instances.iter_mut().find(|i| i.id == instance_id) {
|
|
||||||
inst.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
QueryResponse::MidiClipInstanceAdded(Ok(instance_id))
|
|
||||||
}
|
|
||||||
Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())),
|
Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())),
|
||||||
};
|
};
|
||||||
self.refresh_clip_snapshot();
|
self.refresh_clip_snapshot();
|
||||||
|
|
@ -2769,10 +2783,9 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Query::AddMidiClipInstanceSync(track_id, mut instance) => {
|
Query::AddMidiClipInstanceSync(track_id, mut instance) => {
|
||||||
// Add MIDI clip instance to track (clip must already be in pool)
|
// Add MIDI clip instance to track (clip must already be in pool)
|
||||||
// Assign instance ID
|
// Assign instance ID; positions are already in beats
|
||||||
let instance_id = self.project.next_midi_clip_instance_id();
|
let instance_id = self.project.next_midi_clip_instance_id();
|
||||||
instance.id = instance_id;
|
instance.id = instance_id;
|
||||||
instance.sync_from_seconds(self.current_bpm, self.current_fps);
|
|
||||||
|
|
||||||
let result = match self.project.add_midi_clip_instance(track_id, instance) {
|
let result = match self.project.add_midi_clip_instance(track_id, instance) {
|
||||||
Ok(_) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
|
Ok(_) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
|
||||||
|
|
@ -2940,7 +2953,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle starting a recording
|
/// Handle starting a recording
|
||||||
fn handle_start_recording(&mut self, track_id: TrackId, start_time: f64) {
|
fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats) {
|
||||||
use crate::io::WavWriter;
|
use crate::io::WavWriter;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
|
|
@ -2963,10 +2976,10 @@ impl Engine {
|
||||||
let clip = crate::audio::clip::Clip::new(
|
let clip = crate::audio::clip::Clip::new(
|
||||||
clip_id,
|
clip_id,
|
||||||
0, // Temporary pool index, will be updated on finalization
|
0, // Temporary pool index, will be updated on finalization
|
||||||
0.0, // internal_start
|
Seconds(0.0), // internal_start
|
||||||
0.0, // internal_end - Duration starts at 0, will be updated during recording
|
Seconds(0.0), // internal_end - Duration starts at 0, will be updated during recording
|
||||||
start_time, // external_start (timeline position)
|
start_time, // external_start (timeline position, already Beats)
|
||||||
start_time, // external_end - will be updated during recording
|
Beats(0.0), // external_duration - will be updated during recording
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add clip to track
|
// Add clip to track
|
||||||
|
|
@ -3095,7 +3108,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle starting MIDI recording
|
/// Handle starting MIDI recording
|
||||||
fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) {
|
fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) {
|
||||||
// Check if track exists and is a MIDI track
|
// Check if track exists and is a MIDI track
|
||||||
if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) {
|
||||||
// Create MIDI recording state
|
// Create MIDI recording state
|
||||||
|
|
@ -3105,7 +3118,7 @@ impl Engine {
|
||||||
// so they start at t=0 of the recording rather than being lost
|
// so they start at t=0 of the recording rather than being lost
|
||||||
if let Some(held) = self.midi_held_notes.get(&track_id) {
|
if let Some(held) = self.midi_held_notes.get(&track_id) {
|
||||||
for (¬e, &velocity) in held {
|
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);
|
recording_state.note_on(note, velocity, start_time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3132,8 +3145,8 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close out any active notes at the current playhead position
|
// Close out any active notes at the current playhead position
|
||||||
let end_time = self.playhead as f64 / self.sample_rate as f64;
|
let end_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64));
|
||||||
eprintln!("[MIDI_RECORDING] Closing active notes at time {}", end_time);
|
eprintln!("[MIDI_RECORDING] Closing active notes at time {}", end_time.0);
|
||||||
recording.close_active_notes(end_time);
|
recording.close_active_notes(end_time);
|
||||||
|
|
||||||
let clip_id = recording.clip_id;
|
let clip_id = recording.clip_id;
|
||||||
|
|
@ -3142,8 +3155,8 @@ impl Engine {
|
||||||
let note_count = notes.len();
|
let note_count = notes.len();
|
||||||
let recording_duration = end_time - recording.start_time;
|
let recording_duration = end_time - recording.start_time;
|
||||||
|
|
||||||
eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3}s",
|
eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats",
|
||||||
clip_id, track_id, note_count, recording_duration);
|
clip_id, track_id, note_count, recording_duration.0);
|
||||||
|
|
||||||
// Update the MIDI clip in the pool (new model: clips are stored centrally in the pool)
|
// Update the MIDI clip in the pool (new model: clips are stored centrally in the pool)
|
||||||
eprintln!("[MIDI_RECORDING] Looking for clip {} in midi_clip_pool", clip_id);
|
eprintln!("[MIDI_RECORDING] Looking for clip {} in midi_clip_pool", clip_id);
|
||||||
|
|
@ -3152,16 +3165,17 @@ impl Engine {
|
||||||
// Clear existing events
|
// Clear existing events
|
||||||
clip.events.clear();
|
clip.events.clear();
|
||||||
|
|
||||||
// Update clip duration to match the actual recording time
|
// Recording duration is already in beats (canonical)
|
||||||
clip.duration = recording_duration;
|
let duration_beats = recording_duration;
|
||||||
|
clip.duration = duration_beats;
|
||||||
|
|
||||||
// Add new events from the recorded notes
|
// Add new events from the recorded notes.
|
||||||
// Timestamps are now stored in seconds (sample-rate independent)
|
// Recorded timestamps are already in beats (canonical).
|
||||||
for (start_time, note, velocity, duration) in notes.iter() {
|
for (start_time, note, velocity, duration) in notes.iter() {
|
||||||
let note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity);
|
let note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity);
|
||||||
|
|
||||||
eprintln!("[MIDI_RECORDING] Note {}: start_time={:.3}s, duration={:.3}s",
|
eprintln!("[MIDI_RECORDING] Note {}: start={:.3} beats, duration={:.3} beats",
|
||||||
note, start_time, duration);
|
note, start_time.0, duration.0);
|
||||||
|
|
||||||
clip.events.push(note_on);
|
clip.events.push(note_on);
|
||||||
|
|
||||||
|
|
@ -3171,17 +3185,17 @@ impl Engine {
|
||||||
clip.events.push(note_off);
|
clip.events.push(note_off);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort events by timestamp (using partial_cmp for f64)
|
// Sort events by timestamp (using partial_cmp for Beats)
|
||||||
clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||||
eprintln!("[MIDI_RECORDING] Updated clip {} with {} notes ({} events)", clip_id, note_count, clip.events.len());
|
eprintln!("[MIDI_RECORDING] Updated clip {} with {} notes ({} events)", clip_id, note_count, clip.events.len());
|
||||||
|
|
||||||
// Also update the clip instance's internal_end and external_duration to match the recording duration
|
// Also update the clip instance's internal_end and external_duration to match the recording duration
|
||||||
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||||
if let Some(instance) = track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) {
|
if let Some(instance) = track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) {
|
||||||
instance.internal_end = recording_duration;
|
instance.internal_end = duration_beats;
|
||||||
instance.external_duration = recording_duration;
|
instance.external_duration = duration_beats;
|
||||||
eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3}s, external_duration={:.3}s",
|
eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3} beats, external_duration={:.3} beats",
|
||||||
instance.internal_end, instance.external_duration);
|
instance.internal_end.0, instance.external_duration.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3627,9 +3641,15 @@ impl EngineController {
|
||||||
let _ = self.command_tx.push(Command::AutomationSetName(track_id, node_id, name));
|
let _ = self.command_tx.push(Command::AutomationSetName(track_id, node_id, name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the min/max output range of an AutomationInput node (param ids 0 = min, 1 = max)
|
||||||
|
pub fn automation_set_range(&mut self, track_id: TrackId, node_id: u32, min: f32, max: f32) {
|
||||||
|
self.graph_set_parameter(track_id, node_id, 0, min);
|
||||||
|
self.graph_set_parameter(track_id, node_id, 1, max);
|
||||||
|
}
|
||||||
|
|
||||||
/// Start recording on a track
|
/// Start recording on a track
|
||||||
pub fn start_recording(&mut self, track_id: TrackId, start_time: f64) {
|
pub fn start_recording(&mut self, track_id: TrackId, start_time: f64) {
|
||||||
let _ = self.command_tx.push(Command::StartRecording(track_id, start_time));
|
let _ = self.command_tx.push(Command::StartRecording(track_id, Beats(start_time)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the current recording
|
/// Stop the current recording
|
||||||
|
|
@ -3649,7 +3669,7 @@ impl EngineController {
|
||||||
|
|
||||||
/// Start MIDI recording on a track
|
/// Start MIDI recording on a track
|
||||||
pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) {
|
pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) {
|
||||||
let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time));
|
let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, Beats(start_time)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the current MIDI recording
|
/// Stop the current MIDI recording
|
||||||
|
|
@ -3687,12 +3707,15 @@ impl EngineController {
|
||||||
let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature));
|
let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// After a BPM change: update MIDI clip durations and sync all clip beats/frames.
|
/// Replace the full tempo map (multi-entry variable tempo)
|
||||||
/// Call this after move_clip() has been called for all affected clips.
|
pub fn set_tempo_map(&mut self, map: crate::TempoMap) {
|
||||||
pub fn apply_bpm_change(&mut self, bpm: f64, fps: f64, midi_durations: Vec<(crate::audio::MidiClipId, f64)>) {
|
let _ = self.command_tx.push(Command::SetTempoMap(map));
|
||||||
let _ = self.command_tx.push(Command::ApplyBpmChange(bpm, fps, midi_durations));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale
|
||||||
|
/// automation keyframe times to preserve beat positions.
|
||||||
|
/// `from_bpm` is the BPM before the change; `to_bpm` is the new BPM.
|
||||||
|
/// Call this after move_clip() has been called for all affected clips.
|
||||||
// Node graph operations
|
// Node graph operations
|
||||||
|
|
||||||
/// Add a node to a track's instrument graph
|
/// Add a node to a track's instrument graph
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ use super::buffer_pool::BufferPool;
|
||||||
use super::pool::AudioPool;
|
use super::pool::AudioPool;
|
||||||
use super::project::Project;
|
use super::project::Project;
|
||||||
use crate::command::AudioEvent;
|
use crate::command::AudioEvent;
|
||||||
|
use crate::tempo_map::TempoMap;
|
||||||
|
use crate::time::Seconds;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Render chunk size for offline export. Matches the real-time playback buffer size
|
/// Render chunk size for offline export. Matches the real-time playback buffer size
|
||||||
|
|
@ -42,10 +44,12 @@ pub struct ExportSettings {
|
||||||
pub bit_depth: u16,
|
pub bit_depth: u16,
|
||||||
/// MP3 bitrate in kbps (128, 192, 256, 320)
|
/// MP3 bitrate in kbps (128, 192, 256, 320)
|
||||||
pub mp3_bitrate: u32,
|
pub mp3_bitrate: u32,
|
||||||
/// Start time in seconds
|
/// Start time
|
||||||
pub start_time: f64,
|
pub start_time: Seconds,
|
||||||
/// End time in seconds
|
/// End time
|
||||||
pub end_time: f64,
|
pub end_time: Seconds,
|
||||||
|
/// Tempo map for beat-position scheduling
|
||||||
|
pub tempo_map: TempoMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ExportSettings {
|
impl Default for ExportSettings {
|
||||||
|
|
@ -56,8 +60,9 @@ impl Default for ExportSettings {
|
||||||
channels: 2,
|
channels: 2,
|
||||||
bit_depth: 16,
|
bit_depth: 16,
|
||||||
mp3_bitrate: 320,
|
mp3_bitrate: 320,
|
||||||
start_time: 0.0,
|
start_time: Seconds::ZERO,
|
||||||
end_time: 60.0,
|
end_time: Seconds(60.0),
|
||||||
|
tempo_map: TempoMap::constant(120.0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -79,15 +84,15 @@ pub fn export_audio<P: AsRef<Path>>(
|
||||||
{
|
{
|
||||||
// Validate duration
|
// Validate duration
|
||||||
let duration = settings.end_time - settings.start_time;
|
let duration = settings.end_time - settings.start_time;
|
||||||
if duration <= 0.0 {
|
if duration <= Seconds::ZERO {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Export duration is zero or negative (start={:.3}s, end={:.3}s). \
|
"Export duration is zero or negative (start={:.3}s, end={:.3}s). \
|
||||||
Check that the timeline has content.",
|
Check that the timeline has content.",
|
||||||
settings.start_time, settings.end_time
|
settings.start_time.seconds_to_f64(), settings.end_time.seconds_to_f64()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
|
||||||
if total_frames == 0 {
|
if total_frames == 0 {
|
||||||
return Err("Export would produce zero audio frames".to_string());
|
return Err("Export would produce zero audio frames".to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -148,11 +153,11 @@ pub fn render_to_memory(
|
||||||
{
|
{
|
||||||
// Calculate total number of frames
|
// Calculate total number of frames
|
||||||
let duration = settings.end_time - settings.start_time;
|
let duration = settings.end_time - settings.start_time;
|
||||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
|
||||||
let total_samples = total_frames * settings.channels as usize;
|
let total_samples = total_frames * settings.channels as usize;
|
||||||
|
|
||||||
println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}",
|
println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}",
|
||||||
duration, total_frames, total_samples, settings.channels);
|
duration.seconds_to_f64(), total_frames, total_samples, settings.channels);
|
||||||
|
|
||||||
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
||||||
|
|
||||||
|
|
@ -178,6 +183,7 @@ pub fn render_to_memory(
|
||||||
pool,
|
pool,
|
||||||
&mut buffer_pool,
|
&mut buffer_pool,
|
||||||
playhead,
|
playhead,
|
||||||
|
&settings.tempo_map,
|
||||||
settings.sample_rate,
|
settings.sample_rate,
|
||||||
settings.channels,
|
settings.channels,
|
||||||
false,
|
false,
|
||||||
|
|
@ -185,9 +191,9 @@ pub fn render_to_memory(
|
||||||
|
|
||||||
// Calculate how many samples we actually need from this chunk
|
// Calculate how many samples we actually need from this chunk
|
||||||
let remaining_time = settings.end_time - playhead;
|
let remaining_time = settings.end_time - playhead;
|
||||||
let samples_needed = if remaining_time < chunk_duration {
|
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
|
||||||
// Calculate frames needed and ensure it's a whole number
|
// Calculate frames needed and ensure it's a whole number
|
||||||
let frames_needed = (remaining_time * settings.sample_rate as f64).round() as usize;
|
let frames_needed = (remaining_time.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
|
||||||
let samples = frames_needed * settings.channels as usize;
|
let samples = frames_needed * settings.channels as usize;
|
||||||
// Ensure we don't exceed chunk size
|
// Ensure we don't exceed chunk size
|
||||||
samples.min(chunk_samples)
|
samples.min(chunk_samples)
|
||||||
|
|
@ -207,7 +213,7 @@ pub fn render_to_memory(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
playhead += chunk_duration;
|
playhead = playhead + Seconds(chunk_duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Export: rendered {} samples total", all_samples.len());
|
println!("Export: rendered {} samples total", all_samples.len());
|
||||||
|
|
@ -361,7 +367,7 @@ fn export_mp3<P: AsRef<Path>>(
|
||||||
|
|
||||||
// Calculate rendering parameters
|
// Calculate rendering parameters
|
||||||
let duration = settings.end_time - settings.start_time;
|
let duration = settings.end_time - settings.start_time;
|
||||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
|
||||||
|
|
||||||
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
||||||
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
||||||
|
|
@ -396,6 +402,7 @@ fn export_mp3<P: AsRef<Path>>(
|
||||||
pool,
|
pool,
|
||||||
&mut buffer_pool,
|
&mut buffer_pool,
|
||||||
playhead,
|
playhead,
|
||||||
|
&settings.tempo_map,
|
||||||
settings.sample_rate,
|
settings.sample_rate,
|
||||||
settings.channels,
|
settings.channels,
|
||||||
false,
|
false,
|
||||||
|
|
@ -403,8 +410,8 @@ fn export_mp3<P: AsRef<Path>>(
|
||||||
|
|
||||||
// Calculate how many samples we need from this chunk
|
// Calculate how many samples we need from this chunk
|
||||||
let remaining_time = settings.end_time - playhead;
|
let remaining_time = settings.end_time - playhead;
|
||||||
let samples_needed = if remaining_time < chunk_duration {
|
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
|
||||||
((remaining_time * settings.sample_rate as f64) as usize * settings.channels as usize)
|
((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize)
|
||||||
.min(chunk_samples)
|
.min(chunk_samples)
|
||||||
} else {
|
} else {
|
||||||
chunk_samples
|
chunk_samples
|
||||||
|
|
@ -445,7 +452,7 @@ fn export_mp3<P: AsRef<Path>>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playhead += chunk_duration;
|
playhead = playhead + Seconds(chunk_duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode any remaining samples as the final frame
|
// Encode any remaining samples as the final frame
|
||||||
|
|
@ -529,7 +536,7 @@ fn export_aac<P: AsRef<Path>>(
|
||||||
|
|
||||||
// Calculate rendering parameters
|
// Calculate rendering parameters
|
||||||
let duration = settings.end_time - settings.start_time;
|
let duration = settings.end_time - settings.start_time;
|
||||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize;
|
||||||
|
|
||||||
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
||||||
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
||||||
|
|
@ -564,6 +571,7 @@ fn export_aac<P: AsRef<Path>>(
|
||||||
pool,
|
pool,
|
||||||
&mut buffer_pool,
|
&mut buffer_pool,
|
||||||
playhead,
|
playhead,
|
||||||
|
&settings.tempo_map,
|
||||||
settings.sample_rate,
|
settings.sample_rate,
|
||||||
settings.channels,
|
settings.channels,
|
||||||
false,
|
false,
|
||||||
|
|
@ -571,8 +579,8 @@ fn export_aac<P: AsRef<Path>>(
|
||||||
|
|
||||||
// Calculate how many samples we need from this chunk
|
// Calculate how many samples we need from this chunk
|
||||||
let remaining_time = settings.end_time - playhead;
|
let remaining_time = settings.end_time - playhead;
|
||||||
let samples_needed = if remaining_time < chunk_duration {
|
let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration {
|
||||||
((remaining_time * settings.sample_rate as f64) as usize * settings.channels as usize)
|
((remaining_time.seconds_to_f64() * settings.sample_rate as f64) as usize * settings.channels as usize)
|
||||||
.min(chunk_samples)
|
.min(chunk_samples)
|
||||||
} else {
|
} else {
|
||||||
chunk_samples
|
chunk_samples
|
||||||
|
|
@ -613,7 +621,7 @@ fn export_aac<P: AsRef<Path>>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playhead += chunk_duration;
|
playhead = playhead + Seconds(chunk_duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode any remaining samples as the final frame
|
// Encode any remaining samples as the final frame
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
|
use crate::time::Beats;
|
||||||
|
|
||||||
/// MIDI event representing a single MIDI message
|
/// MIDI event representing a single MIDI message
|
||||||
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct MidiEvent {
|
pub struct MidiEvent {
|
||||||
/// Time position within the clip in seconds (sample-rate independent)
|
/// Time position in beats (quarter-note beats)
|
||||||
pub timestamp: f64,
|
pub timestamp: Beats,
|
||||||
/// Time position in beats (quarter-note beats from clip start); derived from timestamp
|
|
||||||
#[serde(default)]
|
|
||||||
pub timestamp_beats: f64,
|
|
||||||
/// Time position in frames; derived from timestamp
|
|
||||||
#[serde(default)]
|
|
||||||
pub timestamp_frames: f64,
|
|
||||||
/// MIDI status byte (includes channel)
|
/// MIDI status byte (includes channel)
|
||||||
pub status: u8,
|
pub status: u8,
|
||||||
/// First data byte (note number, CC number, etc.)
|
/// First data byte (note number, CC number, etc.)
|
||||||
|
|
@ -18,79 +14,28 @@ pub struct MidiEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MidiEvent {
|
impl MidiEvent {
|
||||||
/// Create a new MIDI event
|
pub fn new(timestamp: Beats, status: u8, data1: u8, data2: u8) -> Self {
|
||||||
pub fn new(timestamp: f64, status: u8, data1: u8, data2: u8) -> Self {
|
Self { timestamp, status, data1, data2 }
|
||||||
Self {
|
|
||||||
timestamp,
|
|
||||||
timestamp_beats: 0.0,
|
|
||||||
timestamp_frames: 0.0,
|
|
||||||
status,
|
|
||||||
data1,
|
|
||||||
data2,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a note on event
|
pub fn note_on(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self {
|
||||||
pub fn note_on(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self {
|
Self { timestamp, status: 0x90 | (channel & 0x0F), data1: note, data2: velocity }
|
||||||
Self {
|
|
||||||
timestamp,
|
|
||||||
timestamp_beats: 0.0,
|
|
||||||
timestamp_frames: 0.0,
|
|
||||||
status: 0x90 | (channel & 0x0F),
|
|
||||||
data1: note,
|
|
||||||
data2: velocity,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a note off event
|
pub fn note_off(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self {
|
||||||
pub fn note_off(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self {
|
Self { timestamp, status: 0x80 | (channel & 0x0F), data1: note, data2: velocity }
|
||||||
Self {
|
|
||||||
timestamp,
|
|
||||||
timestamp_beats: 0.0,
|
|
||||||
timestamp_frames: 0.0,
|
|
||||||
status: 0x80 | (channel & 0x0F),
|
|
||||||
data1: note,
|
|
||||||
data2: velocity,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync beats and frames from seconds (call after constructing or when seconds is canonical)
|
|
||||||
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
|
|
||||||
self.timestamp_beats = self.timestamp * bpm / 60.0;
|
|
||||||
self.timestamp_frames = self.timestamp * fps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recompute seconds and frames from beats (call when BPM changes in Measures mode)
|
|
||||||
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
|
|
||||||
self.timestamp = self.timestamp_beats * 60.0 / bpm;
|
|
||||||
self.timestamp_frames = self.timestamp * fps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recompute seconds and beats from frames (call when FPS changes in Frames mode)
|
|
||||||
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
|
|
||||||
self.timestamp = self.timestamp_frames / fps;
|
|
||||||
self.timestamp_beats = self.timestamp * bpm / 60.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a note on event (with non-zero velocity)
|
|
||||||
pub fn is_note_on(&self) -> bool {
|
pub fn is_note_on(&self) -> bool {
|
||||||
(self.status & 0xF0) == 0x90 && self.data2 > 0
|
(self.status & 0xF0) == 0x90 && self.data2 > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this is a note off event (or note on with zero velocity)
|
|
||||||
pub fn is_note_off(&self) -> bool {
|
pub fn is_note_off(&self) -> bool {
|
||||||
(self.status & 0xF0) == 0x80 || ((self.status & 0xF0) == 0x90 && self.data2 == 0)
|
(self.status & 0xF0) == 0x80 || ((self.status & 0xF0) == 0x90 && self.data2 == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the MIDI channel (0-15)
|
pub fn channel(&self) -> u8 { self.status & 0x0F }
|
||||||
pub fn channel(&self) -> u8 {
|
pub fn message_type(&self) -> u8 { self.status & 0xF0 }
|
||||||
self.status & 0x0F
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the message type (upper 4 bits of status)
|
|
||||||
pub fn message_type(&self) -> u8 {
|
|
||||||
self.status & 0xF0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MIDI clip ID type (for clips stored in the pool)
|
/// MIDI clip ID type (for clips stored in the pool)
|
||||||
|
|
@ -99,240 +44,118 @@ pub type MidiClipId = u32;
|
||||||
/// MIDI clip instance ID type (for instances placed on tracks)
|
/// MIDI clip instance ID type (for instances placed on tracks)
|
||||||
pub type MidiClipInstanceId = u32;
|
pub type MidiClipInstanceId = u32;
|
||||||
|
|
||||||
/// MIDI clip content - stores the actual MIDI events
|
/// MIDI clip content — stores the actual MIDI events.
|
||||||
///
|
/// `duration` is in beats.
|
||||||
/// This represents the content data stored in the MidiClipPool.
|
|
||||||
/// Events have timestamps relative to the start of the clip (0.0 = clip beginning).
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct MidiClip {
|
pub struct MidiClip {
|
||||||
pub id: MidiClipId,
|
pub id: MidiClipId,
|
||||||
pub events: Vec<MidiEvent>,
|
pub events: Vec<MidiEvent>,
|
||||||
pub duration: f64, // Total content duration in seconds
|
/// Total content duration in beats
|
||||||
|
pub duration: Beats,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MidiClip {
|
impl MidiClip {
|
||||||
/// Create a new MIDI clip with content
|
pub fn new(id: MidiClipId, events: Vec<MidiEvent>, duration: Beats, name: String) -> Self {
|
||||||
pub fn new(id: MidiClipId, events: Vec<MidiEvent>, duration: f64, name: String) -> Self {
|
let mut clip = Self { id, events, duration, name };
|
||||||
let mut clip = Self {
|
|
||||||
id,
|
|
||||||
events,
|
|
||||||
duration,
|
|
||||||
name,
|
|
||||||
};
|
|
||||||
// Sort events by timestamp
|
|
||||||
clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||||
clip
|
clip
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an empty MIDI clip
|
pub fn empty(id: MidiClipId, duration: Beats, name: String) -> Self {
|
||||||
pub fn empty(id: MidiClipId, duration: f64, name: String) -> Self {
|
Self { id, events: Vec::new(), duration, name }
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
events: Vec::new(),
|
|
||||||
duration,
|
|
||||||
name,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a MIDI event to the clip
|
|
||||||
pub fn add_event(&mut self, event: MidiEvent) {
|
pub fn add_event(&mut self, event: MidiEvent) {
|
||||||
self.events.push(event);
|
self.events.push(event);
|
||||||
// Keep events sorted by timestamp
|
|
||||||
self.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
self.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get events within a time range (relative to clip start)
|
/// Get events within a beat range (relative to clip start)
|
||||||
/// This is used by MidiClipInstance to fetch events for a given portion
|
pub fn get_events_in_range(&self, start: Beats, end: Beats) -> Vec<MidiEvent> {
|
||||||
pub fn get_events_in_range(&self, start: f64, end: f64) -> Vec<MidiEvent> {
|
self.events.iter()
|
||||||
self.events
|
|
||||||
.iter()
|
|
||||||
.filter(|e| e.timestamp >= start && e.timestamp < end)
|
.filter(|e| e.timestamp >= start && e.timestamp < end)
|
||||||
.copied()
|
.copied()
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MIDI clip instance - a reference to MidiClip content with timeline positioning
|
/// MIDI clip instance — a reference to MidiClip content with timeline positioning.
|
||||||
///
|
///
|
||||||
/// ## Timing Model
|
/// All timing fields are in beats.
|
||||||
/// - `internal_start` / `internal_end`: Define the region of the source clip to play (trimming)
|
|
||||||
/// - `external_start` / `external_duration`: Define where the instance appears on the timeline and how long
|
|
||||||
/// - `*_beats` / `*_frames`: Derived representations for Measures/Frames mode display
|
|
||||||
///
|
|
||||||
/// ## Looping
|
|
||||||
/// If `external_duration` is greater than `internal_end - internal_start`,
|
|
||||||
/// the instance will seamlessly loop back to `internal_start` when it reaches `internal_end`.
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct MidiClipInstance {
|
pub struct MidiClipInstance {
|
||||||
pub id: MidiClipInstanceId,
|
pub id: MidiClipInstanceId,
|
||||||
pub clip_id: MidiClipId, // Reference to MidiClip in pool
|
pub clip_id: MidiClipId,
|
||||||
|
|
||||||
/// Start position within the clip content (seconds)
|
/// Start of the trimmed region within the clip content (beats)
|
||||||
pub internal_start: f64,
|
pub internal_start: Beats,
|
||||||
#[serde(default)] pub internal_start_beats: f64,
|
/// End of the trimmed region within the clip content (beats)
|
||||||
#[serde(default)] pub internal_start_frames: f64,
|
pub internal_end: Beats,
|
||||||
/// End position within the clip content (seconds)
|
|
||||||
pub internal_end: f64,
|
|
||||||
#[serde(default)] pub internal_end_beats: f64,
|
|
||||||
#[serde(default)] pub internal_end_frames: f64,
|
|
||||||
|
|
||||||
/// Start position on the timeline (seconds)
|
/// Start position on the timeline (beats)
|
||||||
pub external_start: f64,
|
pub external_start: Beats,
|
||||||
#[serde(default)] pub external_start_beats: f64,
|
/// Duration on the timeline (beats); > internal duration = looping
|
||||||
#[serde(default)] pub external_start_frames: f64,
|
pub external_duration: Beats,
|
||||||
/// Duration on the timeline (seconds) - can be longer than internal duration for looping
|
|
||||||
pub external_duration: f64,
|
|
||||||
#[serde(default)] pub external_duration_beats: f64,
|
|
||||||
#[serde(default)] pub external_duration_frames: f64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MidiClipInstance {
|
impl MidiClipInstance {
|
||||||
/// Create a new MIDI clip instance
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: MidiClipInstanceId,
|
id: MidiClipInstanceId,
|
||||||
clip_id: MidiClipId,
|
clip_id: MidiClipId,
|
||||||
internal_start: f64,
|
internal_start: Beats,
|
||||||
internal_end: f64,
|
internal_end: Beats,
|
||||||
external_start: f64,
|
external_start: Beats,
|
||||||
external_duration: f64,
|
external_duration: Beats,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self { id, clip_id, internal_start, internal_end, external_start, external_duration }
|
||||||
id,
|
|
||||||
clip_id,
|
|
||||||
internal_start,
|
|
||||||
internal_start_beats: 0.0,
|
|
||||||
internal_start_frames: 0.0,
|
|
||||||
internal_end,
|
|
||||||
internal_end_beats: 0.0,
|
|
||||||
internal_end_frames: 0.0,
|
|
||||||
external_start,
|
|
||||||
external_start_beats: 0.0,
|
|
||||||
external_start_frames: 0.0,
|
|
||||||
external_duration,
|
|
||||||
external_duration_beats: 0.0,
|
|
||||||
external_duration_frames: 0.0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an instance that uses the full clip content (no trimming, no looping)
|
/// Create an instance covering the full clip with no trim
|
||||||
pub fn from_full_clip(
|
pub fn from_full_clip(
|
||||||
id: MidiClipInstanceId,
|
id: MidiClipInstanceId,
|
||||||
clip_id: MidiClipId,
|
clip_id: MidiClipId,
|
||||||
clip_duration: f64,
|
clip_duration: Beats,
|
||||||
external_start: f64,
|
external_start: Beats,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
clip_id,
|
clip_id,
|
||||||
internal_start: 0.0,
|
internal_start: Beats::ZERO,
|
||||||
internal_start_beats: 0.0,
|
|
||||||
internal_start_frames: 0.0,
|
|
||||||
internal_end: clip_duration,
|
internal_end: clip_duration,
|
||||||
internal_end_beats: 0.0,
|
|
||||||
internal_end_frames: 0.0,
|
|
||||||
external_start,
|
external_start,
|
||||||
external_start_beats: 0.0,
|
|
||||||
external_start_frames: 0.0,
|
|
||||||
external_duration: clip_duration,
|
external_duration: clip_duration,
|
||||||
external_duration_beats: 0.0,
|
|
||||||
external_duration_frames: 0.0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the internal (content) duration
|
pub fn internal_duration(&self) -> Beats { self.internal_end - self.internal_start }
|
||||||
pub fn internal_duration(&self) -> f64 {
|
pub fn external_end(&self) -> Beats { self.external_start + self.external_duration }
|
||||||
self.internal_end - self.internal_start
|
pub fn is_looping(&self) -> bool { self.external_duration > self.internal_duration() }
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the end time on the timeline
|
/// Check if this instance overlaps with a beat range
|
||||||
pub fn external_end(&self) -> f64 {
|
pub fn overlaps_range(&self, range_start: Beats, range_end: Beats) -> bool {
|
||||||
self.external_start + self.external_duration
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this instance loops
|
|
||||||
pub fn is_looping(&self) -> bool {
|
|
||||||
self.external_duration > self.internal_duration()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the end time on the timeline (for backwards compatibility)
|
|
||||||
pub fn end_time(&self) -> f64 {
|
|
||||||
self.external_end()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the start time on the timeline (for backwards compatibility)
|
|
||||||
pub fn start_time(&self) -> f64 {
|
|
||||||
self.external_start
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this instance overlaps with a time range
|
|
||||||
pub fn overlaps_range(&self, range_start: f64, range_end: f64) -> bool {
|
|
||||||
self.external_start < range_end && self.external_end() > range_start
|
self.external_start < range_end && self.external_end() > range_start
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Populate beats/frames from the current seconds values.
|
/// Get events that should fire in a given beat range on the timeline.
|
||||||
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
|
/// Returns events with `timestamp` set to their global timeline beat position.
|
||||||
self.external_start_beats = self.external_start * bpm / 60.0;
|
|
||||||
self.external_start_frames = self.external_start * fps;
|
|
||||||
self.external_duration_beats = self.external_duration * bpm / 60.0;
|
|
||||||
self.external_duration_frames = self.external_duration * fps;
|
|
||||||
self.internal_start_beats = self.internal_start * bpm / 60.0;
|
|
||||||
self.internal_start_frames = self.internal_start * fps;
|
|
||||||
self.internal_end_beats = self.internal_end * bpm / 60.0;
|
|
||||||
self.internal_end_frames = self.internal_end * fps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BPM changed; recompute seconds/frames from the stored beats values.
|
|
||||||
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
|
|
||||||
self.external_start = self.external_start_beats * 60.0 / bpm;
|
|
||||||
self.external_start_frames = self.external_start * fps;
|
|
||||||
self.external_duration = self.external_duration_beats * 60.0 / bpm;
|
|
||||||
self.external_duration_frames = self.external_duration * fps;
|
|
||||||
self.internal_start = self.internal_start_beats * 60.0 / bpm;
|
|
||||||
self.internal_start_frames = self.internal_start * fps;
|
|
||||||
self.internal_end = self.internal_end_beats * 60.0 / bpm;
|
|
||||||
self.internal_end_frames = self.internal_end * fps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FPS changed; recompute seconds/beats from the stored frames values.
|
|
||||||
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
|
|
||||||
self.external_start = self.external_start_frames / fps;
|
|
||||||
self.external_start_beats = self.external_start * bpm / 60.0;
|
|
||||||
self.external_duration = self.external_duration_frames / fps;
|
|
||||||
self.external_duration_beats = self.external_duration * bpm / 60.0;
|
|
||||||
self.internal_start = self.internal_start_frames / fps;
|
|
||||||
self.internal_start_beats = self.internal_start * bpm / 60.0;
|
|
||||||
self.internal_end = self.internal_end_frames / fps;
|
|
||||||
self.internal_end_beats = self.internal_end * bpm / 60.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get events that should be triggered in a given timeline range
|
|
||||||
///
|
|
||||||
/// This handles:
|
|
||||||
/// - Trimming (internal_start/internal_end)
|
|
||||||
/// - Looping (when external duration > internal duration)
|
|
||||||
/// - Time mapping from timeline to clip content
|
|
||||||
///
|
|
||||||
/// Returns events with timestamps adjusted to timeline time (not clip-relative)
|
|
||||||
pub fn get_events_in_range(
|
pub fn get_events_in_range(
|
||||||
&self,
|
&self,
|
||||||
clip: &MidiClip,
|
clip: &MidiClip,
|
||||||
range_start_seconds: f64,
|
range_start: Beats,
|
||||||
range_end_seconds: f64,
|
range_end: Beats,
|
||||||
) -> Vec<MidiEvent> {
|
) -> Vec<MidiEvent> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|
||||||
// Check if instance overlaps with the range
|
if !self.overlaps_range(range_start, range_end) {
|
||||||
if !self.overlaps_range(range_start_seconds, range_end_seconds) {
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
let internal_duration = self.internal_duration();
|
let internal_duration = self.internal_duration();
|
||||||
if internal_duration <= 0.0 {
|
if internal_duration <= Beats::ZERO {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate how many complete loops fit in the external duration
|
|
||||||
let num_loops = if self.external_duration > internal_duration {
|
let num_loops = if self.external_duration > internal_duration {
|
||||||
(self.external_duration / internal_duration).ceil() as usize
|
(self.external_duration / internal_duration).ceil() as usize
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -342,24 +165,18 @@ impl MidiClipInstance {
|
||||||
let external_end = self.external_end();
|
let external_end = self.external_end();
|
||||||
|
|
||||||
for loop_idx in 0..num_loops {
|
for loop_idx in 0..num_loops {
|
||||||
let loop_offset = loop_idx as f64 * internal_duration;
|
let loop_offset = internal_duration * loop_idx as f64;
|
||||||
|
|
||||||
// Get events from the clip that fall within the internal range
|
|
||||||
for event in &clip.events {
|
for event in &clip.events {
|
||||||
// Skip events outside the trimmed region
|
|
||||||
// Use > (not >=) for internal_end so note-offs at the clip boundary are included
|
|
||||||
if event.timestamp < self.internal_start || event.timestamp > self.internal_end {
|
if event.timestamp < self.internal_start || event.timestamp > self.internal_end {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to timeline time
|
|
||||||
let relative_content_time = event.timestamp - self.internal_start;
|
let relative_content_time = event.timestamp - self.internal_start;
|
||||||
let timeline_time = self.external_start + loop_offset + relative_content_time;
|
let timeline_time = self.external_start + loop_offset + relative_content_time;
|
||||||
|
|
||||||
// Check if within current buffer range and instance bounds
|
if timeline_time >= range_start
|
||||||
// Use <= for external_end so note-offs at the clip boundary are included
|
&& timeline_time < range_end
|
||||||
if timeline_time >= range_start_seconds
|
|
||||||
&& timeline_time < range_end_seconds
|
|
||||||
&& timeline_time <= external_end
|
&& timeline_time <= external_end
|
||||||
{
|
{
|
||||||
let mut adjusted_event = *event;
|
let mut adjusted_event = *event;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use super::midi::{MidiClip, MidiClipId, MidiEvent};
|
use super::midi::{MidiClip, MidiClipId, MidiEvent};
|
||||||
|
use crate::time::Beats;
|
||||||
|
|
||||||
/// Pool for storing MIDI clip content
|
/// Pool for storing MIDI clip content
|
||||||
/// Similar to AudioClipPool but for MIDI data
|
/// Similar to AudioClipPool but for MIDI data
|
||||||
|
|
@ -21,7 +22,7 @@ impl MidiClipPool {
|
||||||
|
|
||||||
/// Add a new clip to the pool with the given events and duration
|
/// Add a new clip to the pool with the given events and duration
|
||||||
/// Returns the ID of the newly created clip
|
/// Returns the ID of the newly created clip
|
||||||
pub fn add_clip(&mut self, events: Vec<MidiEvent>, duration: f64, name: String) -> MidiClipId {
|
pub fn add_clip(&mut self, events: Vec<MidiEvent>, duration: Beats, name: String) -> MidiClipId {
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use super::node_trait::AudioNode;
|
use super::node_trait::AudioNode;
|
||||||
use super::types::{ConnectionError, SignalType};
|
use super::types::{ConnectionError, SignalType};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
|
use crate::time::Beats;
|
||||||
use petgraph::algo::has_path_connecting;
|
use petgraph::algo::has_path_connecting;
|
||||||
use petgraph::stable_graph::{NodeIndex, StableGraph};
|
use petgraph::stable_graph::{NodeIndex, StableGraph};
|
||||||
use petgraph::visit::{EdgeRef, IntoEdgeReferences};
|
use petgraph::visit::{EdgeRef, IntoEdgeReferences};
|
||||||
|
|
@ -93,8 +94,8 @@ pub struct AudioGraph {
|
||||||
/// UI positions for nodes (node_index -> (x, y))
|
/// UI positions for nodes (node_index -> (x, y))
|
||||||
node_positions: std::collections::HashMap<u32, (f32, f32)>,
|
node_positions: std::collections::HashMap<u32, (f32, f32)>,
|
||||||
|
|
||||||
/// Current playback time (for automation nodes)
|
/// Current playback time in beats (for automation nodes)
|
||||||
playback_time: f64,
|
playback_time: Beats,
|
||||||
|
|
||||||
/// Project tempo (synced from Engine via SetTempo)
|
/// Project tempo (synced from Engine via SetTempo)
|
||||||
bpm: f32,
|
bpm: f32,
|
||||||
|
|
@ -123,7 +124,7 @@ impl AudioGraph {
|
||||||
// Pre-allocate MIDI input buffers (max 128 events per port)
|
// Pre-allocate MIDI input buffers (max 128 events per port)
|
||||||
midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(),
|
midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(),
|
||||||
node_positions: std::collections::HashMap::new(),
|
node_positions: std::collections::HashMap::new(),
|
||||||
playback_time: 0.0,
|
playback_time: Beats::ZERO,
|
||||||
bpm: 120.0,
|
bpm: 120.0,
|
||||||
beats_per_bar: 4,
|
beats_per_bar: 4,
|
||||||
topo_cache: None,
|
topo_cache: None,
|
||||||
|
|
@ -475,7 +476,7 @@ impl AudioGraph {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process the graph and produce audio output
|
/// Process the graph and produce audio output
|
||||||
pub fn process(&mut self, output_buffer: &mut [f32], midi_events: &[MidiEvent], playback_time: f64) {
|
pub fn process(&mut self, output_buffer: &mut [f32], midi_events: &[MidiEvent], playback_time: Beats) {
|
||||||
// Update playback time
|
// Update playback time
|
||||||
self.playback_time = playback_time;
|
self.playback_time = playback_time;
|
||||||
|
|
||||||
|
|
@ -484,6 +485,7 @@ impl AudioGraph {
|
||||||
for node in self.graph.node_weights_mut() {
|
for node in self.graph.node_weights_mut() {
|
||||||
if let Some(auto_node) = node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
if let Some(auto_node) = node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
||||||
auto_node.set_playback_time(playback_time);
|
auto_node.set_playback_time(playback_time);
|
||||||
|
auto_node.set_bpm(self.bpm as f64);
|
||||||
} else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() {
|
} else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() {
|
||||||
beat_node.set_playback_time(playback_time);
|
beat_node.set_playback_time(playback_time);
|
||||||
beat_node.set_tempo(self.bpm, self.beats_per_bar);
|
beat_node.set_tempo(self.bpm, self.beats_per_bar);
|
||||||
|
|
@ -1017,7 +1019,7 @@ impl AudioGraph {
|
||||||
serialized.automation_display_name = Some(auto_node.display_name().to_string());
|
serialized.automation_display_name = Some(auto_node.display_name().to_string());
|
||||||
serialized.automation_keyframes = auto_node.keyframes().iter().map(|kf| {
|
serialized.automation_keyframes = auto_node.keyframes().iter().map(|kf| {
|
||||||
SerializedKeyframe {
|
SerializedKeyframe {
|
||||||
time: kf.time,
|
time: kf.time.0,
|
||||||
value: kf.value,
|
value: kf.value,
|
||||||
interpolation: match kf.interpolation {
|
interpolation: match kf.interpolation {
|
||||||
InterpolationType::Linear => "linear",
|
InterpolationType::Linear => "linear",
|
||||||
|
|
@ -1357,7 +1359,7 @@ impl AudioGraph {
|
||||||
auto_node.clear_keyframes();
|
auto_node.clear_keyframes();
|
||||||
for kf in &serialized_node.automation_keyframes {
|
for kf in &serialized_node.automation_keyframes {
|
||||||
auto_node.add_keyframe(AutomationKeyframe {
|
auto_node.add_keyframe(AutomationKeyframe {
|
||||||
time: kf.time,
|
time: crate::time::Beats(kf.time),
|
||||||
value: kf.value,
|
value: kf.value,
|
||||||
interpolation: match kf.interpolation.as_str() {
|
interpolation: match kf.interpolation.as_str() {
|
||||||
"bezier" => InterpolationType::Bezier,
|
"bezier" => InterpolationType::Bezier,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
|
use crate::time::Beats;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
|
@ -16,8 +17,7 @@ pub enum InterpolationType {
|
||||||
/// A single keyframe in an automation curve
|
/// A single keyframe in an automation curve
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AutomationKeyframe {
|
pub struct AutomationKeyframe {
|
||||||
/// Time in seconds (absolute project time)
|
pub time: Beats,
|
||||||
pub time: f64,
|
|
||||||
/// CV output value
|
/// CV output value
|
||||||
pub value: f32,
|
pub value: f32,
|
||||||
/// Interpolation type to next keyframe
|
/// Interpolation type to next keyframe
|
||||||
|
|
@ -29,7 +29,7 @@ pub struct AutomationKeyframe {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutomationKeyframe {
|
impl AutomationKeyframe {
|
||||||
pub fn new(time: f64, value: f32) -> Self {
|
pub fn new(time: Beats, value: f32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
time,
|
time,
|
||||||
value,
|
value,
|
||||||
|
|
@ -47,8 +47,10 @@ pub struct AutomationInputNode {
|
||||||
keyframes: Vec<AutomationKeyframe>,
|
keyframes: Vec<AutomationKeyframe>,
|
||||||
outputs: Vec<NodePort>,
|
outputs: Vec<NodePort>,
|
||||||
parameters: Vec<Parameter>,
|
parameters: Vec<Parameter>,
|
||||||
/// Shared playback time (set by the graph before processing)
|
/// Shared playback time in beats (set by the graph before processing)
|
||||||
playback_time: Arc<RwLock<f64>>,
|
playback_time: Arc<RwLock<Beats>>,
|
||||||
|
/// Current BPM (set by the graph before processing, used for per-sample beat advancement)
|
||||||
|
bpm: f64,
|
||||||
/// Minimum output value (for UI display range)
|
/// Minimum output value (for UI display range)
|
||||||
pub value_min: f32,
|
pub value_min: f32,
|
||||||
/// Maximum output value (for UI display range)
|
/// Maximum output value (for UI display range)
|
||||||
|
|
@ -71,22 +73,26 @@ impl AutomationInputNode {
|
||||||
Self {
|
Self {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
display_name: "Automation".to_string(),
|
display_name: "Automation".to_string(),
|
||||||
keyframes: vec![AutomationKeyframe::new(0.0, 0.0)],
|
keyframes: vec![AutomationKeyframe::new(Beats::ZERO, 0.0)],
|
||||||
outputs,
|
outputs,
|
||||||
parameters,
|
parameters,
|
||||||
playback_time: Arc::new(RwLock::new(0.0)),
|
playback_time: Arc::new(RwLock::new(Beats::ZERO)),
|
||||||
|
bpm: 120.0,
|
||||||
value_min: -1.0,
|
value_min: -1.0,
|
||||||
value_max: 1.0,
|
value_max: 1.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the playback time (called by graph before processing)
|
pub fn set_playback_time(&mut self, time: Beats) {
|
||||||
pub fn set_playback_time(&mut self, time: f64) {
|
|
||||||
if let Ok(mut playback) = self.playback_time.write() {
|
if let Ok(mut playback) = self.playback_time.write() {
|
||||||
*playback = time;
|
*playback = time;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_bpm(&mut self, bpm: f64) {
|
||||||
|
self.bpm = bpm;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the display name (shown in UI)
|
/// Get the display name (shown in UI)
|
||||||
pub fn display_name(&self) -> &str {
|
pub fn display_name(&self) -> &str {
|
||||||
&self.display_name
|
&self.display_name
|
||||||
|
|
@ -116,8 +122,7 @@ impl AutomationInputNode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove keyframe at specific time (with tolerance)
|
pub fn remove_keyframe_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
|
||||||
pub fn remove_keyframe_at_time(&mut self, time: f64, tolerance: f64) -> bool {
|
|
||||||
if let Some(idx) = self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) {
|
if let Some(idx) = self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) {
|
||||||
self.keyframes.remove(idx);
|
self.keyframes.remove(idx);
|
||||||
true
|
true
|
||||||
|
|
@ -126,10 +131,8 @@ impl AutomationInputNode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update an existing keyframe
|
|
||||||
pub fn update_keyframe(&mut self, keyframe: AutomationKeyframe) {
|
pub fn update_keyframe(&mut self, keyframe: AutomationKeyframe) {
|
||||||
// Remove old keyframe at this time, then add new one
|
self.remove_keyframe_at_time(keyframe.time, Beats(0.001));
|
||||||
self.remove_keyframe_at_time(keyframe.time, 0.001);
|
|
||||||
self.add_keyframe(keyframe);
|
self.add_keyframe(keyframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,24 +146,20 @@ impl AutomationInputNode {
|
||||||
self.keyframes.clear();
|
self.keyframes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate curve at a specific time
|
fn evaluate_at_time(&self, time: Beats) -> f32 {
|
||||||
fn evaluate_at_time(&self, time: f64) -> f32 {
|
|
||||||
if self.keyframes.is_empty() {
|
if self.keyframes.is_empty() {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before first keyframe
|
|
||||||
if time <= self.keyframes[0].time {
|
if time <= self.keyframes[0].time {
|
||||||
return self.keyframes[0].value;
|
return self.keyframes[0].value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// After last keyframe
|
|
||||||
let last_idx = self.keyframes.len() - 1;
|
let last_idx = self.keyframes.len() - 1;
|
||||||
if time >= self.keyframes[last_idx].time {
|
if time >= self.keyframes[last_idx].time {
|
||||||
return self.keyframes[last_idx].value;
|
return self.keyframes[last_idx].value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find bracketing keyframes
|
|
||||||
for i in 0..self.keyframes.len() - 1 {
|
for i in 0..self.keyframes.len() - 1 {
|
||||||
let kf1 = &self.keyframes[i];
|
let kf1 = &self.keyframes[i];
|
||||||
let kf2 = &self.keyframes[i + 1];
|
let kf2 = &self.keyframes[i + 1];
|
||||||
|
|
@ -173,14 +172,12 @@ impl AutomationInputNode {
|
||||||
0.0
|
0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interpolate between two keyframes
|
fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: Beats) -> f32 {
|
||||||
fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: f64) -> f32 {
|
|
||||||
// Calculate normalized position between keyframes (0.0 to 1.0)
|
|
||||||
let t = if kf2.time == kf1.time {
|
let t = if kf2.time == kf1.time {
|
||||||
0.0
|
0.0f64
|
||||||
} else {
|
} else {
|
||||||
((time - kf1.time) / (kf2.time - kf1.time)) as f32
|
(time - kf1.time) / (kf2.time - kf1.time)
|
||||||
};
|
} as f32;
|
||||||
|
|
||||||
match kf1.interpolation {
|
match kf1.interpolation {
|
||||||
InterpolationType::Linear => {
|
InterpolationType::Linear => {
|
||||||
|
|
@ -257,19 +254,17 @@ impl AudioNode for AutomationInputNode {
|
||||||
let output = &mut outputs[0];
|
let output = &mut outputs[0];
|
||||||
let length = output.len();
|
let length = output.len();
|
||||||
|
|
||||||
// Get the starting playback time
|
|
||||||
let playhead = if let Ok(playback) = self.playback_time.read() {
|
let playhead = if let Ok(playback) = self.playback_time.read() {
|
||||||
*playback
|
*playback
|
||||||
} else {
|
} else {
|
||||||
0.0
|
Beats::ZERO
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate time per sample
|
// Advance per sample in beats: beats_per_sample = bpm / 60 / sample_rate
|
||||||
let sample_duration = 1.0 / sample_rate as f64;
|
let beats_per_sample = self.bpm / 60.0 / sample_rate as f64;
|
||||||
|
|
||||||
// Evaluate curve for each sample
|
|
||||||
for i in 0..length {
|
for i in 0..length {
|
||||||
let time = playhead + (i as f64 * sample_duration);
|
let time = playhead + Beats(i as f64 * beats_per_sample);
|
||||||
output[i] = self.evaluate_at_time(time);
|
output[i] = self.evaluate_at_time(time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +288,8 @@ impl AudioNode for AutomationInputNode {
|
||||||
keyframes: self.keyframes.clone(),
|
keyframes: self.keyframes.clone(),
|
||||||
outputs: self.outputs.clone(),
|
outputs: self.outputs.clone(),
|
||||||
parameters: self.parameters.clone(),
|
parameters: self.parameters.clone(),
|
||||||
playback_time: Arc::new(RwLock::new(0.0)),
|
playback_time: Arc::new(RwLock::new(Beats::ZERO)),
|
||||||
|
bpm: self.bpm,
|
||||||
value_min: self.value_min,
|
value_min: self.value_min,
|
||||||
value_max: self.value_max,
|
value_max: self.value_max,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
|
use crate::time::Beats;
|
||||||
|
|
||||||
const PARAM_RESOLUTION: u32 = 0;
|
const PARAM_RESOLUTION: u32 = 0;
|
||||||
|
|
||||||
|
|
@ -61,12 +62,12 @@ pub struct BeatNode {
|
||||||
bpm: f32,
|
bpm: f32,
|
||||||
beats_per_bar: u32,
|
beats_per_bar: u32,
|
||||||
resolution: BeatResolution,
|
resolution: BeatResolution,
|
||||||
/// Playback time in seconds, set by the graph before process()
|
/// Playback time in beats, set by the graph before process()
|
||||||
playback_time: f64,
|
playback_time: Beats,
|
||||||
/// Previous playback_time to detect paused state
|
/// Previous playback_time to detect paused state
|
||||||
prev_playback_time: f64,
|
prev_playback_time: Beats,
|
||||||
/// Free-running time accumulator for when playback is stopped
|
/// Free-running beat accumulator for when playback is stopped
|
||||||
free_run_time: f64,
|
free_run_time: Beats,
|
||||||
inputs: Vec<NodePort>,
|
inputs: Vec<NodePort>,
|
||||||
outputs: Vec<NodePort>,
|
outputs: Vec<NodePort>,
|
||||||
parameters: Vec<Parameter>,
|
parameters: Vec<Parameter>,
|
||||||
|
|
@ -92,16 +93,16 @@ impl BeatNode {
|
||||||
bpm: DEFAULT_BPM,
|
bpm: DEFAULT_BPM,
|
||||||
beats_per_bar: DEFAULT_BEATS_PER_BAR,
|
beats_per_bar: DEFAULT_BEATS_PER_BAR,
|
||||||
resolution: BeatResolution::Quarter,
|
resolution: BeatResolution::Quarter,
|
||||||
playback_time: 0.0,
|
playback_time: Beats::ZERO,
|
||||||
prev_playback_time: -1.0,
|
prev_playback_time: Beats(-1.0),
|
||||||
free_run_time: 0.0,
|
free_run_time: Beats::ZERO,
|
||||||
inputs,
|
inputs,
|
||||||
outputs,
|
outputs,
|
||||||
parameters,
|
parameters,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_playback_time(&mut self, time: f64) {
|
pub fn set_playback_time(&mut self, time: Beats) {
|
||||||
self.playback_time = time;
|
self.playback_time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,8 +170,8 @@ impl AudioNode for BeatNode {
|
||||||
let base_time = if paused { self.free_run_time } else { self.playback_time };
|
let base_time = if paused { self.free_run_time } else { self.playback_time };
|
||||||
|
|
||||||
for i in 0..len {
|
for i in 0..len {
|
||||||
let time = base_time + i as f64 * sample_period;
|
// base_time is already in beats; advance by beats_per_second per second
|
||||||
let beat_pos = time * beats_per_second;
|
let beat_pos = base_time.0 + i as f64 * beats_per_second * sample_period;
|
||||||
|
|
||||||
// Beat subdivision phase: 0→1 sawtooth
|
// Beat subdivision phase: 0→1 sawtooth
|
||||||
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32;
|
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32;
|
||||||
|
|
@ -188,13 +189,13 @@ impl AudioNode for BeatNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance free-run time (always ticks, so it's ready when playback stops)
|
// Advance free-run time (always ticks, so it's ready when playback stops)
|
||||||
self.free_run_time += len as f64 * sample_period;
|
self.free_run_time += Beats(len as f64 * beats_per_second * sample_period);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset(&mut self) {
|
fn reset(&mut self) {
|
||||||
self.playback_time = 0.0;
|
self.playback_time = Beats::ZERO;
|
||||||
self.prev_playback_time = -1.0;
|
self.prev_playback_time = Beats(-1.0);
|
||||||
self.free_run_time = 0.0;
|
self.free_run_time = Beats::ZERO;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn node_type(&self) -> &str {
|
fn node_type(&self) -> &str {
|
||||||
|
|
@ -211,9 +212,9 @@ impl AudioNode for BeatNode {
|
||||||
bpm: self.bpm,
|
bpm: self.bpm,
|
||||||
beats_per_bar: self.beats_per_bar,
|
beats_per_bar: self.beats_per_bar,
|
||||||
resolution: self.resolution,
|
resolution: self.resolution,
|
||||||
playback_time: 0.0,
|
playback_time: Beats::ZERO,
|
||||||
prev_playback_time: -1.0,
|
prev_playback_time: Beats(-1.0),
|
||||||
free_run_time: 0.0,
|
free_run_time: Beats::ZERO,
|
||||||
inputs: self.inputs.clone(),
|
inputs: self.inputs.clone(),
|
||||||
outputs: self.outputs.clone(),
|
outputs: self.outputs.clone(),
|
||||||
parameters: self.parameters.clone(),
|
parameters: self.parameters.clone(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
|
use crate::time::Beats;
|
||||||
|
|
||||||
const PARAM_MODE: u32 = 0;
|
const PARAM_MODE: u32 = 0;
|
||||||
const PARAM_STEPS: u32 = 1;
|
const PARAM_STEPS: u32 = 1;
|
||||||
|
|
@ -244,14 +245,14 @@ impl AudioNode for SequencerNode {
|
||||||
// Note-off for notes no longer active
|
// Note-off for notes no longer active
|
||||||
for ¬e in &self.prev_active_notes {
|
for ¬e in &self.prev_active_notes {
|
||||||
if !new_notes.contains(¬e) {
|
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
|
// Note-on for newly active notes
|
||||||
for ¬e in &new_notes {
|
for ¬e in &new_notes {
|
||||||
if !self.prev_active_notes.contains(¬e) {
|
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
|
// Process this voice's graph with its MIDI events
|
||||||
// Note: playback_time is 0.0 since voice allocator doesn't track time
|
// Note: playback_time is 0.0 since voice allocator doesn't track time
|
||||||
self.voice_instances[voice_idx].process(mix_slice, &midi_events, 0.0);
|
self.voice_instances[voice_idx].process(mix_slice, &midi_events, crate::time::Beats::ZERO);
|
||||||
|
|
||||||
// Auto-deactivate releasing voices that have gone silent
|
// Auto-deactivate releasing voices that have gone silent
|
||||||
if voice_state.releasing {
|
if voice_state.releasing {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::time::Seconds;
|
||||||
|
|
||||||
/// Windowed sinc interpolation for high-quality time stretching
|
/// Windowed sinc interpolation for high-quality time stretching
|
||||||
/// This is stateless and can handle arbitrary fractional positions
|
/// This is stateless and can handle arbitrary fractional positions
|
||||||
|
|
@ -442,19 +443,20 @@ impl AudioClipPool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render audio from a file in the pool with high-quality windowed sinc interpolation
|
/// Render audio from a file in the pool with high-quality windowed sinc interpolation
|
||||||
/// start_time_seconds: position in the audio file to start reading from (in seconds)
|
/// start_time: position in the audio file to start reading from (in seconds)
|
||||||
/// clip_read_ahead: per-clip-instance read-ahead buffer for compressed audio streaming
|
/// clip_read_ahead: per-clip-instance read-ahead buffer for compressed audio streaming
|
||||||
/// Returns the number of samples actually rendered
|
/// Returns the number of samples actually rendered
|
||||||
pub fn render_from_file(
|
pub fn render_from_file(
|
||||||
&self,
|
&self,
|
||||||
pool_index: usize,
|
pool_index: usize,
|
||||||
output: &mut [f32],
|
output: &mut [f32],
|
||||||
start_time_seconds: f64,
|
start_time: Seconds,
|
||||||
gain: f32,
|
gain: f32,
|
||||||
engine_sample_rate: u32,
|
engine_sample_rate: u32,
|
||||||
engine_channels: u32,
|
engine_channels: u32,
|
||||||
clip_read_ahead: Option<&super::disk_reader::ReadAheadBuffer>,
|
clip_read_ahead: Option<&super::disk_reader::ReadAheadBuffer>,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
|
let start_time_seconds = start_time.0;
|
||||||
let Some(audio_file) = self.files.get(pool_index) else {
|
let Some(audio_file) = self.files.get(pool_index) else {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ use super::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, Mi
|
||||||
use super::midi_pool::MidiClipPool;
|
use super::midi_pool::MidiClipPool;
|
||||||
use super::pool::AudioClipPool;
|
use super::pool::AudioClipPool;
|
||||||
use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode};
|
use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode};
|
||||||
|
use crate::tempo_map::TempoMap;
|
||||||
|
use crate::time::{Beats, Seconds};
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
|
@ -216,46 +218,6 @@ impl Project {
|
||||||
self.tracks.iter().map(|(&id, node)| (id, node))
|
self.tracks.iter().map(|(&id, node)| (id, node))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// After a BPM change, update MIDI clip durations then sync all clip beats/frames from seconds.
|
|
||||||
///
|
|
||||||
/// `midi_durations` maps each MidiClipId to its new content duration in seconds.
|
|
||||||
/// Call this after the seconds positions have already been updated (e.g. via MoveClip).
|
|
||||||
pub fn apply_bpm_change(&mut self, bpm: f64, fps: f64, midi_durations: &[(crate::audio::midi::MidiClipId, f64)]) {
|
|
||||||
for (_, track) in self.tracks.iter_mut() {
|
|
||||||
match track {
|
|
||||||
crate::audio::track::TrackNode::Audio(t) => {
|
|
||||||
for clip in &mut t.clips {
|
|
||||||
clip.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::audio::track::TrackNode::Midi(t) => {
|
|
||||||
// Update content durations first so internal_end is correct before sync
|
|
||||||
for instance in &mut t.clip_instances {
|
|
||||||
if let Some(&new_dur) = midi_durations.iter()
|
|
||||||
.find(|(id, _)| *id == instance.clip_id)
|
|
||||||
.map(|(_, d)| d)
|
|
||||||
{
|
|
||||||
let old_internal_dur = instance.internal_duration();
|
|
||||||
instance.internal_end = instance.internal_start + new_dur;
|
|
||||||
// Scale external_duration by the same ratio (works for both looping and non-looping)
|
|
||||||
if old_internal_dur > 1e-12 {
|
|
||||||
instance.external_duration = instance.external_duration * new_dur / old_internal_dur;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
instance.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
|
||||||
// Update pool clip durations
|
|
||||||
for &(clip_id, new_dur) in midi_durations {
|
|
||||||
if let Some(clip) = self.midi_clip_pool.get_clip_mut(clip_id) {
|
|
||||||
clip.duration = new_dur;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get oscilloscope data from a node in a track's graph
|
/// Get oscilloscope data from a node in a track's graph
|
||||||
pub fn get_oscilloscope_data(&self, track_id: TrackId, node_id: u32, sample_count: usize) -> Option<(Vec<f32>, Vec<f32>)> {
|
pub fn get_oscilloscope_data(&self, track_id: TrackId, node_id: u32, sample_count: usize) -> Option<(Vec<f32>, Vec<f32>)> {
|
||||||
if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) {
|
if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) {
|
||||||
|
|
@ -328,9 +290,9 @@ impl Project {
|
||||||
&mut self,
|
&mut self,
|
||||||
track_id: TrackId,
|
track_id: TrackId,
|
||||||
events: Vec<MidiEvent>,
|
events: Vec<MidiEvent>,
|
||||||
duration: f64,
|
duration: Beats,
|
||||||
name: String,
|
name: String,
|
||||||
external_start: f64,
|
external_start: Beats,
|
||||||
) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> {
|
) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> {
|
||||||
// Verify track exists and is a MIDI track
|
// Verify track exists and is a MIDI track
|
||||||
if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) {
|
if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) {
|
||||||
|
|
@ -363,11 +325,11 @@ impl Project {
|
||||||
|
|
||||||
/// Legacy method for backwards compatibility - creates clip and instance from old MidiClip format
|
/// Legacy method for backwards compatibility - creates clip and instance from old MidiClip format
|
||||||
pub fn add_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) -> Result<MidiClipInstanceId, &'static str> {
|
pub fn add_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) -> Result<MidiClipInstanceId, &'static str> {
|
||||||
self.add_midi_clip_at(track_id, clip, 0.0)
|
self.add_midi_clip_at(track_id, clip, Beats::ZERO)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a MIDI clip to the pool and create an instance at the given timeline position
|
/// Add a MIDI clip to the pool and create an instance at the given timeline position
|
||||||
pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: f64) -> Result<MidiClipInstanceId, &'static str> {
|
pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) -> Result<MidiClipInstanceId, &'static str> {
|
||||||
// Add the clip to the pool (it already has events and duration)
|
// Add the clip to the pool (it already has events and duration)
|
||||||
let duration = clip.duration;
|
let duration = clip.duration;
|
||||||
let clip_id = clip.id;
|
let clip_id = clip.id;
|
||||||
|
|
@ -411,7 +373,8 @@ impl Project {
|
||||||
output: &mut [f32],
|
output: &mut [f32],
|
||||||
audio_pool: &AudioClipPool,
|
audio_pool: &AudioClipPool,
|
||||||
buffer_pool: &mut BufferPool,
|
buffer_pool: &mut BufferPool,
|
||||||
playhead_seconds: f64,
|
playhead_seconds: Seconds,
|
||||||
|
tempo_map: &TempoMap,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
live_only: bool,
|
live_only: bool,
|
||||||
|
|
@ -423,7 +386,7 @@ impl Project {
|
||||||
// Create initial render context
|
// Create initial render context
|
||||||
let ctx = RenderContext {
|
let ctx = RenderContext {
|
||||||
live_only,
|
live_only,
|
||||||
..RenderContext::new(playhead_seconds, sample_rate, channels, output.len())
|
..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render each root track (index-based to avoid clone)
|
// Render each root track (index-based to avoid clone)
|
||||||
|
|
@ -497,7 +460,7 @@ impl Project {
|
||||||
let mut track_buffer = buffer_pool.acquire();
|
let mut track_buffer = buffer_pool.acquire();
|
||||||
track_buffer.resize(output.len(), 0.0);
|
track_buffer.resize(output.len(), 0.0);
|
||||||
track_buffer.fill(0.0);
|
track_buffer.fill(0.0);
|
||||||
track.render(&mut track_buffer, audio_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels);
|
track.render(&mut track_buffer, audio_pool, ctx);
|
||||||
// Accumulate peak level for VU metering (max over meter interval)
|
// Accumulate peak level for VU metering (max over meter interval)
|
||||||
let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
|
let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
|
||||||
track.peak_level = track.peak_level.max(buffer_peak);
|
track.peak_level = track.peak_level.max(buffer_peak);
|
||||||
|
|
@ -585,7 +548,7 @@ impl Project {
|
||||||
let mut graph_output = buffer_pool.acquire();
|
let mut graph_output = buffer_pool.acquire();
|
||||||
graph_output.resize(output.len(), 0.0);
|
graph_output.resize(output.len(), 0.0);
|
||||||
graph_output.fill(0.0);
|
graph_output.fill(0.0);
|
||||||
group.audio_graph.process(&mut graph_output, &[], ctx.playhead_seconds);
|
group.audio_graph.process(&mut graph_output, &[], ctx.playhead_beats());
|
||||||
|
|
||||||
for (out_sample, graph_sample) in output.iter_mut().zip(graph_output.iter()) {
|
for (out_sample, graph_sample) in output.iter_mut().zip(graph_output.iter()) {
|
||||||
*out_sample += graph_sample * group.volume;
|
*out_sample += graph_sample * group.volume;
|
||||||
|
|
@ -680,7 +643,7 @@ impl Project {
|
||||||
pub fn send_midi_note_on(&mut self, track_id: TrackId, note: u8, velocity: u8) {
|
pub fn send_midi_note_on(&mut self, track_id: TrackId, note: u8, velocity: u8) {
|
||||||
// Queue the MIDI note-on event to the track's live MIDI queue
|
// Queue the MIDI note-on event to the track's live MIDI queue
|
||||||
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
|
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
|
||||||
let event = MidiEvent::note_on(0.0, 0, note, velocity);
|
let event = MidiEvent::note_on(Beats::ZERO, 0, note, velocity);
|
||||||
track.queue_live_midi(event);
|
track.queue_live_midi(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -689,7 +652,7 @@ impl Project {
|
||||||
pub fn send_midi_note_off(&mut self, track_id: TrackId, note: u8) {
|
pub fn send_midi_note_off(&mut self, track_id: TrackId, note: u8) {
|
||||||
// Queue the MIDI note-off event to the track's live MIDI queue
|
// Queue the MIDI note-off event to the track's live MIDI queue
|
||||||
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
|
if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) {
|
||||||
let event = MidiEvent::note_off(0.0, 0, note, 0);
|
let event = MidiEvent::note_off(Beats::ZERO, 0, note, 0);
|
||||||
track.queue_live_midi(event);
|
track.queue_live_midi(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/// Audio recording system for capturing microphone input
|
/// Audio recording system for capturing microphone input
|
||||||
use crate::audio::{ClipId, MidiClipId, TrackId};
|
use crate::audio::{ClipId, MidiClipId, TrackId};
|
||||||
use crate::io::{WavWriter, WaveformPeak};
|
use crate::io::{WavWriter, WaveformPeak};
|
||||||
|
use crate::time::{Beats, Seconds};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
|
@ -18,8 +19,8 @@ pub struct RecordingState {
|
||||||
pub sample_rate: u32,
|
pub sample_rate: u32,
|
||||||
/// Number of channels
|
/// Number of channels
|
||||||
pub channels: u32,
|
pub channels: u32,
|
||||||
/// Timeline start position in seconds
|
/// Timeline start position
|
||||||
pub start_time: f64,
|
pub start_time: Beats,
|
||||||
/// Total frames recorded
|
/// Total frames recorded
|
||||||
pub frames_written: usize,
|
pub frames_written: usize,
|
||||||
/// Whether recording is currently paused
|
/// Whether recording is currently paused
|
||||||
|
|
@ -45,7 +46,7 @@ impl RecordingState {
|
||||||
writer: WavWriter,
|
writer: WavWriter,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
start_time: f64,
|
start_time: Beats,
|
||||||
_flush_interval_seconds: f64, // No longer used - kept for API compatibility
|
_flush_interval_seconds: f64, // No longer used - kept for API compatibility
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Calculate frames per waveform peak
|
// Calculate frames per waveform peak
|
||||||
|
|
@ -130,9 +131,9 @@ impl RecordingState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current recording duration in seconds
|
/// Get current recording duration
|
||||||
pub fn duration(&self) -> f64 {
|
pub fn duration(&self) -> Seconds {
|
||||||
self.frames_written as f64 / self.sample_rate as f64
|
Seconds(self.frames_written as f64 / self.sample_rate as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finalize the recording and return the temp file path, waveform, and audio data
|
/// Finalize the recording and return the temp file path, waveform, and audio data
|
||||||
|
|
@ -175,33 +176,23 @@ impl RecordingState {
|
||||||
/// Active MIDI note waiting for its noteOff event
|
/// Active MIDI note waiting for its noteOff event
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct ActiveMidiNote {
|
struct ActiveMidiNote {
|
||||||
/// MIDI note number (0-127)
|
|
||||||
note: u8,
|
note: u8,
|
||||||
/// Velocity (0-127)
|
|
||||||
velocity: u8,
|
velocity: u8,
|
||||||
/// Absolute time when note started (seconds)
|
start_time: Beats,
|
||||||
start_time: f64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State of an active MIDI recording session
|
/// State of an active MIDI recording session.
|
||||||
pub struct MidiRecordingState {
|
pub struct MidiRecordingState {
|
||||||
/// Track being recorded to
|
|
||||||
pub track_id: TrackId,
|
pub track_id: TrackId,
|
||||||
/// MIDI clip ID
|
|
||||||
pub clip_id: MidiClipId,
|
pub clip_id: MidiClipId,
|
||||||
/// Timeline start position in seconds
|
pub start_time: Beats,
|
||||||
pub start_time: f64,
|
|
||||||
/// Currently active notes (noteOn without matching noteOff)
|
|
||||||
/// Maps note number to ActiveMidiNote
|
|
||||||
active_notes: HashMap<u8, ActiveMidiNote>,
|
active_notes: HashMap<u8, ActiveMidiNote>,
|
||||||
/// Completed notes ready to be added to clip
|
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats
|
||||||
/// Format: (time_offset, note, velocity, duration)
|
pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
|
||||||
pub completed_notes: Vec<(f64, u8, u8, f64)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MidiRecordingState {
|
impl MidiRecordingState {
|
||||||
/// Create a new MIDI recording state
|
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: Beats) -> Self {
|
||||||
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: f64) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
track_id,
|
track_id,
|
||||||
clip_id,
|
clip_id,
|
||||||
|
|
@ -211,87 +202,62 @@ impl MidiRecordingState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a MIDI note on event
|
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) {
|
||||||
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: f64) {
|
self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time });
|
||||||
self.active_notes.insert(note, ActiveMidiNote {
|
|
||||||
note,
|
|
||||||
velocity,
|
|
||||||
start_time: absolute_time,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a MIDI note off event
|
pub fn note_off(&mut self, note: u8, absolute_time: Beats) {
|
||||||
pub fn note_off(&mut self, note: u8, absolute_time: f64) {
|
|
||||||
// Find the matching noteOn
|
|
||||||
if let Some(active_note) = self.active_notes.remove(¬e) {
|
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 {
|
if absolute_time <= self.start_time {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp note start to clip start: notes held across the recording boundary
|
|
||||||
// are treated as starting at the clip position.
|
|
||||||
let note_start = active_note.start_time.max(self.start_time);
|
let note_start = active_note.start_time.max(self.start_time);
|
||||||
let time_offset = note_start - self.start_time;
|
|
||||||
let duration = absolute_time - note_start;
|
|
||||||
|
|
||||||
eprintln!("[MIDI_RECORDING_STATE] Completing note {}: note_start={:.3}s, note_end={:.3}s, recording_start={:.3}s, time_offset={:.3}s, duration={:.3}s",
|
|
||||||
note, note_start, absolute_time, self.start_time, time_offset, duration);
|
|
||||||
|
|
||||||
self.completed_notes.push((
|
self.completed_notes.push((
|
||||||
time_offset,
|
note_start - self.start_time,
|
||||||
active_note.note,
|
active_note.note,
|
||||||
active_note.velocity,
|
active_note.velocity,
|
||||||
duration,
|
absolute_time - note_start,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// If no matching noteOn found, ignore the noteOff
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all completed notes
|
pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] {
|
||||||
pub fn get_notes(&self) -> &[(f64, u8, u8, f64)] {
|
|
||||||
&self.completed_notes
|
&self.completed_notes
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the number of completed notes
|
|
||||||
pub fn note_count(&self) -> usize {
|
pub fn note_count(&self) -> usize {
|
||||||
self.completed_notes.len()
|
self.completed_notes.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all completed notes plus currently-held notes with a provisional duration.
|
/// Get all completed notes plus currently-held notes with a provisional duration.
|
||||||
/// Used for live preview during recording so held notes appear immediately.
|
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
|
||||||
pub fn get_notes_with_active(&self, current_time: f64) -> Vec<(f64, u8, u8, f64)> {
|
|
||||||
let mut notes = self.completed_notes.clone();
|
let mut notes = self.completed_notes.clone();
|
||||||
for active in self.active_notes.values() {
|
for active in self.active_notes.values() {
|
||||||
let note_start = active.start_time.max(self.start_time);
|
let note_start = active.start_time.max(self.start_time);
|
||||||
let time_offset = note_start - self.start_time;
|
notes.push((
|
||||||
let provisional_dur = (current_time - note_start).max(0.0);
|
note_start - self.start_time,
|
||||||
notes.push((time_offset, active.note, active.velocity, provisional_dur));
|
active.note,
|
||||||
|
active.velocity,
|
||||||
|
(current_time - note_start).max(Beats::ZERO),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
notes
|
notes
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the note numbers of all currently held (active) notes
|
|
||||||
pub fn active_note_numbers(&self) -> Vec<u8> {
|
pub fn active_note_numbers(&self) -> Vec<u8> {
|
||||||
self.active_notes.keys().copied().collect()
|
self.active_notes.keys().copied().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close out all active notes at the given time
|
pub fn close_active_notes(&mut self, end_time: Beats) {
|
||||||
/// This should be called when stopping recording to end any held notes
|
|
||||||
pub fn close_active_notes(&mut self, end_time: f64) {
|
|
||||||
let active_notes: Vec<_> = self.active_notes.drain().collect();
|
let active_notes: Vec<_> = self.active_notes.drain().collect();
|
||||||
|
|
||||||
for (_note_num, active_note) in active_notes {
|
for (_note_num, active_note) in active_notes {
|
||||||
let note_start = active_note.start_time.max(self.start_time);
|
let note_start = active_note.start_time.max(self.start_time);
|
||||||
let time_offset = note_start - self.start_time;
|
|
||||||
let duration = end_time - note_start;
|
|
||||||
|
|
||||||
self.completed_notes.push((
|
self.completed_notes.push((
|
||||||
time_offset,
|
note_start - self.start_time,
|
||||||
active_note.note,
|
active_note.note,
|
||||||
active_note.velocity,
|
active_note.velocity,
|
||||||
duration,
|
end_time - note_start,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ use super::node_graph::AudioGraph;
|
||||||
use super::node_graph::nodes::{AudioInputNode, AudioOutputNode};
|
use super::node_graph::nodes::{AudioInputNode, AudioOutputNode};
|
||||||
use super::node_graph::preset::GraphPreset;
|
use super::node_graph::preset::GraphPreset;
|
||||||
use super::pool::AudioClipPool;
|
use super::pool::AudioClipPool;
|
||||||
|
use crate::tempo_map::TempoMap;
|
||||||
|
use crate::time::{Beats, Seconds};
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
|
@ -23,10 +25,12 @@ pub type Track = AudioTrack;
|
||||||
/// Rendering context that carries timing information through the track hierarchy
|
/// Rendering context that carries timing information through the track hierarchy
|
||||||
///
|
///
|
||||||
/// This allows metatracks to transform time for their children (time stretch, offset, etc.)
|
/// This allows metatracks to transform time for their children (time stretch, offset, etc.)
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct RenderContext {
|
pub struct RenderContext<'a> {
|
||||||
/// Current playhead position in seconds (in transformed time)
|
/// Current playhead position in seconds (in transformed time)
|
||||||
pub playhead_seconds: f64,
|
pub playhead_seconds: Seconds,
|
||||||
|
/// Tempo map for beat ↔ second conversion
|
||||||
|
pub tempo_map: &'a TempoMap,
|
||||||
/// Audio sample rate
|
/// Audio sample rate
|
||||||
pub sample_rate: u32,
|
pub sample_rate: u32,
|
||||||
/// Number of channels
|
/// Number of channels
|
||||||
|
|
@ -41,16 +45,17 @@ pub struct RenderContext {
|
||||||
pub live_only: bool,
|
pub live_only: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderContext {
|
impl<'a> RenderContext<'a> {
|
||||||
/// Create a new render context
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
playhead_seconds: f64,
|
playhead_seconds: Seconds,
|
||||||
|
tempo_map: &'a TempoMap,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
buffer_size: usize,
|
buffer_size: usize,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
playhead_seconds,
|
playhead_seconds,
|
||||||
|
tempo_map,
|
||||||
sample_rate,
|
sample_rate,
|
||||||
channels,
|
channels,
|
||||||
buffer_size,
|
buffer_size,
|
||||||
|
|
@ -59,15 +64,21 @@ impl RenderContext {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the duration of the buffer in seconds
|
pub fn buffer_duration(&self) -> Seconds {
|
||||||
pub fn buffer_duration(&self) -> f64 {
|
Seconds(self.buffer_size as f64 / (self.sample_rate as f64 * self.channels as f64))
|
||||||
self.buffer_size as f64 / (self.sample_rate as f64 * self.channels as f64)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the end time of the buffer
|
pub fn buffer_end(&self) -> Seconds {
|
||||||
pub fn buffer_end(&self) -> f64 {
|
|
||||||
self.playhead_seconds + self.buffer_duration()
|
self.playhead_seconds + self.buffer_duration()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn playhead_beats(&self) -> Beats {
|
||||||
|
self.tempo_map.seconds_to_beats(self.playhead_seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buffer_end_beats(&self) -> Beats {
|
||||||
|
self.tempo_map.seconds_to_beats(self.buffer_end())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Node in the track hierarchy - can be an audio track, MIDI track, or a metatrack
|
/// Node in the track hierarchy - can be an audio track, MIDI track, or a metatrack
|
||||||
|
|
@ -170,14 +181,14 @@ pub struct Metatrack {
|
||||||
pub time_stretch: f32,
|
pub time_stretch: f32,
|
||||||
/// Pitch shift in semitones (for future implementation)
|
/// Pitch shift in semitones (for future implementation)
|
||||||
pub pitch_shift: f32,
|
pub pitch_shift: f32,
|
||||||
/// Time offset in seconds (shift content forward/backward in time)
|
/// Time offset (shift content forward/backward in time)
|
||||||
pub offset: f64,
|
pub offset: Seconds,
|
||||||
/// Trim start: offset into the metatrack's internal content (seconds)
|
/// Trim start: offset into the metatrack's internal content
|
||||||
/// Children will see time starting from this point
|
/// Children will see time starting from this point
|
||||||
pub trim_start: f64,
|
pub trim_start: Seconds,
|
||||||
/// Trim end: offset into the metatrack's internal content (seconds)
|
/// Trim end: offset into the metatrack's internal content
|
||||||
/// None means no end trim (play until content ends)
|
/// None means no end trim (play until content ends)
|
||||||
pub trim_end: Option<f64>,
|
pub trim_end: Option<Seconds>,
|
||||||
/// Automation lanes for this metatrack
|
/// Automation lanes for this metatrack
|
||||||
pub automation_lanes: HashMap<AutomationLaneId, AutomationLane>,
|
pub automation_lanes: HashMap<AutomationLaneId, AutomationLane>,
|
||||||
next_automation_id: AutomationLaneId,
|
next_automation_id: AutomationLaneId,
|
||||||
|
|
@ -231,8 +242,8 @@ impl Metatrack {
|
||||||
solo: false,
|
solo: false,
|
||||||
time_stretch: 1.0,
|
time_stretch: 1.0,
|
||||||
pitch_shift: 0.0,
|
pitch_shift: 0.0,
|
||||||
offset: 0.0,
|
offset: Seconds::ZERO,
|
||||||
trim_start: 0.0,
|
trim_start: Seconds::ZERO,
|
||||||
trim_end: None,
|
trim_end: None,
|
||||||
automation_lanes: HashMap::new(),
|
automation_lanes: HashMap::new(),
|
||||||
next_automation_id: 0,
|
next_automation_id: 0,
|
||||||
|
|
@ -252,7 +263,11 @@ impl Metatrack {
|
||||||
graph
|
graph
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the explicit subtrack mixing graph: SubtrackInputs → Mixer → AudioOutput.
|
/// Build the default subtrack mixing graph: SubtrackInputs → Mixer → Gain → AudioOutput,
|
||||||
|
/// with an AutomationInput ("Volume", range 0..2) feeding the Gain's CV port.
|
||||||
|
///
|
||||||
|
/// Existing Volume keyframes are preserved across rebuilds so that adding/removing
|
||||||
|
/// a child track doesn't reset the automation.
|
||||||
///
|
///
|
||||||
/// `subtracks` is an ordered list of (backend TrackId, display name) for each child.
|
/// `subtracks` is an ordered list of (backend TrackId, display name) for each child.
|
||||||
/// Replaces the current graph and marks `graph_is_default = true`.
|
/// Replaces the current graph and marks `graph_is_default = true`.
|
||||||
|
|
@ -262,35 +277,60 @@ impl Metatrack {
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
buffer_size: usize,
|
buffer_size: usize,
|
||||||
) {
|
) {
|
||||||
use super::node_graph::nodes::{SubtrackInputsNode, MixerNode};
|
use super::node_graph::nodes::{SubtrackInputsNode, MixerNode, GainNode, AutomationInputNode};
|
||||||
|
use super::node_graph::nodes::AutomationKeyframe;
|
||||||
|
use crate::time::Beats;
|
||||||
|
|
||||||
|
// Preserve existing Volume keyframes before rebuilding.
|
||||||
|
let existing_volume_kfs = self.get_volume_automation_keyframes();
|
||||||
|
|
||||||
let n = subtracks.len();
|
let n = subtracks.len();
|
||||||
let mut graph = AudioGraph::new(sample_rate, buffer_size);
|
let mut graph = AudioGraph::new(sample_rate, buffer_size);
|
||||||
|
|
||||||
// SubtrackInputs node (N outputs, one per child)
|
// SubtrackInputs node (N outputs, one per child)
|
||||||
// NOTE: `new()` initialises buffers as zero-length; call `update_subtracks` immediately
|
|
||||||
// to allocate stereo interleaved buffers (buffer_size * 2 samples each).
|
|
||||||
let mut inputs_node = SubtrackInputsNode::new("Subtrack Inputs", subtracks);
|
let mut inputs_node = SubtrackInputsNode::new("Subtrack Inputs", subtracks);
|
||||||
let subtracks_copy = inputs_node.subtracks().to_vec();
|
let subtracks_copy = inputs_node.subtracks().to_vec();
|
||||||
inputs_node.update_subtracks(subtracks_copy, buffer_size);
|
inputs_node.update_subtracks(subtracks_copy, buffer_size);
|
||||||
let inputs_id = graph.add_node(Box::new(inputs_node));
|
let inputs_id = graph.add_node(Box::new(inputs_node));
|
||||||
graph.set_node_position(inputs_id, 100.0, 150.0);
|
graph.set_node_position(inputs_id, 100.0, 150.0);
|
||||||
|
|
||||||
// Mixer node (starts with 1 spare; grows as connections are made)
|
// Mixer node
|
||||||
let mixer_node = Box::new(MixerNode::new("Mixer"));
|
let mixer_node = Box::new(MixerNode::new("Mixer"));
|
||||||
let mixer_id = graph.add_node(mixer_node);
|
let mixer_id = graph.add_node(mixer_node);
|
||||||
graph.set_node_position(mixer_id, 350.0, 150.0);
|
graph.set_node_position(mixer_id, 330.0, 150.0);
|
||||||
|
|
||||||
|
// Gain node — group volume control
|
||||||
|
let gain_id = graph.add_node(Box::new(GainNode::new("Volume")));
|
||||||
|
graph.set_node_position(gain_id, 520.0, 150.0);
|
||||||
|
|
||||||
|
// AutomationInput — drives the Gain's CV port
|
||||||
|
let mut auto_node = AutomationInputNode::new("Volume CV");
|
||||||
|
auto_node.set_display_name("Volume".to_string());
|
||||||
|
auto_node.value_min = 0.0;
|
||||||
|
auto_node.value_max = 2.0;
|
||||||
|
auto_node.clear_keyframes();
|
||||||
|
if existing_volume_kfs.is_empty() {
|
||||||
|
auto_node.add_keyframe(AutomationKeyframe::new(Beats::ZERO, 1.0));
|
||||||
|
} else {
|
||||||
|
for kf in existing_volume_kfs {
|
||||||
|
auto_node.add_keyframe(kf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let auto_id = graph.add_node(Box::new(auto_node));
|
||||||
|
graph.set_node_position(auto_id, 520.0, 320.0);
|
||||||
|
|
||||||
// AudioOutput node
|
// AudioOutput node
|
||||||
let output_node = Box::new(AudioOutputNode::new("Audio Output"));
|
let output_node = Box::new(AudioOutputNode::new("Audio Output"));
|
||||||
let output_id = graph.add_node(output_node);
|
let output_id = graph.add_node(output_node);
|
||||||
graph.set_node_position(output_id, 600.0, 150.0);
|
graph.set_node_position(output_id, 720.0, 150.0);
|
||||||
|
|
||||||
// Connect SubtrackInputs[i] → Mixer[i] for each subtrack
|
// Connect SubtrackInputs[i] → Mixer[i] for each subtrack
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let _ = graph.connect(inputs_id, i, mixer_id, i);
|
let _ = graph.connect(inputs_id, i, mixer_id, i);
|
||||||
}
|
}
|
||||||
let _ = graph.connect(mixer_id, 0, output_id, 0);
|
let _ = graph.connect(mixer_id, 0, gain_id, 0); // Mixer → Gain audio
|
||||||
|
let _ = graph.connect(auto_id, 0, gain_id, 1); // AutomationInput → Gain CV
|
||||||
|
let _ = graph.connect(gain_id, 0, output_id, 0); // Gain → Audio Out
|
||||||
graph.set_output_node(Some(output_id));
|
graph.set_output_node(Some(output_id));
|
||||||
|
|
||||||
self.audio_graph = graph;
|
self.audio_graph = graph;
|
||||||
|
|
@ -298,6 +338,22 @@ impl Metatrack {
|
||||||
self.graph_is_default = true;
|
self.graph_is_default = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract Volume AutomationInput keyframes from the current graph (if any),
|
||||||
|
/// so they can be preserved across `set_subtrack_graph` rebuilds.
|
||||||
|
fn get_volume_automation_keyframes(&self) -> Vec<super::node_graph::nodes::AutomationKeyframe> {
|
||||||
|
use super::node_graph::nodes::AutomationInputNode;
|
||||||
|
for idx in self.audio_graph.node_indices() {
|
||||||
|
if let Some(node) = self.audio_graph.get_graph_node(idx) {
|
||||||
|
if node.node.node_type() == "AutomationInput" {
|
||||||
|
if let Some(auto_node) = node.node.as_any().downcast_ref::<AutomationInputNode>() {
|
||||||
|
return auto_node.keyframes().to_vec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a new subtrack port to the existing graph.
|
/// Add a new subtrack port to the existing graph.
|
||||||
///
|
///
|
||||||
/// If `graph_is_default`: also connects the new port to a new Mixer input.
|
/// If `graph_is_default`: also connects the new port to a new Mixer input.
|
||||||
|
|
@ -493,7 +549,7 @@ impl Metatrack {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate automation at a specific time and return effective parameters
|
/// Evaluate automation at a specific time and return effective parameters
|
||||||
pub fn evaluate_automation_at_time(&self, time: f64) -> (f32, f32, f64) {
|
pub fn evaluate_automation_at_time(&self, time: Beats) -> (f32, f32, Seconds) {
|
||||||
let mut volume = self.volume;
|
let mut volume = self.volume;
|
||||||
let mut time_stretch = self.time_stretch;
|
let mut time_stretch = self.time_stretch;
|
||||||
let mut offset = self.offset;
|
let mut offset = self.offset;
|
||||||
|
|
@ -517,7 +573,7 @@ impl Metatrack {
|
||||||
}
|
}
|
||||||
ParameterId::TimeOffset => {
|
ParameterId::TimeOffset => {
|
||||||
if let Some(automated_value) = lane.evaluate(time) {
|
if let Some(automated_value) = lane.evaluate(time) {
|
||||||
offset = automated_value as f64;
|
offset = Seconds(automated_value as f64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -561,7 +617,7 @@ impl Metatrack {
|
||||||
|
|
||||||
/// Check whether this metatrack should produce audio at the given parent time.
|
/// Check whether this metatrack should produce audio at the given parent time.
|
||||||
/// Returns false if the playhead is outside the trim window.
|
/// Returns false if the playhead is outside the trim window.
|
||||||
pub fn is_active_at_time(&self, parent_playhead: f64) -> bool {
|
pub fn is_active_at_time(&self, parent_playhead: Seconds) -> bool {
|
||||||
let local_time = (parent_playhead - self.offset) * self.time_stretch as f64;
|
let local_time = (parent_playhead - self.offset) * self.time_stretch as f64;
|
||||||
if local_time < self.trim_start {
|
if local_time < self.trim_start {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -580,7 +636,7 @@ impl Metatrack {
|
||||||
/// Time stretch affects how fast content plays: 0.5 = half speed, 2.0 = double speed
|
/// Time stretch affects how fast content plays: 0.5 = half speed, 2.0 = double speed
|
||||||
/// Offset shifts content forward/backward in time
|
/// Offset shifts content forward/backward in time
|
||||||
/// Trim start offsets into the internal content
|
/// Trim start offsets into the internal content
|
||||||
pub fn transform_context(&self, ctx: RenderContext) -> RenderContext {
|
pub fn transform_context<'a>(&self, ctx: RenderContext<'a>) -> RenderContext<'a> {
|
||||||
let mut transformed = ctx;
|
let mut transformed = ctx;
|
||||||
|
|
||||||
// Apply transformations in order:
|
// Apply transformations in order:
|
||||||
|
|
@ -596,7 +652,7 @@ impl Metatrack {
|
||||||
let stretched = adjusted_playhead * self.time_stretch as f64;
|
let stretched = adjusted_playhead * self.time_stretch as f64;
|
||||||
|
|
||||||
// 3. Add trim_start so children see time starting from the trim point
|
// 3. Add trim_start so children see time starting from the trim point
|
||||||
// If trim_start=2.0, children start seeing time 2.0 when parent reaches offset
|
// If trim_start=2.0s, children start seeing time 2.0s when parent reaches offset
|
||||||
transformed.playhead_seconds = stretched + self.trim_start;
|
transformed.playhead_seconds = stretched + self.trim_start;
|
||||||
|
|
||||||
// Accumulate time stretch for nested metatracks
|
// Accumulate time stretch for nested metatracks
|
||||||
|
|
@ -772,13 +828,13 @@ impl MidiTrack {
|
||||||
// Send note-off for all 128 possible MIDI notes to silence the instrument
|
// Send note-off for all 128 possible MIDI notes to silence the instrument
|
||||||
let mut note_offs = Vec::new();
|
let mut note_offs = Vec::new();
|
||||||
for note in 0..128 {
|
for note in 0..128 {
|
||||||
note_offs.push(MidiEvent::note_off(0.0, 0, note, 0));
|
note_offs.push(MidiEvent::note_off(Beats::ZERO, 0, note, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a silent buffer to process the note-offs
|
// Create a silent buffer to process the note-offs
|
||||||
let buffer_size = 512 * 2; // stereo
|
let buffer_size = 512 * 2; // stereo
|
||||||
let mut silent_buffer = vec![0.0f32; buffer_size];
|
let mut silent_buffer = vec![0.0f32; buffer_size];
|
||||||
self.instrument_graph.process(&mut silent_buffer, ¬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)
|
/// Queue a live MIDI event (from virtual keyboard or MIDI controller)
|
||||||
|
|
@ -805,17 +861,17 @@ impl MidiTrack {
|
||||||
let mut midi_events = Vec::new();
|
let mut midi_events = Vec::new();
|
||||||
|
|
||||||
if !ctx.live_only {
|
if !ctx.live_only {
|
||||||
let buffer_duration_seconds = output.len() as f64 / (ctx.sample_rate as f64 * ctx.channels as f64);
|
let playhead_beats = ctx.playhead_beats();
|
||||||
let buffer_end_seconds = ctx.playhead_seconds + buffer_duration_seconds;
|
let buffer_end_beats = ctx.buffer_end_beats();
|
||||||
|
|
||||||
// Collect MIDI events from all clip instances that overlap with current time range
|
// Collect MIDI events from all clip instances that overlap with current beat range
|
||||||
let mut currently_active = HashSet::new();
|
let mut currently_active = HashSet::new();
|
||||||
for instance in &self.clip_instances {
|
for instance in &self.clip_instances {
|
||||||
if instance.overlaps_range(ctx.playhead_seconds, buffer_end_seconds) {
|
if instance.overlaps_range(playhead_beats, buffer_end_beats) {
|
||||||
currently_active.insert(instance.id);
|
currently_active.insert(instance.id);
|
||||||
}
|
}
|
||||||
if let Some(clip) = midi_pool.get_clip(instance.clip_id) {
|
if let Some(clip) = midi_pool.get_clip(instance.clip_id) {
|
||||||
let events = instance.get_events_in_range(clip, ctx.playhead_seconds, buffer_end_seconds);
|
let events = instance.get_events_in_range(clip, playhead_beats, buffer_end_beats);
|
||||||
midi_events.extend(events);
|
midi_events.extend(events);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -824,7 +880,7 @@ impl MidiTrack {
|
||||||
for prev_id in &self.prev_active_instances {
|
for prev_id in &self.prev_active_instances {
|
||||||
if !currently_active.contains(prev_id) {
|
if !currently_active.contains(prev_id) {
|
||||||
for note in 0..128u8 {
|
for note in 0..128u8 {
|
||||||
midi_events.push(MidiEvent::note_off(ctx.playhead_seconds, 0, note, 0));
|
midi_events.push(MidiEvent::note_off(playhead_beats, 0, note, 0));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -836,10 +892,10 @@ impl MidiTrack {
|
||||||
midi_events.extend(self.live_midi_queue.drain(..));
|
midi_events.extend(self.live_midi_queue.drain(..));
|
||||||
|
|
||||||
// Generate audio using instrument graph
|
// Generate audio using instrument graph
|
||||||
self.instrument_graph.process(output, &midi_events, ctx.playhead_seconds);
|
self.instrument_graph.process(output, &midi_events, ctx.playhead_beats());
|
||||||
|
|
||||||
// Evaluate and apply automation (skip automation in live_only mode — no playhead to evaluate at)
|
// Evaluate and apply automation (skip automation in live_only mode — no playhead to evaluate at)
|
||||||
let effective_volume = if ctx.live_only { self.volume } else { self.evaluate_automation_at_time(ctx.playhead_seconds) };
|
let effective_volume = if ctx.live_only { self.volume } else { self.evaluate_automation_at_time(ctx.playhead_beats()) };
|
||||||
|
|
||||||
// Apply track volume
|
// Apply track volume
|
||||||
for sample in output.iter_mut() {
|
for sample in output.iter_mut() {
|
||||||
|
|
@ -848,7 +904,7 @@ impl MidiTrack {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate automation at a specific time and return the effective volume
|
/// Evaluate automation at a specific time and return the effective volume
|
||||||
fn evaluate_automation_at_time(&self, time: f64) -> f32 {
|
fn evaluate_automation_at_time(&self, time: Beats) -> f32 {
|
||||||
let mut volume = self.volume;
|
let mut volume = self.volume;
|
||||||
|
|
||||||
// Check for volume automation
|
// Check for volume automation
|
||||||
|
|
@ -1081,12 +1137,9 @@ impl AudioTrack {
|
||||||
&mut self,
|
&mut self,
|
||||||
output: &mut [f32],
|
output: &mut [f32],
|
||||||
pool: &AudioClipPool,
|
pool: &AudioClipPool,
|
||||||
playhead_seconds: f64,
|
ctx: RenderContext<'_>,
|
||||||
sample_rate: u32,
|
|
||||||
channels: u32,
|
|
||||||
) -> usize {
|
) -> usize {
|
||||||
let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64);
|
let buffer_end = ctx.buffer_end();
|
||||||
let buffer_end_seconds = playhead_seconds + buffer_duration_seconds;
|
|
||||||
|
|
||||||
// Split borrow: take clip_render_buffer out to avoid borrow conflict with &self methods
|
// Split borrow: take clip_render_buffer out to avoid borrow conflict with &self methods
|
||||||
let mut clip_buffer = std::mem::take(&mut self.clip_render_buffer);
|
let mut clip_buffer = std::mem::take(&mut self.clip_render_buffer);
|
||||||
|
|
@ -1096,16 +1149,8 @@ impl AudioTrack {
|
||||||
|
|
||||||
// Render all active clip instances into the buffer
|
// Render all active clip instances into the buffer
|
||||||
for clip in &self.clips {
|
for clip in &self.clips {
|
||||||
// Check if clip overlaps with current buffer time range
|
if clip.external_start_secs(ctx.tempo_map) < buffer_end && clip.external_end_secs(ctx.tempo_map) > ctx.playhead_seconds {
|
||||||
if clip.external_start < buffer_end_seconds && clip.external_end() > playhead_seconds {
|
rendered += self.render_clip(clip, &mut clip_buffer, pool, ctx);
|
||||||
rendered += self.render_clip(
|
|
||||||
clip,
|
|
||||||
&mut clip_buffer,
|
|
||||||
pool,
|
|
||||||
playhead_seconds,
|
|
||||||
sample_rate,
|
|
||||||
channels,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1123,13 +1168,13 @@ impl AudioTrack {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process through the effects graph (this will write to output buffer)
|
// Process through the effects graph (this will write to output buffer)
|
||||||
self.effects_graph.process(output, &[], playhead_seconds);
|
self.effects_graph.process(output, &[], ctx.playhead_beats());
|
||||||
|
|
||||||
// Put the buffer back for reuse next callback
|
// Put the buffer back for reuse next callback
|
||||||
self.clip_render_buffer = clip_buffer;
|
self.clip_render_buffer = clip_buffer;
|
||||||
|
|
||||||
// Evaluate and apply automation
|
// Evaluate and apply automation
|
||||||
let effective_volume = self.evaluate_automation_at_time(playhead_seconds);
|
let effective_volume = self.evaluate_automation_at_time(ctx.playhead_beats());
|
||||||
|
|
||||||
// Apply track volume
|
// Apply track volume
|
||||||
for sample in output.iter_mut() {
|
for sample in output.iter_mut() {
|
||||||
|
|
@ -1140,7 +1185,7 @@ impl AudioTrack {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate automation at a specific time and return the effective volume
|
/// Evaluate automation at a specific time and return the effective volume
|
||||||
fn evaluate_automation_at_time(&self, time: f64) -> f32 {
|
fn evaluate_automation_at_time(&self, time: Beats) -> f32 {
|
||||||
let mut volume = self.volume;
|
let mut volume = self.volume;
|
||||||
|
|
||||||
// Check for volume automation
|
// Check for volume automation
|
||||||
|
|
@ -1169,49 +1214,40 @@ impl AudioTrack {
|
||||||
clip: &AudioClipInstance,
|
clip: &AudioClipInstance,
|
||||||
output: &mut [f32],
|
output: &mut [f32],
|
||||||
pool: &AudioClipPool,
|
pool: &AudioClipPool,
|
||||||
playhead_seconds: f64,
|
ctx: RenderContext<'_>,
|
||||||
sample_rate: u32,
|
|
||||||
channels: u32,
|
|
||||||
) -> usize {
|
) -> usize {
|
||||||
let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64);
|
let playhead = ctx.playhead_seconds;
|
||||||
let buffer_end_seconds = playhead_seconds + buffer_duration_seconds;
|
let buffer_end = ctx.buffer_end();
|
||||||
|
let tempo_map = ctx.tempo_map;
|
||||||
|
let sample_rate = ctx.sample_rate;
|
||||||
|
let channels = ctx.channels;
|
||||||
|
|
||||||
// Determine the time range we need to render (intersection of buffer and clip external bounds)
|
// Determine the time range we need to render (intersection of buffer and clip external bounds)
|
||||||
let render_start_seconds = playhead_seconds.max(clip.external_start);
|
let render_start = playhead.max(clip.external_start_secs(tempo_map));
|
||||||
let render_end_seconds = buffer_end_seconds.min(clip.external_end());
|
let render_end = buffer_end.min(clip.external_end_secs(tempo_map));
|
||||||
|
|
||||||
// If no overlap, return early
|
if render_start >= render_end {
|
||||||
if render_start_seconds >= render_end_seconds {
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let internal_duration = clip.internal_duration();
|
let internal_duration = clip.internal_duration();
|
||||||
if internal_duration <= 0.0 {
|
if internal_duration <= Seconds::ZERO {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate combined gain
|
|
||||||
let combined_gain = clip.gain;
|
let combined_gain = clip.gain;
|
||||||
|
|
||||||
let mut total_rendered = 0;
|
let mut total_rendered = 0;
|
||||||
|
|
||||||
// Process the render range sample by sample (or in chunks for efficiency)
|
|
||||||
// For looping clips, we need to handle wrap-around at the loop boundary
|
|
||||||
let samples_per_second = sample_rate as f64 * channels as f64;
|
let samples_per_second = sample_rate as f64 * channels as f64;
|
||||||
|
|
||||||
// For now, render in a simpler way - iterate through the timeline range
|
let output_start_offset = ((render_start - playhead).0 * samples_per_second + 0.5) as usize;
|
||||||
// and use get_content_position for each sample position
|
let output_end_offset = ((render_end - playhead).0 * samples_per_second + 0.5) as usize;
|
||||||
let output_start_offset = ((render_start_seconds - playhead_seconds) * samples_per_second + 0.5) as usize;
|
|
||||||
let output_end_offset = ((render_end_seconds - playhead_seconds) * samples_per_second + 0.5) as usize;
|
|
||||||
|
|
||||||
if output_end_offset > output.len() || output_start_offset > output.len() {
|
if output_end_offset > output.len() || output_start_offset > output.len() {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not looping, we can render in one chunk (more efficient)
|
if !clip.is_looping(tempo_map) {
|
||||||
if !clip.is_looping() {
|
let content_start = clip.get_content_position(render_start, tempo_map).unwrap_or(clip.internal_start);
|
||||||
// Simple case: no looping
|
|
||||||
let content_start = clip.get_content_position(render_start_seconds).unwrap_or(clip.internal_start);
|
|
||||||
let output_len = output.len();
|
let output_len = output.len();
|
||||||
let output_slice = &mut output[output_start_offset..output_end_offset.min(output_len)];
|
let output_slice = &mut output[output_start_offset..output_end_offset.min(output_len)];
|
||||||
|
|
||||||
|
|
@ -1225,23 +1261,20 @@ impl AudioTrack {
|
||||||
clip.read_ahead.as_deref(),
|
clip.read_ahead.as_deref(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Looping case: need to handle wrap-around at loop boundaries
|
// Looping case: handle wrap-around at loop boundaries
|
||||||
// Render in segments, one per loop iteration
|
let mut timeline_pos = render_start;
|
||||||
let mut timeline_pos = render_start_seconds;
|
|
||||||
let mut output_offset = output_start_offset;
|
let mut output_offset = output_start_offset;
|
||||||
|
|
||||||
while timeline_pos < render_end_seconds && output_offset < output.len() {
|
while timeline_pos < render_end && output_offset < output.len() {
|
||||||
// Calculate position within the loop
|
let relative_pos = timeline_pos - clip.external_start_secs(tempo_map);
|
||||||
let relative_pos = timeline_pos - clip.external_start;
|
let loop_offset = relative_pos.0 % internal_duration.0;
|
||||||
let loop_offset = relative_pos % internal_duration;
|
let content_pos = clip.internal_start + Seconds(loop_offset);
|
||||||
let content_pos = clip.internal_start + loop_offset;
|
|
||||||
|
|
||||||
// Calculate how much we can render before hitting the loop boundary
|
let time_to_loop_end = Seconds(internal_duration.0 - loop_offset);
|
||||||
let time_to_loop_end = internal_duration - loop_offset;
|
let time_to_render_end = render_end - timeline_pos;
|
||||||
let time_to_render_end = render_end_seconds - timeline_pos;
|
|
||||||
let chunk_duration = time_to_loop_end.min(time_to_render_end);
|
let chunk_duration = time_to_loop_end.min(time_to_render_end);
|
||||||
|
|
||||||
let chunk_samples = (chunk_duration * samples_per_second) as usize;
|
let chunk_samples = (chunk_duration.0 * samples_per_second) as usize;
|
||||||
let chunk_samples = chunk_samples.min(output.len() - output_offset);
|
let chunk_samples = chunk_samples.min(output.len() - output_offset);
|
||||||
|
|
||||||
if chunk_samples == 0 {
|
if chunk_samples == 0 {
|
||||||
|
|
@ -1262,7 +1295,7 @@ impl AudioTrack {
|
||||||
|
|
||||||
total_rendered += rendered;
|
total_rendered += rendered;
|
||||||
output_offset += chunk_samples;
|
output_offset += chunk_samples;
|
||||||
timeline_pos += chunk_duration;
|
timeline_pos = timeline_pos + chunk_duration;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use crate::audio::midi::MidiEvent;
|
||||||
use crate::audio::buffer_pool::BufferPoolStats;
|
use crate::audio::buffer_pool::BufferPoolStats;
|
||||||
use crate::audio::node_graph::nodes::LoopMode;
|
use crate::audio::node_graph::nodes::LoopMode;
|
||||||
use crate::io::WaveformPeak;
|
use crate::io::WaveformPeak;
|
||||||
|
use crate::time::{Beats, Seconds};
|
||||||
|
|
||||||
/// Commands sent from UI/control thread to audio thread
|
/// Commands sent from UI/control thread to audio thread
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -113,7 +114,7 @@ pub enum Command {
|
||||||
|
|
||||||
// Recording commands
|
// Recording commands
|
||||||
/// Start recording on a track (track_id, start_time)
|
/// Start recording on a track (track_id, start_time)
|
||||||
StartRecording(TrackId, f64),
|
StartRecording(TrackId, Beats),
|
||||||
/// Stop the current recording
|
/// Stop the current recording
|
||||||
StopRecording,
|
StopRecording,
|
||||||
/// Pause the current recording
|
/// Pause the current recording
|
||||||
|
|
@ -123,7 +124,7 @@ pub enum Command {
|
||||||
|
|
||||||
// MIDI Recording commands
|
// MIDI Recording commands
|
||||||
/// Start MIDI recording on a track (track_id, clip_id, start_time)
|
/// Start MIDI recording on a track (track_id, clip_id, start_time)
|
||||||
StartMidiRecording(TrackId, MidiClipId, f64),
|
StartMidiRecording(TrackId, MidiClipId, Beats),
|
||||||
/// Stop the current MIDI recording
|
/// Stop the current MIDI recording
|
||||||
StopMidiRecording,
|
StopMidiRecording,
|
||||||
|
|
||||||
|
|
@ -144,10 +145,8 @@ pub enum Command {
|
||||||
SetMetronomeEnabled(bool),
|
SetMetronomeEnabled(bool),
|
||||||
/// Set project tempo and time signature (bpm, (numerator, denominator))
|
/// Set project tempo and time signature (bpm, (numerator, denominator))
|
||||||
SetTempo(f32, (u32, u32)),
|
SetTempo(f32, (u32, u32)),
|
||||||
/// After a BPM change: update MIDI clip durations and sync all clip beats/frames from seconds.
|
/// Replace the entire tempo map (multi-entry variable tempo support)
|
||||||
/// (bpm, fps, midi_durations: Vec<(clip_id, new_duration_seconds)>)
|
SetTempoMap(crate::TempoMap),
|
||||||
ApplyBpmChange(f64, f64, Vec<(MidiClipId, f64)>),
|
|
||||||
|
|
||||||
// Node graph commands
|
// Node graph commands
|
||||||
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
|
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
|
||||||
GraphAddNode(TrackId, String, f32, f32),
|
GraphAddNode(TrackId, String, f32, f32),
|
||||||
|
|
@ -289,7 +288,7 @@ pub enum AudioEvent {
|
||||||
/// Recording started (track_id, clip_id, sample_rate, channels)
|
/// Recording started (track_id, clip_id, sample_rate, channels)
|
||||||
RecordingStarted(TrackId, ClipId, u32, u32),
|
RecordingStarted(TrackId, ClipId, u32, u32),
|
||||||
/// Recording progress update (clip_id, current_duration)
|
/// Recording progress update (clip_id, current_duration)
|
||||||
RecordingProgress(ClipId, f64),
|
RecordingProgress(ClipId, Seconds),
|
||||||
/// Recording stopped (clip_id, pool_index, waveform)
|
/// Recording stopped (clip_id, pool_index, waveform)
|
||||||
RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
|
RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
|
||||||
/// Recording error (error_message)
|
/// Recording error (error_message)
|
||||||
|
|
@ -297,8 +296,8 @@ pub enum AudioEvent {
|
||||||
/// MIDI recording stopped (track_id, clip_id, note_count)
|
/// MIDI recording stopped (track_id, clip_id, note_count)
|
||||||
MidiRecordingStopped(TrackId, MidiClipId, usize),
|
MidiRecordingStopped(TrackId, MidiClipId, usize),
|
||||||
/// MIDI recording progress (track_id, clip_id, duration, notes)
|
/// MIDI recording progress (track_id, clip_id, duration, notes)
|
||||||
/// Notes format: (start_time, note, velocity, duration)
|
/// Notes format: (start_time, note, velocity, duration) — all times in beats
|
||||||
MidiRecordingProgress(TrackId, MidiClipId, f64, Vec<(f64, u8, u8, f64)>),
|
MidiRecordingProgress(TrackId, MidiClipId, Beats, Vec<(Beats, u8, u8, Beats)>),
|
||||||
/// Project has been reset
|
/// Project has been reset
|
||||||
ProjectReset,
|
ProjectReset,
|
||||||
/// MIDI note started playing (note, velocity)
|
/// MIDI note started playing (note, velocity)
|
||||||
|
|
|
||||||
|
|
@ -1,164 +1,69 @@
|
||||||
use crate::audio::midi::{MidiClip, MidiClipId, MidiEvent};
|
use crate::audio::midi::{MidiClip, MidiClipId, MidiEvent};
|
||||||
|
use crate::time::Beats;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Load a MIDI file and convert it to a MidiClip
|
/// Load a MIDI file and convert it to a MidiClip.
|
||||||
|
///
|
||||||
|
/// Event timestamps are stored as beat positions: `tick / ticks_per_beat`.
|
||||||
|
/// Tempo events in the MIDI file only affect wall-clock playback speed — not the
|
||||||
|
/// beat grid — so they are ignored here.
|
||||||
pub fn load_midi_file<P: AsRef<Path>>(
|
pub fn load_midi_file<P: AsRef<Path>>(
|
||||||
path: P,
|
path: P,
|
||||||
clip_id: MidiClipId,
|
clip_id: MidiClipId,
|
||||||
_sample_rate: u32,
|
_sample_rate: u32,
|
||||||
) -> Result<MidiClip, String> {
|
) -> Result<MidiClip, String> {
|
||||||
// Read the MIDI file
|
|
||||||
let data = fs::read(path.as_ref()).map_err(|e| format!("Failed to read MIDI file: {}", e))?;
|
let data = fs::read(path.as_ref()).map_err(|e| format!("Failed to read MIDI file: {}", e))?;
|
||||||
|
|
||||||
// Parse with midly
|
|
||||||
let smf = midly::Smf::parse(&data).map_err(|e| format!("Failed to parse MIDI file: {}", e))?;
|
let smf = midly::Smf::parse(&data).map_err(|e| format!("Failed to parse MIDI file: {}", e))?;
|
||||||
|
|
||||||
// Convert timing to ticks per second
|
|
||||||
let ticks_per_beat = match smf.header.timing {
|
let ticks_per_beat = match smf.header.timing {
|
||||||
midly::Timing::Metrical(tpb) => tpb.as_int() as f64,
|
midly::Timing::Metrical(tpb) => tpb.as_int() as f64,
|
||||||
midly::Timing::Timecode(fps, subframe) => {
|
midly::Timing::Timecode(fps, subframe) => {
|
||||||
// For timecode, calculate equivalent ticks per second
|
// Timecode-based MIDI: treat subframes as ticks per beat
|
||||||
(fps.as_f32() * subframe as f32) as f64
|
(fps.as_f32() * subframe as f32) as f64
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// First pass: collect all events with their tick positions and tempo changes
|
let mut events = Vec::new();
|
||||||
#[derive(Debug)]
|
let mut max_tick = 0u64;
|
||||||
enum RawEvent {
|
|
||||||
Midi {
|
|
||||||
tick: u64,
|
|
||||||
channel: u8,
|
|
||||||
message: midly::MidiMessage,
|
|
||||||
},
|
|
||||||
Tempo {
|
|
||||||
tick: u64,
|
|
||||||
microseconds_per_beat: f64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut raw_events = Vec::new();
|
|
||||||
let mut max_time_ticks = 0u64;
|
|
||||||
|
|
||||||
// Collect all events from all tracks with their absolute tick positions
|
|
||||||
for track in &smf.tracks {
|
for track in &smf.tracks {
|
||||||
let mut current_tick = 0u64;
|
let mut current_tick = 0u64;
|
||||||
|
|
||||||
for event in track {
|
for event in track {
|
||||||
current_tick += event.delta.as_int() as u64;
|
current_tick += event.delta.as_int() as u64;
|
||||||
max_time_ticks = max_time_ticks.max(current_tick);
|
max_tick = max_tick.max(current_tick);
|
||||||
|
|
||||||
|
let timestamp = Beats(current_tick as f64 / ticks_per_beat);
|
||||||
|
|
||||||
match event.kind {
|
match event.kind {
|
||||||
midly::TrackEventKind::Midi { channel, message } => {
|
midly::TrackEventKind::Midi { channel, message } => {
|
||||||
raw_events.push(RawEvent::Midi {
|
let ch = channel.as_int();
|
||||||
tick: current_tick,
|
|
||||||
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 {
|
match message {
|
||||||
midly::MidiMessage::NoteOn { key, vel } => {
|
midly::MidiMessage::NoteOn { key, vel } => {
|
||||||
let velocity = vel.as_int();
|
let velocity = vel.as_int();
|
||||||
if velocity > 0 {
|
if velocity > 0 {
|
||||||
events.push(MidiEvent::note_on(
|
events.push(MidiEvent::note_on(timestamp, ch, key.as_int(), velocity));
|
||||||
timestamp,
|
|
||||||
channel,
|
|
||||||
key.as_int(),
|
|
||||||
velocity,
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
events.push(MidiEvent::note_off(timestamp, channel, key.as_int(), 64));
|
events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), 64));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
midly::MidiMessage::NoteOff { key, vel } => {
|
midly::MidiMessage::NoteOff { key, vel } => {
|
||||||
events.push(MidiEvent::note_off(
|
events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), vel.as_int()));
|
||||||
timestamp,
|
|
||||||
channel,
|
|
||||||
key.as_int(),
|
|
||||||
vel.as_int(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
midly::MidiMessage::Controller { controller, value } => {
|
midly::MidiMessage::Controller { controller, value } => {
|
||||||
let status = 0xB0 | channel;
|
let status = 0xB0 | ch;
|
||||||
events.push(MidiEvent::new(
|
events.push(MidiEvent::new(timestamp, status, controller.as_int(), value.as_int()));
|
||||||
timestamp,
|
|
||||||
status,
|
|
||||||
controller.as_int(),
|
|
||||||
value.as_int(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {}
|
||||||
// Ignore other MIDI messages
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ => {} // Tempo and other meta events don't affect beat positions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate final clip duration
|
let duration = Beats(max_tick as f64 / ticks_per_beat);
|
||||||
let final_delta_ticks = max_time_ticks - last_tick;
|
let clip = MidiClip::new(clip_id, events, duration, "Imported MIDI".to_string());
|
||||||
let final_delta_time =
|
|
||||||
(final_delta_ticks as f64 / ticks_per_beat) * (microseconds_per_beat / 1_000_000.0);
|
|
||||||
let duration_seconds = accumulated_time + final_delta_time;
|
|
||||||
|
|
||||||
// Create the MIDI clip (content only, positioning happens when creating instance)
|
|
||||||
let clip = MidiClip::new(clip_id, events, duration_seconds, "Imported MIDI".to_string());
|
|
||||||
|
|
||||||
Ok(clip)
|
Ok(clip)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
|
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
|
pub mod time;
|
||||||
|
pub mod tempo_map;
|
||||||
pub mod dsp;
|
pub mod dsp;
|
||||||
pub mod effects;
|
pub mod effects;
|
||||||
pub mod io;
|
pub mod io;
|
||||||
|
|
@ -18,6 +20,8 @@ pub use audio::{
|
||||||
TrackNode,
|
TrackNode,
|
||||||
};
|
};
|
||||||
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
|
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
|
||||||
|
pub use time::{Beats, Seconds};
|
||||||
|
pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
|
||||||
pub use command::{AudioEvent, Command, OscilloscopeData};
|
pub use command::{AudioEvent, Command, OscilloscopeData};
|
||||||
pub use command::types::AutomationKeyframeData;
|
pub use command::types::AutomationKeyframeData;
|
||||||
pub use io::{load_midi_file, AudioFile, WaveformChunk, WaveformChunkKey, WaveformPeak, WavWriter};
|
pub use io::{load_midi_file, AudioFile, WaveformChunk, WaveformChunkKey, WaveformPeak, WavWriter};
|
||||||
|
|
@ -90,7 +94,7 @@ impl AudioSystem {
|
||||||
// Create input ringbuffer for recording (large buffer for audio samples)
|
// Create input ringbuffer for recording (large buffer for audio samples)
|
||||||
// Buffer size: 10 seconds of audio at 48kHz stereo = 48000 * 2 * 10 = 960000 samples
|
// Buffer size: 10 seconds of audio at 48kHz stereo = 48000 * 2 * 10 = 960000 samples
|
||||||
let input_buffer_size = (sample_rate * channels * 10) as usize;
|
let input_buffer_size = (sample_rate * channels * 10) as usize;
|
||||||
let (mut input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size);
|
let (input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size);
|
||||||
|
|
||||||
// Create mirror ringbuffer for streaming recorded audio to UI (live waveform)
|
// Create mirror ringbuffer for streaming recorded audio to UI (live waveform)
|
||||||
let (mirror_tx, mirror_rx) = rtrb::RingBuffer::new(input_buffer_size);
|
let (mirror_tx, mirror_rx) = rtrb::RingBuffer::new(input_buffer_size);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
for event in &midi_clip.events {
|
||||||
let status = event.status & 0xF0;
|
let status = event.status & 0xF0;
|
||||||
let time_seconds = event.timestamp as f64 / sample_rate;
|
let time_seconds = event.timestamp.beats_to_f64() / sample_rate;
|
||||||
|
|
||||||
match status {
|
match status {
|
||||||
0x90 if event.data2 > 0 => {
|
0x90 if event.data2 > 0 => {
|
||||||
|
|
@ -878,7 +878,7 @@ fn execute_command(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to local UI state with note data
|
// Add to local UI state with note data
|
||||||
app.add_clip(track_id, clip_id, start_time, duration, file_path.to_string(), notes);
|
app.add_clip(track_id, clip_id, start_time, duration.beats_to_f64(), file_path.to_string(), notes);
|
||||||
app.next_clip_id += 1;
|
app.next_clip_id += 1;
|
||||||
|
|
||||||
// Send to audio engine with the start_time (clip content is separate from timeline position)
|
// Send to audio engine with the start_time (clip content is separate from timeline position)
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,6 @@ impl Action for AddClipInstanceAction {
|
||||||
if let Some(valid_start) = adjusted_start {
|
if let Some(valid_start) = adjusted_start {
|
||||||
// Update instance to use the valid position
|
// Update instance to use the valid position
|
||||||
self.clip_instance.timeline_start = valid_start;
|
self.clip_instance.timeline_start = valid_start;
|
||||||
let (bpm, fps) = (document.bpm, document.framerate);
|
|
||||||
self.clip_instance.sync_from_seconds(bpm, fps);
|
|
||||||
} else {
|
} else {
|
||||||
// No valid position found - reject the operation
|
// No valid position found - reject the operation
|
||||||
return Err("Cannot add clip: no valid position found on layer (layer is full)".to_string());
|
return Err("Cannot add clip: no valid position found on layer (layer is full)".to_string());
|
||||||
|
|
@ -210,10 +208,10 @@ impl Action for AddClipInstanceAction {
|
||||||
let instance = daw_backend::MidiClipInstance::new(
|
let instance = daw_backend::MidiClipInstance::new(
|
||||||
0, // Instance ID will be assigned by backend
|
0, // Instance ID will be assigned by backend
|
||||||
*midi_clip_id,
|
*midi_clip_id,
|
||||||
internal_start,
|
daw_backend::Beats(internal_start),
|
||||||
internal_end,
|
daw_backend::Beats(internal_end),
|
||||||
external_start,
|
daw_backend::Beats(external_start),
|
||||||
external_duration,
|
daw_backend::Beats(external_duration),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send query to add instance and get instance ID
|
// Send query to add instance and get instance ID
|
||||||
|
|
|
||||||
|
|
@ -1,248 +1,47 @@
|
||||||
//! Change BPM action
|
//! Change BPM / Tempo action
|
||||||
//!
|
//!
|
||||||
//! Atomically changes the document BPM and rescales all clip instance positions and
|
//! Atomically swaps the document `TempoMap`, sending the new map to the engine.
|
||||||
//! MIDI event timestamps so that beat positions are preserved (Measures mode behaviour).
|
//! All clip and note positions are stored in **beats** so no position rescaling
|
||||||
|
//! is required — only the TempoMap entry changes.
|
||||||
|
|
||||||
use crate::action::{Action, BackendContext};
|
use crate::action::{Action, BackendContext};
|
||||||
use crate::clip::ClipInstance;
|
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::layer::AnyLayer;
|
use crate::tempo_map::TempoMap;
|
||||||
use std::collections::HashMap;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Snapshot of all timing fields on a `ClipInstance`
|
/// Action that changes the global BPM by replacing the tempo map.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TimingFields {
|
|
||||||
timeline_start: f64,
|
|
||||||
timeline_start_beats: f64,
|
|
||||||
timeline_start_frames: f64,
|
|
||||||
trim_start: f64,
|
|
||||||
trim_start_beats: f64,
|
|
||||||
trim_start_frames: f64,
|
|
||||||
trim_end: Option<f64>,
|
|
||||||
trim_end_beats: Option<f64>,
|
|
||||||
trim_end_frames: Option<f64>,
|
|
||||||
timeline_duration: Option<f64>,
|
|
||||||
timeline_duration_beats: Option<f64>,
|
|
||||||
timeline_duration_frames: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TimingFields {
|
|
||||||
fn from_instance(ci: &ClipInstance) -> Self {
|
|
||||||
Self {
|
|
||||||
timeline_start: ci.timeline_start,
|
|
||||||
timeline_start_beats: ci.timeline_start_beats,
|
|
||||||
timeline_start_frames: ci.timeline_start_frames,
|
|
||||||
trim_start: ci.trim_start,
|
|
||||||
trim_start_beats: ci.trim_start_beats,
|
|
||||||
trim_start_frames: ci.trim_start_frames,
|
|
||||||
trim_end: ci.trim_end,
|
|
||||||
trim_end_beats: ci.trim_end_beats,
|
|
||||||
trim_end_frames: ci.trim_end_frames,
|
|
||||||
timeline_duration: ci.timeline_duration,
|
|
||||||
timeline_duration_beats: ci.timeline_duration_beats,
|
|
||||||
timeline_duration_frames: ci.timeline_duration_frames,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_to(&self, ci: &mut ClipInstance) {
|
|
||||||
ci.timeline_start = self.timeline_start;
|
|
||||||
ci.timeline_start_beats = self.timeline_start_beats;
|
|
||||||
ci.timeline_start_frames = self.timeline_start_frames;
|
|
||||||
ci.trim_start = self.trim_start;
|
|
||||||
ci.trim_start_beats = self.trim_start_beats;
|
|
||||||
ci.trim_start_frames = self.trim_start_frames;
|
|
||||||
ci.trim_end = self.trim_end;
|
|
||||||
ci.trim_end_beats = self.trim_end_beats;
|
|
||||||
ci.trim_end_frames = self.trim_end_frames;
|
|
||||||
ci.timeline_duration = self.timeline_duration;
|
|
||||||
ci.timeline_duration_beats = self.timeline_duration_beats;
|
|
||||||
ci.timeline_duration_frames = self.timeline_duration_frames;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct ClipTimingSnapshot {
|
|
||||||
layer_id: Uuid,
|
|
||||||
instance_id: Uuid,
|
|
||||||
old_fields: TimingFields,
|
|
||||||
new_fields: TimingFields,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct MidiClipSnapshot {
|
|
||||||
layer_id: Uuid,
|
|
||||||
midi_clip_id: u32,
|
|
||||||
clip_id: Uuid,
|
|
||||||
old_clip_duration: f64,
|
|
||||||
new_clip_duration: f64,
|
|
||||||
old_events: Vec<daw_backend::audio::midi::MidiEvent>,
|
|
||||||
new_events: Vec<daw_backend::audio::midi::MidiEvent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action that atomically changes BPM and rescales all clip/note positions to preserve beats
|
|
||||||
pub struct ChangeBpmAction {
|
pub struct ChangeBpmAction {
|
||||||
old_bpm: f64,
|
old_map: TempoMap,
|
||||||
new_bpm: f64,
|
new_map: TempoMap,
|
||||||
time_sig: (u32, u32),
|
|
||||||
clip_snapshots: Vec<ClipTimingSnapshot>,
|
|
||||||
midi_snapshots: Vec<MidiClipSnapshot>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChangeBpmAction {
|
impl ChangeBpmAction {
|
||||||
/// Build the action, computing new positions for all clip instances and MIDI events.
|
/// Build the action from the current document state and a desired new BPM.
|
||||||
///
|
pub fn new(new_bpm: f64, document: &Document) -> Self {
|
||||||
/// `midi_event_cache` maps backend MIDI clip ID → current event list.
|
let old_map = document.tempo_map().clone();
|
||||||
pub fn new(
|
let mut new_map = old_map.clone();
|
||||||
old_bpm: f64,
|
new_map.set_global_bpm(new_bpm);
|
||||||
new_bpm: f64,
|
Self { old_map, new_map }
|
||||||
document: &Document,
|
|
||||||
midi_event_cache: &HashMap<u32, Vec<daw_backend::audio::midi::MidiEvent>>,
|
|
||||||
) -> Self {
|
|
||||||
let fps = document.framerate;
|
|
||||||
let time_sig = (
|
|
||||||
document.time_signature.numerator,
|
|
||||||
document.time_signature.denominator,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut clip_snapshots: Vec<ClipTimingSnapshot> = Vec::new();
|
|
||||||
let mut midi_snapshots: Vec<MidiClipSnapshot> = Vec::new();
|
|
||||||
|
|
||||||
// Collect MIDI clip IDs we've already snapshotted (avoid duplicates)
|
|
||||||
let mut seen_midi_clips: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
for layer in document.all_layers() {
|
|
||||||
let layer_id = layer.id();
|
|
||||||
|
|
||||||
let clip_instances: &[ClipInstance] = match layer {
|
|
||||||
AnyLayer::Vector(vl) => &vl.clip_instances,
|
|
||||||
AnyLayer::Audio(al) => &al.clip_instances,
|
|
||||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
|
||||||
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
for ci in clip_instances {
|
|
||||||
let old_fields = TimingFields::from_instance(ci);
|
|
||||||
|
|
||||||
// Compute new fields: beats are canonical, recompute seconds + frames.
|
|
||||||
// Guard: if timeline_start_beats was never populated (clips added without
|
|
||||||
// sync_from_seconds), derive beats from seconds before applying.
|
|
||||||
let mut new_ci = ci.clone();
|
|
||||||
if new_ci.timeline_start_beats == 0.0 && new_ci.timeline_start.abs() > 1e-9 {
|
|
||||||
new_ci.sync_from_seconds(old_bpm, fps);
|
|
||||||
}
|
|
||||||
new_ci.apply_beats(new_bpm, fps);
|
|
||||||
let new_fields = TimingFields::from_instance(&new_ci);
|
|
||||||
|
|
||||||
clip_snapshots.push(ClipTimingSnapshot {
|
|
||||||
layer_id,
|
|
||||||
instance_id: ci.id,
|
|
||||||
old_fields,
|
|
||||||
new_fields,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If this is a MIDI clip on an audio layer, collect MIDI events + rescale duration.
|
|
||||||
// Always snapshot the clip (even if empty) so clip.duration is rescaled.
|
|
||||||
if let AnyLayer::Audio(_) = layer {
|
|
||||||
if let Some(audio_clip) = document.get_audio_clip(&ci.clip_id) {
|
|
||||||
use crate::clip::AudioClipType;
|
|
||||||
if let AudioClipType::Midi { midi_clip_id } = &audio_clip.clip_type {
|
|
||||||
let midi_id = *midi_clip_id;
|
|
||||||
if !seen_midi_clips.contains(&midi_id) {
|
|
||||||
seen_midi_clips.insert(midi_id);
|
|
||||||
|
|
||||||
let old_clip_duration = audio_clip.duration;
|
|
||||||
let new_clip_duration = old_clip_duration * old_bpm / new_bpm;
|
|
||||||
|
|
||||||
// Use cached events if present; empty vec for clips with no events yet.
|
|
||||||
let old_events = midi_event_cache.get(&midi_id).cloned().unwrap_or_default();
|
|
||||||
let new_events: Vec<_> = old_events.iter().map(|ev| {
|
|
||||||
let mut e = ev.clone();
|
|
||||||
// Ensure beats are populated before using them as canonical.
|
|
||||||
// Events created before triple-rep (e.g. from recording)
|
|
||||||
// have timestamp_beats == 0.0 — sync from seconds first.
|
|
||||||
if e.timestamp_beats == 0.0 && e.timestamp.abs() > 1e-9 {
|
|
||||||
e.sync_from_seconds(old_bpm, fps);
|
|
||||||
}
|
|
||||||
e.apply_beats(new_bpm, fps);
|
|
||||||
e
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
midi_snapshots.push(MidiClipSnapshot {
|
|
||||||
layer_id,
|
|
||||||
midi_clip_id: midi_id,
|
|
||||||
clip_id: ci.clip_id,
|
|
||||||
old_clip_duration,
|
|
||||||
new_clip_duration,
|
|
||||||
old_events,
|
|
||||||
new_events,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
/// Build from explicit old/new maps (used when the map already changed in-place,
|
||||||
old_bpm,
|
/// e.g., after live drag preview; the caller provides start and end states).
|
||||||
new_bpm,
|
pub fn from_maps(old_map: TempoMap, new_map: TempoMap) -> Self {
|
||||||
time_sig,
|
Self { old_map, new_map }
|
||||||
clip_snapshots,
|
|
||||||
midi_snapshots,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the new MIDI event lists for each affected clip (for immediate cache update).
|
pub fn new_bpm(&self) -> f64 { self.new_map.global_bpm() }
|
||||||
pub fn new_midi_events(&self) -> impl Iterator<Item = (u32, &Vec<daw_backend::audio::midi::MidiEvent>)> {
|
pub fn old_bpm(&self) -> f64 { self.old_map.global_bpm() }
|
||||||
self.midi_snapshots.iter().map(|s| (s.midi_clip_id, &s.new_events))
|
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Action for ChangeBpmAction {
|
impl Action for ChangeBpmAction {
|
||||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
document.bpm = self.new_bpm;
|
*document.tempo_map_mut() = self.new_map.clone();
|
||||||
Self::apply_clips(document, &self.clip_snapshots, true);
|
|
||||||
Self::apply_midi_durations(document, &self.midi_snapshots, true);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
document.bpm = self.old_bpm;
|
*document.tempo_map_mut() = self.old_map.clone();
|
||||||
Self::apply_clips(document, &self.clip_snapshots, false);
|
|
||||||
Self::apply_midi_durations(document, &self.midi_snapshots, false);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,109 +52,26 @@ impl Action for ChangeBpmAction {
|
||||||
fn execute_backend(
|
fn execute_backend(
|
||||||
&mut self,
|
&mut self,
|
||||||
backend: &mut BackendContext,
|
backend: &mut BackendContext,
|
||||||
document: &Document,
|
_document: &Document,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
controller.set_tempo_map(self.new_map.clone());
|
||||||
// Update tempo
|
|
||||||
controller.set_tempo(self.new_bpm as f32, self.time_sig);
|
|
||||||
|
|
||||||
// Update MIDI clip events and positions
|
|
||||||
for snap in &self.midi_snapshots {
|
|
||||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
|
||||||
Some(&id) => id,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
controller.update_midi_clip_events(track_id, snap.midi_clip_id, snap.new_events.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move clip instances in the backend
|
|
||||||
for snap in &self.clip_snapshots {
|
|
||||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
|
||||||
Some(&id) => id,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
|
|
||||||
match backend_id {
|
|
||||||
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
|
|
||||||
controller.move_clip(track_id, *audio_id, snap.new_fields.timeline_start);
|
|
||||||
}
|
|
||||||
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
|
|
||||||
controller.move_clip(track_id, *midi_id, snap.new_fields.timeline_start);
|
|
||||||
}
|
|
||||||
None => {} // Vector/video clips — no backend move needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync beat/frame representations and rescale MIDI clip durations in the backend
|
|
||||||
let fps = document.framerate;
|
|
||||||
let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter()
|
|
||||||
.map(|s| (s.midi_clip_id, s.new_clip_duration))
|
|
||||||
.collect();
|
|
||||||
controller.apply_bpm_change(self.new_bpm, fps, midi_durations);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rollback_backend(
|
fn rollback_backend(
|
||||||
&mut self,
|
&mut self,
|
||||||
backend: &mut BackendContext,
|
backend: &mut BackendContext,
|
||||||
document: &Document,
|
_document: &Document,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
controller.set_tempo_map(self.old_map.clone());
|
||||||
controller.set_tempo(self.old_bpm as f32, self.time_sig);
|
|
||||||
|
|
||||||
for snap in &self.midi_snapshots {
|
|
||||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
|
||||||
Some(&id) => id,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
controller.update_midi_clip_events(track_id, snap.midi_clip_id, snap.old_events.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
for snap in &self.clip_snapshots {
|
|
||||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
|
||||||
Some(&id) => id,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
|
|
||||||
match backend_id {
|
|
||||||
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
|
|
||||||
controller.move_clip(track_id, *audio_id, snap.old_fields.timeline_start);
|
|
||||||
}
|
|
||||||
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
|
|
||||||
controller.move_clip(track_id, *midi_id, snap.old_fields.timeline_start);
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync beat/frame representations and restore MIDI clip durations in the backend
|
|
||||||
let fps = document.framerate;
|
|
||||||
let midi_durations: Vec<(u32, f64)> = self.midi_snapshots.iter()
|
|
||||||
.map(|s| (s.midi_clip_id, s.old_clip_duration))
|
|
||||||
.collect();
|
|
||||||
controller.apply_bpm_change(self.old_bpm, fps, midi_durations);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn all_midi_events_after_execute(&self) -> Vec<(u32, Vec<daw_backend::audio::midi::MidiEvent>)> {
|
|
||||||
self.midi_snapshots.iter()
|
|
||||||
.map(|s| (s.midi_clip_id, s.new_events.clone()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn all_midi_events_after_rollback(&self) -> Vec<(u32, Vec<daw_backend::audio::midi::MidiEvent>)> {
|
|
||||||
self.midi_snapshots.iter()
|
|
||||||
.map(|s| (s.midi_clip_id, s.old_events.clone()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,156 +1,33 @@
|
||||||
//! Change FPS action
|
//! Change FPS action
|
||||||
//!
|
//!
|
||||||
//! Atomically changes the document framerate and rescales all clip instance positions
|
//! Atomically changes the document framerate.
|
||||||
//! so that frame positions are preserved (Frames mode behaviour).
|
//! All clip positions are stored in **beats** so no position rescaling is
|
||||||
|
//! required — only the framerate field changes.
|
||||||
|
|
||||||
use crate::action::{Action, BackendContext};
|
use crate::action::{Action, BackendContext};
|
||||||
use crate::clip::ClipInstance;
|
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::layer::AnyLayer;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Snapshot of all timing fields on a `ClipInstance`
|
/// Action that changes the document framerate.
|
||||||
#[derive(Clone)]
|
|
||||||
struct TimingFields {
|
|
||||||
timeline_start: f64,
|
|
||||||
timeline_start_beats: f64,
|
|
||||||
timeline_start_frames: f64,
|
|
||||||
trim_start: f64,
|
|
||||||
trim_start_beats: f64,
|
|
||||||
trim_start_frames: f64,
|
|
||||||
trim_end: Option<f64>,
|
|
||||||
trim_end_beats: Option<f64>,
|
|
||||||
trim_end_frames: Option<f64>,
|
|
||||||
timeline_duration: Option<f64>,
|
|
||||||
timeline_duration_beats: Option<f64>,
|
|
||||||
timeline_duration_frames: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TimingFields {
|
|
||||||
fn from_instance(ci: &ClipInstance) -> Self {
|
|
||||||
Self {
|
|
||||||
timeline_start: ci.timeline_start,
|
|
||||||
timeline_start_beats: ci.timeline_start_beats,
|
|
||||||
timeline_start_frames: ci.timeline_start_frames,
|
|
||||||
trim_start: ci.trim_start,
|
|
||||||
trim_start_beats: ci.trim_start_beats,
|
|
||||||
trim_start_frames: ci.trim_start_frames,
|
|
||||||
trim_end: ci.trim_end,
|
|
||||||
trim_end_beats: ci.trim_end_beats,
|
|
||||||
trim_end_frames: ci.trim_end_frames,
|
|
||||||
timeline_duration: ci.timeline_duration,
|
|
||||||
timeline_duration_beats: ci.timeline_duration_beats,
|
|
||||||
timeline_duration_frames: ci.timeline_duration_frames,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_to(&self, ci: &mut ClipInstance) {
|
|
||||||
ci.timeline_start = self.timeline_start;
|
|
||||||
ci.timeline_start_beats = self.timeline_start_beats;
|
|
||||||
ci.timeline_start_frames = self.timeline_start_frames;
|
|
||||||
ci.trim_start = self.trim_start;
|
|
||||||
ci.trim_start_beats = self.trim_start_beats;
|
|
||||||
ci.trim_start_frames = self.trim_start_frames;
|
|
||||||
ci.trim_end = self.trim_end;
|
|
||||||
ci.trim_end_beats = self.trim_end_beats;
|
|
||||||
ci.trim_end_frames = self.trim_end_frames;
|
|
||||||
ci.timeline_duration = self.timeline_duration;
|
|
||||||
ci.timeline_duration_beats = self.timeline_duration_beats;
|
|
||||||
ci.timeline_duration_frames = self.timeline_duration_frames;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct ClipTimingSnapshot {
|
|
||||||
layer_id: Uuid,
|
|
||||||
instance_id: Uuid,
|
|
||||||
old_fields: TimingFields,
|
|
||||||
new_fields: TimingFields,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action that atomically changes framerate and rescales all clip positions to preserve frames
|
|
||||||
pub struct ChangeFpsAction {
|
pub struct ChangeFpsAction {
|
||||||
old_fps: f64,
|
old_fps: f64,
|
||||||
new_fps: f64,
|
new_fps: f64,
|
||||||
clip_snapshots: Vec<ClipTimingSnapshot>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChangeFpsAction {
|
impl ChangeFpsAction {
|
||||||
/// Build the action, computing new positions for all clip instances.
|
/// Build the action from old and new framerates.
|
||||||
pub fn new(old_fps: f64, new_fps: f64, document: &Document) -> Self {
|
pub fn new(old_fps: f64, new_fps: f64, _document: &Document) -> Self {
|
||||||
let bpm = document.bpm;
|
Self { old_fps, new_fps }
|
||||||
|
|
||||||
let mut clip_snapshots: Vec<ClipTimingSnapshot> = Vec::new();
|
|
||||||
|
|
||||||
for layer in document.all_layers() {
|
|
||||||
let layer_id = layer.id();
|
|
||||||
|
|
||||||
let clip_instances: &[ClipInstance] = match layer {
|
|
||||||
AnyLayer::Vector(vl) => &vl.clip_instances,
|
|
||||||
AnyLayer::Audio(al) => &al.clip_instances,
|
|
||||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
|
||||||
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
for ci in clip_instances {
|
|
||||||
let old_fields = TimingFields::from_instance(ci);
|
|
||||||
|
|
||||||
// Compute new fields: frames are canonical, recompute seconds + beats
|
|
||||||
let mut new_ci = ci.clone();
|
|
||||||
new_ci.apply_frames(new_fps, bpm);
|
|
||||||
let new_fields = TimingFields::from_instance(&new_ci);
|
|
||||||
|
|
||||||
clip_snapshots.push(ClipTimingSnapshot {
|
|
||||||
layer_id,
|
|
||||||
instance_id: ci.id,
|
|
||||||
old_fields,
|
|
||||||
new_fields,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
old_fps,
|
|
||||||
new_fps,
|
|
||||||
clip_snapshots,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_clips(document: &mut Document, snapshots: &[ClipTimingSnapshot], use_new: bool) {
|
|
||||||
for snap in snapshots {
|
|
||||||
let fields = if use_new { &snap.new_fields } else { &snap.old_fields };
|
|
||||||
|
|
||||||
let layer = match document.get_layer_mut(&snap.layer_id) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
let clip_instances = match layer {
|
|
||||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
|
||||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
|
||||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
|
||||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
|
||||||
AnyLayer::Group(_) | AnyLayer::Raster(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(ci) = clip_instances.iter_mut().find(|ci| ci.id == snap.instance_id) {
|
|
||||||
fields.apply_to(ci);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Action for ChangeFpsAction {
|
impl Action for ChangeFpsAction {
|
||||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
document.framerate = self.new_fps;
|
document.framerate = self.new_fps;
|
||||||
Self::apply_clips(document, &self.clip_snapshots, true);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
document.framerate = self.old_fps;
|
document.framerate = self.old_fps;
|
||||||
Self::apply_clips(document, &self.clip_snapshots, false);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,68 +37,18 @@ impl Action for ChangeFpsAction {
|
||||||
|
|
||||||
fn execute_backend(
|
fn execute_backend(
|
||||||
&mut self,
|
&mut self,
|
||||||
backend: &mut BackendContext,
|
_backend: &mut BackendContext,
|
||||||
_document: &Document,
|
_document: &Document,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// FPS change does not affect audio timing — only move clips that changed position
|
// FPS does not affect audio scheduling — nothing to do in the backend.
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
for snap in &self.clip_snapshots {
|
|
||||||
if (snap.new_fields.timeline_start - snap.old_fields.timeline_start).abs() < 1e-9 {
|
|
||||||
continue; // No movement, skip
|
|
||||||
}
|
|
||||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
|
||||||
Some(&id) => id,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
|
|
||||||
match backend_id {
|
|
||||||
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
|
|
||||||
controller.move_clip(track_id, *audio_id, snap.new_fields.timeline_start);
|
|
||||||
}
|
|
||||||
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
|
|
||||||
controller.move_clip(track_id, *midi_id, snap.new_fields.timeline_start);
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rollback_backend(
|
fn rollback_backend(
|
||||||
&mut self,
|
&mut self,
|
||||||
backend: &mut BackendContext,
|
_backend: &mut BackendContext,
|
||||||
_document: &Document,
|
_document: &Document,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
for snap in &self.clip_snapshots {
|
|
||||||
if (snap.new_fields.timeline_start - snap.old_fields.timeline_start).abs() < 1e-9 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let track_id = match backend.layer_to_track_map.get(&snap.layer_id) {
|
|
||||||
Some(&id) => id,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let backend_id = backend.clip_instance_to_backend_map.get(&snap.instance_id);
|
|
||||||
match backend_id {
|
|
||||||
Some(crate::action::BackendClipInstanceId::Audio(audio_id)) => {
|
|
||||||
controller.move_clip(track_id, *audio_id, snap.old_fields.timeline_start);
|
|
||||||
}
|
|
||||||
Some(crate::action::BackendClipInstanceId::Midi(midi_id)) => {
|
|
||||||
controller.move_clip(track_id, *midi_id, snap.old_fields.timeline_start);
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,6 @@ impl Action for MoveClipInstancesAction {
|
||||||
// Store adjusted moves for rollback
|
// Store adjusted moves for rollback
|
||||||
self.layer_moves = adjusted_moves.clone();
|
self.layer_moves = adjusted_moves.clone();
|
||||||
|
|
||||||
let bpm = document.bpm;
|
|
||||||
let fps = document.framerate;
|
|
||||||
|
|
||||||
// Apply all adjusted moves
|
// Apply all adjusted moves
|
||||||
for (layer_id, moves) in &adjusted_moves {
|
for (layer_id, moves) in &adjusted_moves {
|
||||||
let layer = document.get_layer_mut(layer_id)
|
let layer = document.get_layer_mut(layer_id)
|
||||||
|
|
@ -142,7 +139,6 @@ impl Action for MoveClipInstancesAction {
|
||||||
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
|
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
|
||||||
{
|
{
|
||||||
clip_instance.timeline_start = *new;
|
clip_instance.timeline_start = *new;
|
||||||
clip_instance.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,8 +147,6 @@ impl Action for MoveClipInstancesAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
let bpm = document.bpm;
|
|
||||||
let fps = document.framerate;
|
|
||||||
for (layer_id, moves) in &self.layer_moves {
|
for (layer_id, moves) in &self.layer_moves {
|
||||||
let layer = document.get_layer_mut(layer_id)
|
let layer = document.get_layer_mut(layer_id)
|
||||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
||||||
|
|
@ -172,7 +166,6 @@ impl Action for MoveClipInstancesAction {
|
||||||
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
|
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
|
||||||
{
|
{
|
||||||
clip_instance.timeline_start = *old;
|
clip_instance.timeline_start = *old;
|
||||||
clip_instance.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,10 +177,10 @@ impl Action for RemoveClipInstancesAction {
|
||||||
let midi_instance = daw_backend::MidiClipInstance::new(
|
let midi_instance = daw_backend::MidiClipInstance::new(
|
||||||
0,
|
0,
|
||||||
*midi_clip_id,
|
*midi_clip_id,
|
||||||
internal_start,
|
daw_backend::Beats(internal_start),
|
||||||
internal_end,
|
daw_backend::Beats(internal_end),
|
||||||
external_start,
|
daw_backend::Beats(external_start),
|
||||||
external_duration,
|
daw_backend::Beats(external_duration),
|
||||||
);
|
);
|
||||||
|
|
||||||
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
|
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
|
||||||
|
|
|
||||||
|
|
@ -126,8 +126,8 @@ impl Action for SplitClipInstanceAction {
|
||||||
.get_clip_duration(&instance.clip_id)
|
.get_clip_duration(&instance.clip_id)
|
||||||
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
||||||
|
|
||||||
// Calculate the effective duration and timeline end
|
// Calculate the effective duration and timeline end (both in beats)
|
||||||
let effective_duration = instance.effective_duration(clip_duration);
|
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
|
||||||
let timeline_end = instance.timeline_start + effective_duration;
|
let timeline_end = instance.timeline_start + effective_duration;
|
||||||
|
|
||||||
// Validate: split_time must be strictly within the clip's timeline span
|
// Validate: split_time must be strictly within the clip's timeline span
|
||||||
|
|
@ -179,7 +179,6 @@ impl Action for SplitClipInstanceAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.new_instance_id = Some(right_instance.id);
|
self.new_instance_id = Some(right_instance.id);
|
||||||
right_instance.sync_from_seconds(document.bpm, document.framerate);
|
|
||||||
|
|
||||||
// Now modify the original (left) instance and add the new (right) instance
|
// Now modify the original (left) instance and add the new (right) instance
|
||||||
let layer_mut = document
|
let layer_mut = document
|
||||||
|
|
@ -239,21 +238,6 @@ impl Action for SplitClipInstanceAction {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync derived fields on the left (original) instance
|
|
||||||
let (bpm, fps) = (document.bpm, document.framerate);
|
|
||||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
|
||||||
let cis: &mut Vec<crate::clip::ClipInstance> = match layer {
|
|
||||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
|
||||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
|
||||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
|
||||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
|
||||||
_ => return { self.executed = true; Ok(()) },
|
|
||||||
};
|
|
||||||
if let Some(inst) = cis.iter_mut().find(|ci| ci.id == self.instance_id) {
|
|
||||||
inst.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.executed = true;
|
self.executed = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -406,10 +390,10 @@ impl Action for SplitClipInstanceAction {
|
||||||
let instance = daw_backend::MidiClipInstance::new(
|
let instance = daw_backend::MidiClipInstance::new(
|
||||||
0,
|
0,
|
||||||
*midi_clip_id,
|
*midi_clip_id,
|
||||||
internal_start,
|
daw_backend::Beats(internal_start),
|
||||||
internal_end,
|
daw_backend::Beats(internal_end),
|
||||||
external_start,
|
daw_backend::Beats(external_start),
|
||||||
external_duration,
|
daw_backend::Beats(external_duration),
|
||||||
);
|
);
|
||||||
|
|
||||||
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
|
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
|
||||||
|
|
|
||||||
|
|
@ -260,9 +260,6 @@ impl Action for TrimClipInstancesAction {
|
||||||
// Store clamped trims for rollback
|
// Store clamped trims for rollback
|
||||||
self.layer_trims = clamped_trims.clone();
|
self.layer_trims = clamped_trims.clone();
|
||||||
|
|
||||||
let bpm = document.bpm;
|
|
||||||
let fps = document.framerate;
|
|
||||||
|
|
||||||
// Apply all clamped trims
|
// Apply all clamped trims
|
||||||
for (layer_id, trims) in &clamped_trims {
|
for (layer_id, trims) in &clamped_trims {
|
||||||
let layer = match document.get_layer_mut(layer_id) {
|
let layer = match document.get_layer_mut(layer_id) {
|
||||||
|
|
@ -297,7 +294,6 @@ impl Action for TrimClipInstancesAction {
|
||||||
clip_instance.trim_end = new.trim_value;
|
clip_instance.trim_end = new.trim_value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clip_instance.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -305,8 +301,6 @@ impl Action for TrimClipInstancesAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
let bpm = document.bpm;
|
|
||||||
let fps = document.framerate;
|
|
||||||
for (layer_id, trims) in &self.layer_trims {
|
for (layer_id, trims) in &self.layer_trims {
|
||||||
let layer = match document.get_layer_mut(layer_id) {
|
let layer = match document.get_layer_mut(layer_id) {
|
||||||
Some(l) => l,
|
Some(l) => l,
|
||||||
|
|
@ -340,7 +334,6 @@ impl Action for TrimClipInstancesAction {
|
||||||
clip_instance.trim_end = old.trim_value;
|
clip_instance.trim_end = old.trim_value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clip_instance.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Beat/measure ↔ seconds conversion utilities
|
//! Beat/measure ↔ seconds conversion utilities
|
||||||
|
|
||||||
use crate::document::TimeSignature;
|
use crate::document::TimeSignature;
|
||||||
|
use crate::tempo_map::TempoMap;
|
||||||
|
|
||||||
/// Position expressed as measure, beat, tick
|
/// Position expressed as measure, beat, tick
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
|
@ -11,9 +12,8 @@ pub struct MeasurePosition {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a time in seconds to a measure position
|
/// Convert a time in seconds to a measure position
|
||||||
pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> MeasurePosition {
|
pub fn time_to_measure(time: f64, tempo_map: &TempoMap, time_sig: &TimeSignature) -> MeasurePosition {
|
||||||
let beats_per_second = bpm / 60.0;
|
let total_beats = tempo_map.inverse_transform(time).max(0.0);
|
||||||
let total_beats = (time * beats_per_second).max(0.0);
|
|
||||||
let beats_per_measure = time_sig.numerator as f64;
|
let beats_per_measure = time_sig.numerator as f64;
|
||||||
|
|
||||||
let measure = (total_beats / beats_per_measure).floor() as u32 + 1;
|
let measure = (total_beats / beats_per_measure).floor() as u32 + 1;
|
||||||
|
|
@ -24,21 +24,20 @@ pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> Measure
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a measure position to seconds
|
/// Convert a measure position to seconds
|
||||||
pub fn measure_to_time(pos: MeasurePosition, bpm: f64, time_sig: &TimeSignature) -> f64 {
|
pub fn measure_to_time(pos: MeasurePosition, tempo_map: &TempoMap, time_sig: &TimeSignature) -> f64 {
|
||||||
let beats_per_measure = time_sig.numerator as f64;
|
let beats_per_measure = time_sig.numerator as f64;
|
||||||
let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure
|
let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure
|
||||||
+ (pos.beat as f64 - 1.0)
|
+ (pos.beat as f64 - 1.0)
|
||||||
+ (pos.tick as f64 / 1000.0);
|
+ (pos.tick as f64 / 1000.0);
|
||||||
let beats_per_second = bpm / 60.0;
|
tempo_map.transform(total_beats)
|
||||||
total_beats / beats_per_second
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the duration of one beat in seconds
|
/// Get the duration of one beat in seconds at the given beat position
|
||||||
pub fn beat_duration(bpm: f64) -> f64 {
|
pub fn beat_duration(beat: f64, tempo_map: &TempoMap) -> f64 {
|
||||||
60.0 / bpm
|
60.0 / tempo_map.bpm_at(daw_backend::Beats(beat))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the duration of one measure in seconds
|
/// Get the duration of one measure in seconds at the given beat position
|
||||||
pub fn measure_duration(bpm: f64, time_sig: &TimeSignature) -> f64 {
|
pub fn measure_duration(beat: f64, tempo_map: &TempoMap, time_sig: &TimeSignature) -> f64 {
|
||||||
beat_duration(bpm) * time_sig.numerator as f64
|
beat_duration(beat, tempo_map) * time_sig.numerator as f64
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,15 +98,20 @@ impl VectorClip {
|
||||||
///
|
///
|
||||||
/// The `clip_duration_fn` resolves referenced clip durations for non-vector layers.
|
/// The `clip_duration_fn` resolves referenced clip durations for non-vector layers.
|
||||||
/// Falls back to the stored `duration` field if no content exists.
|
/// Falls back to the stored `duration` field if no content exists.
|
||||||
pub fn content_duration(&self, framerate: f64) -> f64 {
|
pub fn content_duration(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
||||||
self.content_duration_with(framerate, |_| None)
|
self.content_duration_with(framerate, tempo_map, |_| None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like `content_duration`, but with a closure that resolves clip durations
|
/// Like `content_duration`, but with a closure that resolves clip durations
|
||||||
/// for audio/video/effect clip instances inside this movie clip.
|
/// for audio/video/effect clip instances inside this movie clip.
|
||||||
pub fn content_duration_with(&self, framerate: f64, clip_duration_fn: impl Fn(&Uuid) -> Option<f64>) -> f64 {
|
///
|
||||||
|
/// `clip_duration_fn` returns clip content duration **in seconds**.
|
||||||
|
/// Result is in **seconds**.
|
||||||
|
pub fn content_duration_with(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap, clip_duration_fn: impl Fn(&Uuid) -> Option<f64>) -> f64 {
|
||||||
let frame_duration = 1.0 / framerate;
|
let frame_duration = 1.0 / framerate;
|
||||||
let mut last_time: Option<f64> = None;
|
// Work in beats, convert to seconds at the end.
|
||||||
|
let mut last_beats: Option<f64> = None;
|
||||||
|
let mut last_secs: Option<f64> = None;
|
||||||
|
|
||||||
for layer_node in self.layers.iter() {
|
for layer_node in self.layers.iter() {
|
||||||
// Check clip instances on ALL layer types (vector, audio, video, effect)
|
// Check clip instances on ALL layer types (vector, audio, video, effect)
|
||||||
|
|
@ -119,33 +124,38 @@ impl VectorClip {
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
};
|
};
|
||||||
for ci in clip_instances {
|
for ci in clip_instances {
|
||||||
let end = if let Some(td) = ci.timeline_duration {
|
// Compute end position of this clip instance in beats
|
||||||
ci.timeline_start + td
|
let end_beats = if let Some(td_beats) = ci.timeline_duration {
|
||||||
|
ci.timeline_start + td_beats
|
||||||
} else if let Some(te) = ci.trim_end {
|
} else if let Some(te) = ci.trim_end {
|
||||||
ci.timeline_start + (te - ci.trim_start).max(0.0)
|
let secs = (te - ci.trim_start).max(0.0);
|
||||||
} else if let Some(clip_dur) = clip_duration_fn(&ci.clip_id) {
|
ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start
|
||||||
ci.timeline_start + (clip_dur - ci.trim_start).max(0.0)
|
} else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
|
||||||
|
let secs = (clip_dur_secs - ci.trim_start).max(0.0);
|
||||||
|
ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
last_time = Some(match last_time {
|
last_beats = Some(last_beats.map_or(end_beats, |t: f64| t.max(end_beats)));
|
||||||
Some(t) => t.max(end),
|
|
||||||
None => end,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check vector layer keyframes
|
// Vector layer keyframes are in seconds
|
||||||
if let AnyLayer::Vector(vector_layer) = &layer_node.data {
|
if let AnyLayer::Vector(vector_layer) = &layer_node.data {
|
||||||
if let Some(last_kf) = vector_layer.keyframes.last() {
|
if let Some(last_kf) = vector_layer.keyframes.last() {
|
||||||
last_time = Some(match last_time {
|
last_secs = Some(last_secs.map_or(last_kf.time, |t: f64| t.max(last_kf.time)));
|
||||||
Some(t) => t.max(last_kf.time),
|
|
||||||
None => last_kf.time,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match last_time {
|
let from_clips = last_beats.map(|b| tempo_map.transform(b));
|
||||||
|
let combined = match (from_clips, last_secs) {
|
||||||
|
(Some(a), Some(b)) => Some(a.max(b)),
|
||||||
|
(Some(a), None) => Some(a),
|
||||||
|
(None, Some(b)) => Some(b),
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match combined {
|
||||||
Some(t) => t + frame_duration,
|
Some(t) => t + frame_duration,
|
||||||
None => self.duration,
|
None => self.duration,
|
||||||
}
|
}
|
||||||
|
|
@ -186,9 +196,10 @@ impl VectorClip {
|
||||||
|
|
||||||
// Handle nested clip instances recursively
|
// Handle nested clip instances recursively
|
||||||
for clip_instance in &vector_layer.clip_instances {
|
for clip_instance in &vector_layer.clip_instances {
|
||||||
// Convert parent clip time to nested clip local time
|
// Convert parent clip time (seconds) to nested clip local time (seconds).
|
||||||
// Apply timeline offset and playback speed, then add trim offset
|
// timeline_start is in beats; convert to seconds using document BPM.
|
||||||
let nested_clip_time = ((clip_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
let start_secs = document.tempo_map().transform(clip_instance.timeline_start);
|
||||||
|
let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||||
|
|
||||||
// Look up the nested clip definition
|
// Look up the nested clip definition
|
||||||
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||||
|
|
@ -485,12 +496,12 @@ impl AudioClip {
|
||||||
pub fn new_midi(
|
pub fn new_midi(
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
midi_clip_id: u32,
|
midi_clip_id: u32,
|
||||||
duration: f64,
|
duration: daw_backend::Beats,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
duration,
|
duration: duration.beats_to_f64(),
|
||||||
clip_type: AudioClipType::Midi { midi_clip_id },
|
clip_type: AudioClipType::Midi { midi_clip_id },
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
}
|
}
|
||||||
|
|
@ -589,6 +600,12 @@ impl AnyClip {
|
||||||
/// - Timeline placement (when this instance appears on the parent layer's timeline)
|
/// - Timeline placement (when this instance appears on the parent layer's timeline)
|
||||||
/// - Trimming (trim_start, trim_end within the clip's internal content)
|
/// - Trimming (trim_start, trim_end within the clip's internal content)
|
||||||
/// - Playback speed (time remapping)
|
/// - Playback speed (time remapping)
|
||||||
|
///
|
||||||
|
/// ## Coordinate systems
|
||||||
|
/// - `timeline_start` / `timeline_duration` are in **beats** (quarter-note beats).
|
||||||
|
/// Use [`crate::tempo_map::TempoMap::beats_to_seconds`] to convert to seconds.
|
||||||
|
/// - `trim_start` / `trim_end` are in **seconds** (audio/video file seek offsets;
|
||||||
|
/// not affected by BPM changes).
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct ClipInstance {
|
pub struct ClipInstance {
|
||||||
/// Unique identifier for this instance
|
/// Unique identifier for this instance
|
||||||
|
|
@ -606,52 +623,25 @@ pub struct ClipInstance {
|
||||||
/// Optional name for this instance
|
/// Optional name for this instance
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
|
||||||
/// When this instance starts on the timeline (in seconds, relative to parent layer)
|
/// When this instance starts on the timeline, in **beats**.
|
||||||
/// This is the external positioning - where the instance appears on the timeline
|
/// Default: 0.0
|
||||||
/// Default: 0.0 (start at beginning of layer)
|
|
||||||
pub timeline_start: f64,
|
pub timeline_start: f64,
|
||||||
/// timeline_start in beats (quarter-note beats); derived from timeline_start
|
|
||||||
#[serde(default)]
|
|
||||||
pub timeline_start_beats: f64,
|
|
||||||
/// timeline_start in frames; derived from timeline_start
|
|
||||||
#[serde(default)]
|
|
||||||
pub timeline_start_frames: f64,
|
|
||||||
|
|
||||||
/// How long this instance appears on the timeline (in seconds)
|
/// How long this instance appears on the timeline, in **beats**.
|
||||||
/// If timeline_duration > (trim_end - trim_start), the trimmed content will loop
|
/// If set and longer than the trimmed content, the content will loop.
|
||||||
/// Default: None (use trimmed clip duration, no looping)
|
/// Default: None (use trimmed clip duration, no looping)
|
||||||
pub timeline_duration: Option<f64>,
|
pub timeline_duration: Option<f64>,
|
||||||
/// timeline_duration in beats; derived from timeline_duration
|
|
||||||
#[serde(default)]
|
|
||||||
pub timeline_duration_beats: Option<f64>,
|
|
||||||
/// timeline_duration in frames; derived from timeline_duration
|
|
||||||
#[serde(default)]
|
|
||||||
pub timeline_duration_frames: Option<f64>,
|
|
||||||
|
|
||||||
/// Trim start: offset into the clip's internal content (in seconds)
|
/// Trim start: offset into the clip's internal content, in **seconds**.
|
||||||
/// Allows trimming the beginning of the clip
|
/// - For audio: byte-offset into the audio file
|
||||||
/// - For audio: offset into the audio file
|
/// - For video: seek position in the video file
|
||||||
/// - For video: offset into the video file
|
/// - For vector: time offset into the animation
|
||||||
/// - For vector: offset into the animation timeline
|
/// Default: 0.0
|
||||||
/// Default: 0.0 (start at beginning of clip)
|
|
||||||
pub trim_start: f64,
|
pub trim_start: f64,
|
||||||
/// trim_start in beats; derived from trim_start
|
|
||||||
#[serde(default)]
|
|
||||||
pub trim_start_beats: f64,
|
|
||||||
/// trim_start in frames; derived from trim_start
|
|
||||||
#[serde(default)]
|
|
||||||
pub trim_start_frames: f64,
|
|
||||||
|
|
||||||
/// Trim end: offset into the clip's internal content (in seconds)
|
/// Trim end: offset into the clip's internal content, in **seconds**.
|
||||||
/// Allows trimming the end of the clip
|
|
||||||
/// Default: None (use full clip duration)
|
/// Default: None (use full clip duration)
|
||||||
pub trim_end: Option<f64>,
|
pub trim_end: Option<f64>,
|
||||||
/// trim_end in beats; derived from trim_end
|
|
||||||
#[serde(default)]
|
|
||||||
pub trim_end_beats: Option<f64>,
|
|
||||||
/// trim_end in frames; derived from trim_end
|
|
||||||
#[serde(default)]
|
|
||||||
pub trim_end_frames: Option<f64>,
|
|
||||||
|
|
||||||
/// Playback speed multiplier
|
/// Playback speed multiplier
|
||||||
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
|
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
|
||||||
|
|
@ -663,7 +653,7 @@ pub struct ClipInstance {
|
||||||
/// Default: 1.0
|
/// Default: 1.0
|
||||||
pub gain: f32,
|
pub gain: f32,
|
||||||
|
|
||||||
/// How far (in seconds) the looped content extends before timeline_start.
|
/// How far (in beats) the looped content extends before timeline_start.
|
||||||
/// When set, loop iterations are drawn/played before the content start.
|
/// When set, loop iterations are drawn/played before the content start.
|
||||||
/// Default: None (no pre-loop)
|
/// Default: None (no pre-loop)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -720,17 +710,9 @@ impl ClipInstance {
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
name: None,
|
name: None,
|
||||||
timeline_start: 0.0,
|
timeline_start: 0.0,
|
||||||
timeline_start_beats: 0.0,
|
|
||||||
timeline_start_frames: 0.0,
|
|
||||||
timeline_duration: None,
|
timeline_duration: None,
|
||||||
timeline_duration_beats: None,
|
|
||||||
timeline_duration_frames: None,
|
|
||||||
trim_start: 0.0,
|
trim_start: 0.0,
|
||||||
trim_start_beats: 0.0,
|
|
||||||
trim_start_frames: 0.0,
|
|
||||||
trim_end: None,
|
trim_end: None,
|
||||||
trim_end_beats: None,
|
|
||||||
trim_end_frames: None,
|
|
||||||
playback_speed: 1.0,
|
playback_speed: 1.0,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
loop_before: None,
|
loop_before: None,
|
||||||
|
|
@ -746,71 +728,15 @@ impl ClipInstance {
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
name: None,
|
name: None,
|
||||||
timeline_start: 0.0,
|
timeline_start: 0.0,
|
||||||
timeline_start_beats: 0.0,
|
|
||||||
timeline_start_frames: 0.0,
|
|
||||||
timeline_duration: None,
|
timeline_duration: None,
|
||||||
timeline_duration_beats: None,
|
|
||||||
timeline_duration_frames: None,
|
|
||||||
trim_start: 0.0,
|
trim_start: 0.0,
|
||||||
trim_start_beats: 0.0,
|
|
||||||
trim_start_frames: 0.0,
|
|
||||||
trim_end: None,
|
trim_end: None,
|
||||||
trim_end_beats: None,
|
|
||||||
trim_end_frames: None,
|
|
||||||
playback_speed: 1.0,
|
playback_speed: 1.0,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
loop_before: None,
|
loop_before: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync beats and frames from the seconds fields (call after any seconds-based write).
|
|
||||||
pub fn sync_from_seconds(&mut self, bpm: f64, fps: f64) {
|
|
||||||
self.timeline_start_beats = self.timeline_start * bpm / 60.0;
|
|
||||||
self.timeline_start_frames = self.timeline_start * fps;
|
|
||||||
self.trim_start_beats = self.trim_start * bpm / 60.0;
|
|
||||||
self.trim_start_frames = self.trim_start * fps;
|
|
||||||
self.trim_end_beats = self.trim_end.map(|v| v * bpm / 60.0);
|
|
||||||
self.trim_end_frames = self.trim_end.map(|v| v * fps);
|
|
||||||
self.timeline_duration_beats = self.timeline_duration.map(|v| v * bpm / 60.0);
|
|
||||||
self.timeline_duration_frames = self.timeline_duration.map(|v| v * fps);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recompute seconds and frames from beats (call when BPM changes in Measures mode).
|
|
||||||
pub fn apply_beats(&mut self, bpm: f64, fps: f64) {
|
|
||||||
self.timeline_start = self.timeline_start_beats * 60.0 / bpm;
|
|
||||||
self.timeline_start_frames = self.timeline_start * fps;
|
|
||||||
self.trim_start = self.trim_start_beats * 60.0 / bpm;
|
|
||||||
self.trim_start_frames = self.trim_start * fps;
|
|
||||||
if let Some(b) = self.trim_end_beats {
|
|
||||||
let s = b * 60.0 / bpm;
|
|
||||||
self.trim_end = Some(s);
|
|
||||||
self.trim_end_frames = Some(s * fps);
|
|
||||||
}
|
|
||||||
if let Some(b) = self.timeline_duration_beats {
|
|
||||||
let s = b * 60.0 / bpm;
|
|
||||||
self.timeline_duration = Some(s);
|
|
||||||
self.timeline_duration_frames = Some(s * fps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recompute seconds and beats from frames (call when FPS changes in Frames mode).
|
|
||||||
pub fn apply_frames(&mut self, fps: f64, bpm: f64) {
|
|
||||||
self.timeline_start = self.timeline_start_frames / fps;
|
|
||||||
self.timeline_start_beats = self.timeline_start * bpm / 60.0;
|
|
||||||
self.trim_start = self.trim_start_frames / fps;
|
|
||||||
self.trim_start_beats = self.trim_start * bpm / 60.0;
|
|
||||||
if let Some(f) = self.trim_end_frames {
|
|
||||||
let s = f / fps;
|
|
||||||
self.trim_end = Some(s);
|
|
||||||
self.trim_end_beats = Some(s * bpm / 60.0);
|
|
||||||
}
|
|
||||||
if let Some(f) = self.timeline_duration_frames {
|
|
||||||
let s = f / fps;
|
|
||||||
self.timeline_duration = Some(s);
|
|
||||||
self.timeline_duration_beats = Some(s * bpm / 60.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the transform
|
/// Set the transform
|
||||||
pub fn with_transform(mut self, transform: Transform) -> Self {
|
pub fn with_transform(mut self, transform: Transform) -> Self {
|
||||||
self.transform = transform;
|
self.transform = transform;
|
||||||
|
|
@ -861,83 +787,83 @@ impl ClipInstance {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set explicit timeline duration by setting trim_end
|
/// Set explicit timeline duration (in beats) by directly setting `timeline_duration`.
|
||||||
///
|
pub fn with_timeline_duration(mut self, duration_beats: f64) -> Self {
|
||||||
/// For effect instances, this effectively sets the duration since
|
self.timeline_duration = Some(duration_beats);
|
||||||
/// effects have infinite internal duration (trim_start defaults to 0).
|
|
||||||
pub fn with_timeline_duration(mut self, duration: f64) -> Self {
|
|
||||||
self.trim_end = Some(self.trim_start + duration);
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the effective duration of this instance (accounting for trimming and looping)
|
/// Content window size in seconds: `trim_end - trim_start`.
|
||||||
/// If timeline_duration is set, returns that (enabling content looping)
|
/// Used for internal looping calculations.
|
||||||
/// Otherwise returns the trimmed content duration
|
pub fn content_window_secs(&self, clip_duration_secs: f64) -> f64 {
|
||||||
pub fn effective_duration(&self, clip_duration: f64) -> f64 {
|
let end = self.trim_end.unwrap_or(clip_duration_secs);
|
||||||
// If timeline_duration is explicitly set, use that (for looping)
|
|
||||||
if let Some(timeline_dur) = self.timeline_duration {
|
|
||||||
return timeline_dur;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, return the trimmed content duration
|
|
||||||
let end = self.trim_end.unwrap_or(clip_duration);
|
|
||||||
(end - self.trim_start).max(0.0)
|
(end - self.trim_start).max(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the effective start position on the timeline, accounting for loop_before.
|
/// How long this instance appears on the timeline, in **beats**.
|
||||||
/// This is the left edge of the clip's visual extent.
|
///
|
||||||
|
/// If `timeline_duration` is set, returns that (enabling content looping).
|
||||||
|
/// Otherwise converts the content window from seconds to beats using the tempo map.
|
||||||
|
pub fn effective_duration_beats(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
||||||
|
if let Some(td) = self.timeline_duration {
|
||||||
|
return td;
|
||||||
|
}
|
||||||
|
let window_secs = self.content_window_secs(clip_duration_secs);
|
||||||
|
let start_secs = tempo_map.transform(self.timeline_start);
|
||||||
|
tempo_map.inverse_transform(start_secs + window_secs) - self.timeline_start
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Left edge of the clip's visual extent on the timeline, in **beats**.
|
||||||
pub fn effective_start(&self) -> f64 {
|
pub fn effective_start(&self) -> f64 {
|
||||||
self.timeline_start - self.loop_before.unwrap_or(0.0)
|
self.timeline_start - self.loop_before.unwrap_or(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the total visual duration including both loop_before and effective_duration.
|
/// Total visual duration (loop_before + effective_duration), in **beats**.
|
||||||
pub fn total_duration(&self, clip_duration: f64) -> f64 {
|
pub fn total_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
||||||
self.loop_before.unwrap_or(0.0) + self.effective_duration(clip_duration)
|
self.loop_before.unwrap_or(0.0) + self.effective_duration_beats(clip_duration_secs, tempo_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remap timeline time to clip content time
|
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
|
||||||
///
|
///
|
||||||
/// Takes a global timeline time and returns the corresponding time within this
|
/// Returns `None` if the clip instance is not active at `time_secs`.
|
||||||
/// clip's content, accounting for:
|
pub fn remap_time_secs(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option<f64> {
|
||||||
/// - Instance position (timeline_start)
|
let start_secs = tempo_map.transform(self.timeline_start);
|
||||||
/// - Playback speed
|
let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map);
|
||||||
/// - Trimming (trim_start, trim_end)
|
let end_secs = tempo_map.transform(self.timeline_start + dur_beats);
|
||||||
/// - Looping (if timeline_duration > content window)
|
|
||||||
///
|
if time_secs < start_secs || time_secs >= end_secs {
|
||||||
/// Returns None if the clip instance is not active at the given timeline time.
|
|
||||||
pub fn remap_time(&self, timeline_time: f64, clip_duration: f64) -> Option<f64> {
|
|
||||||
// Check if clip instance is active at this time
|
|
||||||
let instance_end = self.timeline_start + self.effective_duration(clip_duration);
|
|
||||||
if timeline_time < self.timeline_start || timeline_time >= instance_end {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate relative time within the instance (0.0 = start of instance)
|
let relative_secs = time_secs - start_secs;
|
||||||
let relative_time = timeline_time - self.timeline_start;
|
let content_time = relative_secs * self.playback_speed;
|
||||||
|
let content_window = self.content_window_secs(clip_duration_secs);
|
||||||
|
|
||||||
// Account for playback speed
|
|
||||||
let content_time = relative_time * self.playback_speed;
|
|
||||||
|
|
||||||
// Get the content window size (the portion of clip we're sampling)
|
|
||||||
let trim_end = self.trim_end.unwrap_or(clip_duration);
|
|
||||||
let content_window = (trim_end - self.trim_start).max(0.0);
|
|
||||||
|
|
||||||
// If content_window is zero, can't sample anything
|
|
||||||
if content_window == 0.0 {
|
if content_window == 0.0 {
|
||||||
return Some(self.trim_start);
|
return Some(self.trim_start);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply looping if content exceeds the window
|
|
||||||
let looped_time = if content_time > content_window {
|
let looped_time = if content_time > content_window {
|
||||||
content_time % content_window
|
content_time % content_window
|
||||||
} else {
|
} else {
|
||||||
content_time
|
content_time
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add trim_start offset to get final clip time
|
|
||||||
Some(self.trim_start + looped_time)
|
Some(self.trim_start + looped_time)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alias for `remap_time_secs`.
|
||||||
|
#[inline]
|
||||||
|
pub fn remap_time(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option<f64> {
|
||||||
|
self.remap_time_secs(time_secs, clip_duration_secs, tempo_map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alias for `effective_duration_beats`.
|
||||||
|
#[inline]
|
||||||
|
pub fn effective_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
||||||
|
self.effective_duration_beats(clip_duration_secs, tempo_map)
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert to affine transform
|
/// Convert to affine transform
|
||||||
pub fn to_affine(&self) -> vello::kurbo::Affine {
|
pub fn to_affine(&self) -> vello::kurbo::Affine {
|
||||||
self.transform.to_affine()
|
self.transform.to_affine()
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@
|
||||||
use crate::asset_folder::AssetFolderTree;
|
use crate::asset_folder::AssetFolderTree;
|
||||||
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
|
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
|
||||||
use crate::effect::EffectDefinition;
|
use crate::effect::EffectDefinition;
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::{AnyLayer, GroupLayer};
|
||||||
use crate::script::ScriptDefinition;
|
use crate::script::ScriptDefinition;
|
||||||
use crate::layout::LayoutNode;
|
use crate::layout::LayoutNode;
|
||||||
use crate::shape::ShapeColor;
|
use crate::shape::ShapeColor;
|
||||||
|
use crate::tempo_map::TempoMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -131,6 +132,8 @@ impl Default for TimeSignature {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep for backward serde compat (old files may have a `bpm` field); no longer used.
|
||||||
|
#[allow(dead_code)]
|
||||||
fn default_bpm() -> f64 { 120.0 }
|
fn default_bpm() -> f64 { 120.0 }
|
||||||
|
|
||||||
/// How time is displayed in the timeline
|
/// How time is displayed in the timeline
|
||||||
|
|
@ -173,14 +176,16 @@ pub struct Document {
|
||||||
/// Framerate (frames per second)
|
/// Framerate (frames per second)
|
||||||
pub framerate: f64,
|
pub framerate: f64,
|
||||||
|
|
||||||
/// Tempo in beats per minute
|
|
||||||
#[serde(default = "default_bpm")]
|
|
||||||
pub bpm: f64,
|
|
||||||
|
|
||||||
/// Time signature
|
/// Time signature
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub time_signature: TimeSignature,
|
pub time_signature: TimeSignature,
|
||||||
|
|
||||||
|
/// Master track (master bus + tempo automation lane).
|
||||||
|
/// Stored separately from the root layer tree; shown in timeline when
|
||||||
|
/// `show_master_track` is enabled in the editor state.
|
||||||
|
#[serde(default)]
|
||||||
|
pub master_layer: GroupLayer,
|
||||||
|
|
||||||
/// Duration in seconds
|
/// Duration in seconds
|
||||||
pub duration: f64,
|
pub duration: f64,
|
||||||
|
|
||||||
|
|
@ -266,8 +271,12 @@ impl Default for Document {
|
||||||
width: 1920.0,
|
width: 1920.0,
|
||||||
height: 1080.0,
|
height: 1080.0,
|
||||||
framerate: 60.0,
|
framerate: 60.0,
|
||||||
bpm: 120.0,
|
|
||||||
time_signature: TimeSignature::default(),
|
time_signature: TimeSignature::default(),
|
||||||
|
master_layer: {
|
||||||
|
let mut ml = GroupLayer::new_master(120.0);
|
||||||
|
ml.layer.id = uuid::Uuid::new_v4();
|
||||||
|
ml
|
||||||
|
},
|
||||||
duration: 10.0,
|
duration: 10.0,
|
||||||
root: GraphicsObject::default(),
|
root: GraphicsObject::default(),
|
||||||
vector_clips: HashMap::new(),
|
vector_clips: HashMap::new(),
|
||||||
|
|
@ -311,6 +320,28 @@ impl Document {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reference to the document's tempo map (stored on the master layer).
|
||||||
|
pub fn tempo_map(&self) -> &TempoMap {
|
||||||
|
self.master_layer.tempo_map.as_ref()
|
||||||
|
.expect("master_layer always has a tempo_map")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable reference to the document's tempo map.
|
||||||
|
pub fn tempo_map_mut(&mut self) -> &mut TempoMap {
|
||||||
|
self.master_layer.tempo_map.as_mut()
|
||||||
|
.expect("master_layer always has a tempo_map")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience accessor for the global BPM (first entry of the tempo map).
|
||||||
|
pub fn bpm(&self) -> f64 {
|
||||||
|
self.tempo_map().global_bpm()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global BPM. Rebuilds the tempo map's seconds cache.
|
||||||
|
pub fn set_bpm(&mut self, bpm: f64) {
|
||||||
|
self.tempo_map_mut().set_global_bpm(bpm);
|
||||||
|
}
|
||||||
|
|
||||||
/// Rebuild the layer→clip reverse lookup map from all vector clips.
|
/// Rebuild the layer→clip reverse lookup map from all vector clips.
|
||||||
/// Call after deserialization or bulk clip modifications.
|
/// Call after deserialization or bulk clip modifications.
|
||||||
pub fn rebuild_layer_to_clip_map(&mut self) {
|
pub fn rebuild_layer_to_clip_map(&mut self) {
|
||||||
|
|
@ -619,56 +650,6 @@ impl Document {
|
||||||
layers
|
layers
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migrate old documents: compute beats/frames from seconds for any ClipInstance whose
|
|
||||||
/// derived fields are still zero (i.e., documents saved before triple-representation).
|
|
||||||
/// Call once after loading a document.
|
|
||||||
pub fn sync_all_clip_positions(&mut self) {
|
|
||||||
let bpm = self.bpm;
|
|
||||||
let fps = self.framerate;
|
|
||||||
|
|
||||||
fn sync_list(list: &mut [crate::layer::AnyLayer], bpm: f64, fps: f64) {
|
|
||||||
for layer in list.iter_mut() {
|
|
||||||
match layer {
|
|
||||||
crate::layer::AnyLayer::Vector(vl) => {
|
|
||||||
for ci in &mut vl.clip_instances {
|
|
||||||
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::layer::AnyLayer::Audio(al) => {
|
|
||||||
for ci in &mut al.clip_instances {
|
|
||||||
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::layer::AnyLayer::Video(vl) => {
|
|
||||||
for ci in &mut vl.clip_instances {
|
|
||||||
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::layer::AnyLayer::Effect(el) => {
|
|
||||||
for ci in &mut el.clip_instances {
|
|
||||||
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::layer::AnyLayer::Group(g) => {
|
|
||||||
sync_list(&mut g.children, bpm, fps);
|
|
||||||
}
|
|
||||||
crate::layer::AnyLayer::Raster(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sync_list(&mut self.root.children, bpm, fps);
|
|
||||||
for clip in self.vector_clips.values_mut() {
|
|
||||||
for node in &mut clip.layers.roots {
|
|
||||||
if let crate::layer::AnyLayer::Vector(vl) = &mut node.data {
|
|
||||||
for ci in &mut vl.clip_instances {
|
|
||||||
if ci.timeline_start_beats == 0.0 { ci.sync_from_seconds(bpm, fps); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === CLIP LIBRARY METHODS ===
|
// === CLIP LIBRARY METHODS ===
|
||||||
|
|
||||||
/// Add a vector clip to the library
|
/// Add a vector clip to the library
|
||||||
|
|
@ -866,11 +847,12 @@ impl Document {
|
||||||
if clip.is_group {
|
if clip.is_group {
|
||||||
Some(clip.duration)
|
Some(clip.duration)
|
||||||
} else {
|
} else {
|
||||||
Some(clip.content_duration_with(self.framerate, |id| {
|
let tempo_map = self.tempo_map();
|
||||||
|
Some(clip.content_duration_with(self.framerate, tempo_map, |id| {
|
||||||
// Resolve nested clip durations (audio, video, other vector clips)
|
// Resolve nested clip durations (audio, video, other vector clips)
|
||||||
if let Some(vc) = self.vector_clips.get(id) {
|
if let Some(vc) = self.vector_clips.get(id) {
|
||||||
// Avoid deep recursion — use stored duration for nested vector clips
|
// Avoid deep recursion — use stored duration for nested vector clips
|
||||||
Some(vc.content_duration(self.framerate))
|
Some(vc.content_duration(self.framerate, tempo_map))
|
||||||
} else if let Some(ac) = self.audio_clips.get(id) {
|
} else if let Some(ac) = self.audio_clips.get(id) {
|
||||||
Some(ac.duration)
|
Some(ac.duration)
|
||||||
} else if let Some(vc) = self.video_clips.get(id) {
|
} else if let Some(vc) = self.video_clips.get(id) {
|
||||||
|
|
@ -961,7 +943,7 @@ impl Document {
|
||||||
};
|
};
|
||||||
|
|
||||||
let instance_start = instance.effective_start();
|
let instance_start = instance.effective_start();
|
||||||
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration);
|
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map());
|
||||||
|
|
||||||
// Check overlap: start_a < end_b AND start_b < end_a
|
// Check overlap: start_a < end_b AND start_b < end_a
|
||||||
if start_time < instance_end && instance_start < end_time {
|
if start_time < instance_end && instance_start < end_time {
|
||||||
|
|
@ -1019,7 +1001,7 @@ impl Document {
|
||||||
|
|
||||||
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
|
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
|
||||||
let inst_start = instance.effective_start();
|
let inst_start = instance.effective_start();
|
||||||
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur);
|
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
|
||||||
occupied_ranges.push((inst_start, inst_end, instance.id));
|
occupied_ranges.push((inst_start, inst_end, instance.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1114,7 +1096,7 @@ impl Document {
|
||||||
}
|
}
|
||||||
if let Some(dur) = self.get_clip_duration(&inst.clip_id) {
|
if let Some(dur) = self.get_clip_duration(&inst.clip_id) {
|
||||||
let start = inst.effective_start();
|
let start = inst.effective_start();
|
||||||
let end = inst.timeline_start + inst.effective_duration(dur);
|
let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
|
||||||
non_group.push((start, end));
|
non_group.push((start, end));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1184,7 +1166,7 @@ impl Document {
|
||||||
|
|
||||||
// Calculate other clip's extent (accounting for loop_before)
|
// Calculate other clip's extent (accounting for loop_before)
|
||||||
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
||||||
let other_end = other.timeline_start + other.effective_duration(clip_duration);
|
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
|
||||||
|
|
||||||
// If this clip is to the left and closer than current nearest
|
// If this clip is to the left and closer than current nearest
|
||||||
if other_end <= current_timeline_start && other_end > nearest_end {
|
if other_end <= current_timeline_start && other_end > nearest_end {
|
||||||
|
|
@ -1280,7 +1262,7 @@ impl Document {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
||||||
let other_end = other.timeline_start + other.effective_duration(clip_duration);
|
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
|
||||||
|
|
||||||
if other_end <= current_effective_start && other_end > nearest_end {
|
if other_end <= current_effective_start && other_end > nearest_end {
|
||||||
nearest_end = other_end;
|
nearest_end = other_end;
|
||||||
|
|
|
||||||
|
|
@ -142,16 +142,18 @@ impl EffectLayer {
|
||||||
self.clip_instances.iter_mut().find(|e| &e.id == id)
|
self.clip_instances.iter_mut().find(|e| &e.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all clip instances (effects) that are active at a given time
|
/// Get all clip instances (effects) that are active at a given time.
|
||||||
///
|
///
|
||||||
/// Uses `EFFECT_DURATION` to calculate effective duration for each instance.
|
/// `time_secs` is the playback time in seconds; `bpm` is used to convert
|
||||||
pub fn active_clip_instances_at(&self, time: f64) -> Vec<&ClipInstance> {
|
/// `timeline_start` (beats) to seconds for comparison.
|
||||||
|
pub fn active_clip_instances_at(&self, time_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Vec<&ClipInstance> {
|
||||||
use crate::effect::EFFECT_DURATION;
|
use crate::effect::EFFECT_DURATION;
|
||||||
|
let time_beats = tempo_map.inverse_transform(time_secs);
|
||||||
self.clip_instances
|
self.clip_instances
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
let end = e.timeline_start + e.effective_duration(EFFECT_DURATION);
|
let end = e.timeline_start + e.effective_duration(EFFECT_DURATION, tempo_map);
|
||||||
time >= e.timeline_start && time < end
|
time_beats >= e.timeline_start && time_beats < end
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -239,13 +241,13 @@ mod tests {
|
||||||
layer.add_clip_instance(effect2);
|
layer.add_clip_instance(effect2);
|
||||||
|
|
||||||
// At time 2: only effect1 active
|
// At time 2: only effect1 active
|
||||||
assert_eq!(layer.active_clip_instances_at(2.0).len(), 1);
|
assert_eq!(layer.active_clip_instances_at(2.0, 60.0).len(), 1);
|
||||||
|
|
||||||
// At time 4: both effects active
|
// At time 4: both effects active
|
||||||
assert_eq!(layer.active_clip_instances_at(4.0).len(), 2);
|
assert_eq!(layer.active_clip_instances_at(4.0, 60.0).len(), 2);
|
||||||
|
|
||||||
// At time 7: only effect2 active
|
// At time 7: only effect2 active
|
||||||
assert_eq!(layer.active_clip_instances_at(7.0).len(), 1);
|
assert_eq!(layer.active_clip_instances_at(7.0, 60.0).len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ pub struct AudioExportSettings {
|
||||||
|
|
||||||
/// End time in seconds
|
/// End time in seconds
|
||||||
pub end_time: f64,
|
pub end_time: f64,
|
||||||
|
|
||||||
|
/// Project BPM (for beat-position scheduling during export)
|
||||||
|
pub bpm: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AudioExportSettings {
|
impl Default for AudioExportSettings {
|
||||||
|
|
@ -88,6 +91,7 @@ impl Default for AudioExportSettings {
|
||||||
bitrate_kbps: 320,
|
bitrate_kbps: 320,
|
||||||
start_time: 0.0,
|
start_time: 0.0,
|
||||||
end_time: 60.0,
|
end_time: 60.0,
|
||||||
|
bpm: 120.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -491,6 +491,8 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
|
||||||
// 5. Extract document and audio backend state
|
// 5. Extract document and audio backend state
|
||||||
let step5_start = std::time::Instant::now();
|
let step5_start = std::time::Instant::now();
|
||||||
let mut document = beam_project.ui_state;
|
let mut document = beam_project.ui_state;
|
||||||
|
// Rebuild derived seconds cache on all TempoMap entries after deserialization.
|
||||||
|
document.tempo_map_mut().rebuild_seconds();
|
||||||
let mut audio_project = beam_project.audio_backend.project;
|
let mut audio_project = beam_project.audio_backend.project;
|
||||||
let audio_pool_entries = beam_project.audio_backend.audio_pool_entries;
|
let audio_pool_entries = beam_project.audio_backend.audio_pool_entries;
|
||||||
let layer_to_track_map = beam_project.audio_backend.layer_to_track_map;
|
let layer_to_track_map = beam_project.audio_backend.layer_to_track_map;
|
||||||
|
|
|
||||||
|
|
@ -256,15 +256,20 @@ pub fn hit_test_clip_instances(
|
||||||
parent_transform: Affine,
|
parent_transform: Affine,
|
||||||
timeline_time: f64,
|
timeline_time: f64,
|
||||||
) -> Option<Uuid> {
|
) -> Option<Uuid> {
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
for clip_instance in clip_instances.iter().rev() {
|
for clip_instance in clip_instances.iter().rev() {
|
||||||
// Check time bounds: skip clip instances not active at this time
|
// Check time bounds: skip clip instances not active at this time
|
||||||
|
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
||||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration);
|
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
||||||
if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end {
|
let timeline_beats = tempo_map.inverse_transform(timeline_time);
|
||||||
|
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
// clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds)
|
||||||
|
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
||||||
|
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||||
|
|
||||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||||
vector_clip.calculate_content_bounds(document, clip_time)
|
vector_clip.calculate_content_bounds(document, clip_time)
|
||||||
|
|
@ -294,16 +299,20 @@ pub fn hit_test_clip_instances_in_rect(
|
||||||
timeline_time: f64,
|
timeline_time: f64,
|
||||||
) -> Vec<Uuid> {
|
) -> Vec<Uuid> {
|
||||||
let mut hits = Vec::new();
|
let mut hits = Vec::new();
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
|
|
||||||
for clip_instance in clip_instances {
|
for clip_instance in clip_instances {
|
||||||
// Check time bounds: skip clip instances not active at this time
|
// Check time bounds: skip clip instances not active at this time
|
||||||
|
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
||||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration);
|
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
||||||
if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end {
|
let timeline_beats = tempo_map.inverse_transform(timeline_time);
|
||||||
|
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
||||||
|
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||||
|
|
||||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||||
vector_clip.calculate_content_bounds(document, clip_time)
|
vector_clip.calculate_content_bounds(document, clip_time)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
//! Layers organize objects and shapes, and contain animation data.
|
//! Layers organize objects and shapes, and contain animation data.
|
||||||
|
|
||||||
use crate::animation::AnimationData;
|
use crate::animation::AnimationData;
|
||||||
|
use crate::tempo_map::TempoMap;
|
||||||
use crate::clip::ClipInstance;
|
use crate::clip::ClipInstance;
|
||||||
use crate::vector_graph::VectorGraph;
|
use crate::vector_graph::VectorGraph;
|
||||||
use crate::effect_layer::EffectLayer;
|
use crate::effect_layer::EffectLayer;
|
||||||
|
|
@ -720,6 +721,12 @@ pub struct GroupLayer {
|
||||||
/// Whether the group is expanded in the timeline
|
/// Whether the group is expanded in the timeline
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub expanded: bool,
|
pub expanded: bool,
|
||||||
|
|
||||||
|
/// Optional tempo map for this group. When `Some`, the group's children use
|
||||||
|
/// this tempo map for beat-to-parent-time conversion. The master group always
|
||||||
|
/// has `Some`; regular groups default to `None` (inherit parent tempo).
|
||||||
|
#[serde(default)]
|
||||||
|
pub tempo_map: Option<TempoMap>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
|
|
@ -746,6 +753,12 @@ impl LayerTrait for GroupLayer {
|
||||||
fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; }
|
fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for GroupLayer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new("Group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl GroupLayer {
|
impl GroupLayer {
|
||||||
/// Create a new group layer
|
/// Create a new group layer
|
||||||
pub fn new(name: impl Into<String>) -> Self {
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
|
@ -753,6 +766,21 @@ impl GroupLayer {
|
||||||
layer: Layer::new(LayerType::Group, name),
|
layer: Layer::new(LayerType::Group, name),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
expanded: true,
|
expanded: true,
|
||||||
|
tempo_map: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a master group layer with a constant tempo.
|
||||||
|
pub fn new_master(bpm: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
layer: Layer::with_id(
|
||||||
|
uuid::Uuid::nil(), // replaced by Document::new
|
||||||
|
LayerType::Group,
|
||||||
|
"Master",
|
||||||
|
),
|
||||||
|
children: Vec::new(),
|
||||||
|
expanded: false, // collapsed so it always renders a visible header row
|
||||||
|
tempo_map: Some(TempoMap::constant(bpm)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// Shared data structures and types
|
// Shared data structures and types
|
||||||
|
|
||||||
pub mod beat_time;
|
pub mod beat_time;
|
||||||
|
pub mod tempo_map;
|
||||||
pub mod gpu;
|
pub mod gpu;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod pane;
|
pub mod pane;
|
||||||
|
|
|
||||||
|
|
@ -391,9 +391,10 @@ pub fn render_layer_isolated(
|
||||||
let mut video_mgr = video_manager.lock().unwrap();
|
let mut video_mgr = video_manager.lock().unwrap();
|
||||||
let mut instances = Vec::new();
|
let mut instances = Vec::new();
|
||||||
|
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
for clip_instance in &video_layer.clip_instances {
|
for clip_instance in &video_layer.clip_instances {
|
||||||
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
||||||
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else { continue };
|
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else { continue };
|
||||||
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time) else { continue };
|
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time) else { continue };
|
||||||
|
|
||||||
// Evaluate animated transform properties.
|
// Evaluate animated transform properties.
|
||||||
|
|
@ -458,8 +459,9 @@ pub fn render_layer_isolated(
|
||||||
AnyLayer::Effect(effect_layer) => {
|
AnyLayer::Effect(effect_layer) => {
|
||||||
// Effect layers are processed during compositing, not rendered to scene
|
// Effect layers are processed during compositing, not rendered to scene
|
||||||
// Return early with a dedicated effect layer type
|
// Return early with a dedicated effect layer type
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
let active_effects: Vec<ClipInstance> = effect_layer
|
let active_effects: Vec<ClipInstance> = effect_layer
|
||||||
.active_clip_instances_at(time)
|
.active_clip_instances_at(time, tempo_map)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -728,16 +730,19 @@ fn render_clip_instance(
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remap timeline time to clip's internal time
|
// Remap timeline time to clip's internal time
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
let clip_time = if vector_clip.is_group {
|
let clip_time = if vector_clip.is_group {
|
||||||
// Groups are static — visible from timeline_start to the next keyframe boundary
|
// Groups are static — visible from timeline_start to the next keyframe boundary.
|
||||||
let end = group_end_time.unwrap_or(clip_instance.timeline_start);
|
// timeline_start is in beats; group_end_time is in seconds (render time).
|
||||||
if time < clip_instance.timeline_start || time >= end {
|
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
||||||
|
let end = group_end_time.unwrap_or(start_secs);
|
||||||
|
if time < start_secs || time >= end {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration);
|
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration);
|
||||||
let Some(t) = clip_instance.remap_time(time, clip_dur) else {
|
let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else {
|
||||||
return; // Clip instance not active at this time
|
return; // Clip instance not active at this time
|
||||||
};
|
};
|
||||||
t
|
t
|
||||||
|
|
@ -889,7 +894,8 @@ fn render_video_layer(
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remap timeline time to clip's internal time
|
// Remap timeline time to clip's internal time
|
||||||
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else {
|
let tempo_map = document.tempo_map();
|
||||||
|
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else {
|
||||||
continue; // Clip instance not active at this time
|
continue; // Clip instance not active at this time
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1553,13 +1559,15 @@ fn render_clip_instance_cpu(
|
||||||
) {
|
) {
|
||||||
let Some(vector_clip) = document.vector_clips.get(&clip_instance.clip_id) else { return };
|
let Some(vector_clip) = document.vector_clips.get(&clip_instance.clip_id) else { return };
|
||||||
|
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
let clip_time = if vector_clip.is_group {
|
let clip_time = if vector_clip.is_group {
|
||||||
let end = group_end_time.unwrap_or(clip_instance.timeline_start);
|
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
||||||
if time < clip_instance.timeline_start || time >= end { return; }
|
let end = group_end_time.unwrap_or(start_secs);
|
||||||
|
if time < start_secs || time >= end { return; }
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration);
|
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration);
|
||||||
let Some(t) = clip_instance.remap_time(time, clip_dur) else { return };
|
let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else { return };
|
||||||
t
|
t
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
channels: settings.channels,
|
||||||
bit_depth: settings.bit_depth,
|
bit_depth: settings.bit_depth,
|
||||||
mp3_bitrate: 320, // Not used for WAV/FLAC
|
mp3_bitrate: 320, // Not used for WAV/FLAC
|
||||||
start_time: settings.start_time,
|
start_time: daw_backend::Seconds(settings.start_time),
|
||||||
end_time: settings.end_time,
|
end_time: daw_backend::Seconds(settings.end_time),
|
||||||
|
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use the existing DAW backend export function
|
// Use the existing DAW backend export function
|
||||||
|
|
@ -105,8 +106,9 @@ fn export_audio_ffmpeg_mp3<P: AsRef<Path>>(
|
||||||
channels: settings.channels,
|
channels: settings.channels,
|
||||||
bit_depth: 16, // Unused
|
bit_depth: 16, // Unused
|
||||||
mp3_bitrate: settings.bitrate_kbps,
|
mp3_bitrate: settings.bitrate_kbps,
|
||||||
start_time: settings.start_time,
|
start_time: daw_backend::Seconds(settings.start_time),
|
||||||
end_time: settings.end_time,
|
end_time: daw_backend::Seconds(settings.end_time),
|
||||||
|
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step 1: Render audio to memory
|
// Step 1: Render audio to memory
|
||||||
|
|
@ -294,8 +296,9 @@ fn export_audio_ffmpeg_aac<P: AsRef<Path>>(
|
||||||
channels: settings.channels,
|
channels: settings.channels,
|
||||||
bit_depth: 16, // Unused
|
bit_depth: 16, // Unused
|
||||||
mp3_bitrate: settings.bitrate_kbps,
|
mp3_bitrate: settings.bitrate_kbps,
|
||||||
start_time: settings.start_time,
|
start_time: daw_backend::Seconds(settings.start_time),
|
||||||
end_time: settings.end_time,
|
end_time: daw_backend::Seconds(settings.end_time),
|
||||||
|
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step 1: Render audio to memory
|
// Step 1: Render audio to memory
|
||||||
|
|
|
||||||
|
|
@ -696,8 +696,9 @@ impl ExportOrchestrator {
|
||||||
channels: settings.channels,
|
channels: settings.channels,
|
||||||
bit_depth: settings.bit_depth,
|
bit_depth: settings.bit_depth,
|
||||||
mp3_bitrate: settings.bitrate_kbps,
|
mp3_bitrate: settings.bitrate_kbps,
|
||||||
start_time: settings.start_time,
|
start_time: daw_backend::Seconds(settings.start_time),
|
||||||
end_time: settings.end_time,
|
end_time: daw_backend::Seconds(settings.end_time),
|
||||||
|
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use DAW backend export for all formats
|
// Use DAW backend export for all formats
|
||||||
|
|
|
||||||
|
|
@ -882,7 +882,7 @@ fn composite_document_to_hdr(
|
||||||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||||
effect_def,
|
effect_def,
|
||||||
effect_instance.timeline_start,
|
effect_instance.timeline_start,
|
||||||
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
|
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, document.tempo_map()),
|
||||||
);
|
);
|
||||||
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
||||||
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
|
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,7 @@ impl From<MenuAction> for AppAction {
|
||||||
MenuAction::AddTestClip => Self::AddTestClip,
|
MenuAction::AddTestClip => Self::AddTestClip,
|
||||||
MenuAction::DeleteLayer => Self::DeleteLayer,
|
MenuAction::DeleteLayer => Self::DeleteLayer,
|
||||||
MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility,
|
MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility,
|
||||||
|
MenuAction::ShowMasterTrack => Self::ToggleLayerVisibility, // not directly mappable
|
||||||
MenuAction::NewKeyframe => Self::NewKeyframe,
|
MenuAction::NewKeyframe => Self::NewKeyframe,
|
||||||
MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe,
|
MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe,
|
||||||
MenuAction::DeleteFrame => Self::DeleteFrame,
|
MenuAction::DeleteFrame => Self::DeleteFrame,
|
||||||
|
|
|
||||||
|
|
@ -1538,6 +1538,21 @@ impl EditorApp {
|
||||||
fn sync_audio_layers_to_backend(&mut self) {
|
fn sync_audio_layers_to_backend(&mut self) {
|
||||||
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
|
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
|
||||||
|
|
||||||
|
// Ensure the master layer has a backend group track.
|
||||||
|
let master_layer_id = self.action_executor.document().master_layer.layer.id;
|
||||||
|
if !self.layer_to_track_map.contains_key(&master_layer_id) {
|
||||||
|
if let Some(ref controller_arc) = self.audio_controller {
|
||||||
|
let track_id_result = {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
controller.create_group_track_sync("[Master]".to_string(), None)
|
||||||
|
};
|
||||||
|
if let Ok(track_id) = track_id_result {
|
||||||
|
self.layer_to_track_map.insert(master_layer_id, track_id);
|
||||||
|
println!("✅ Created master bus track (TrackId: {})", track_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Collect audio layers from root and inside vector clips
|
// Collect audio layers from root and inside vector clips
|
||||||
// Each entry: (layer_id, layer_name, audio_type, parent_clip_id)
|
// Each entry: (layer_id, layer_name, audio_type, parent_clip_id)
|
||||||
let mut audio_layers_to_sync: Vec<(uuid::Uuid, String, AudioLayerType, Option<uuid::Uuid>)> = Vec::new();
|
let mut audio_layers_to_sync: Vec<(uuid::Uuid, String, AudioLayerType, Option<uuid::Uuid>)> = Vec::new();
|
||||||
|
|
@ -1707,6 +1722,17 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push subtrack graph for the master bus (all root-level tracks as its inputs).
|
||||||
|
let master_layer_id = self.action_executor.document().master_layer.layer.id;
|
||||||
|
if let Some(&master_track_id) = self.layer_to_track_map.get(&master_layer_id) {
|
||||||
|
let root_children = self.action_executor.document().root.children.clone();
|
||||||
|
let master_subtracks = self.build_subtrack_list_for_group(&root_children);
|
||||||
|
if let Some(ref controller_arc) = self.audio_controller {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
controller.set_metatrack_subtrack_graph(master_track_id, master_subtracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the ordered subtrack list for a group layer's direct children.
|
/// Build the ordered subtrack list for a group layer's direct children.
|
||||||
|
|
@ -1844,7 +1870,7 @@ impl EditorApp {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for instance in clip_instances {
|
for instance in clip_instances {
|
||||||
if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) {
|
if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) {
|
||||||
let effective_duration = instance.effective_duration(clip_duration);
|
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
|
||||||
let timeline_end = instance.timeline_start + effective_duration;
|
let timeline_end = instance.timeline_start + effective_duration;
|
||||||
|
|
||||||
const EPSILON: f64 = 0.001;
|
const EPSILON: f64 = 0.001;
|
||||||
|
|
@ -2698,7 +2724,7 @@ impl EditorApp {
|
||||||
let mut duplicate = original.clone();
|
let mut duplicate = original.clone();
|
||||||
duplicate.id = uuid::Uuid::new_v4();
|
duplicate.id = uuid::Uuid::new_v4();
|
||||||
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(1.0);
|
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(1.0);
|
||||||
let effective_duration = original.effective_duration(clip_duration);
|
let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
|
||||||
duplicate.timeline_start = original.timeline_start + effective_duration;
|
duplicate.timeline_start = original.timeline_start + effective_duration;
|
||||||
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
|
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
|
||||||
duplicate.clip_id = *new_clip_def_id;
|
duplicate.clip_id = *new_clip_def_id;
|
||||||
|
|
@ -3483,6 +3509,14 @@ impl EditorApp {
|
||||||
println!("Menu: Toggle Layer Visibility");
|
println!("Menu: Toggle Layer Visibility");
|
||||||
// TODO: Implement toggle layer visibility
|
// TODO: Implement toggle layer visibility
|
||||||
}
|
}
|
||||||
|
MenuAction::ShowMasterTrack => {
|
||||||
|
// Toggle show_master_track on all Timeline pane instances
|
||||||
|
for pane in self.pane_instances.values_mut() {
|
||||||
|
if let panes::PaneInstance::Timeline(t) = pane {
|
||||||
|
t.show_master_track = !t.show_master_track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Timeline menu
|
// Timeline menu
|
||||||
MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => {
|
MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => {
|
||||||
|
|
@ -3771,7 +3805,7 @@ impl EditorApp {
|
||||||
// Sync BPM/time signature to metronome
|
// Sync BPM/time signature to metronome
|
||||||
let doc = self.action_executor.document();
|
let doc = self.action_executor.document();
|
||||||
controller.set_tempo(
|
controller.set_tempo(
|
||||||
doc.bpm as f32,
|
doc.bpm() as f32,
|
||||||
(doc.time_signature.numerator, doc.time_signature.denominator),
|
(doc.time_signature.numerator, doc.time_signature.denominator),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3834,9 +3868,6 @@ impl EditorApp {
|
||||||
|
|
||||||
// Rebuild MIDI event cache for all MIDI clips (needed for timeline/piano roll rendering)
|
// Rebuild MIDI event cache for all MIDI clips (needed for timeline/piano roll rendering)
|
||||||
let step8_start = std::time::Instant::now();
|
let step8_start = std::time::Instant::now();
|
||||||
// Migrate old documents: compute beats/frames derived fields
|
|
||||||
self.action_executor.document_mut().sync_all_clip_positions();
|
|
||||||
|
|
||||||
self.midi_event_cache.clear();
|
self.midi_event_cache.clear();
|
||||||
let midi_clip_ids: Vec<u32> = self.action_executor.document()
|
let midi_clip_ids: Vec<u32> = self.action_executor.document()
|
||||||
.audio_clips.values()
|
.audio_clips.values()
|
||||||
|
|
@ -3859,19 +3890,6 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
eprintln!("📊 [APPLY] Step 8: Rebuilt MIDI event cache for {} clips in {:.2}ms", midi_fetched, step8_start.elapsed().as_secs_f64() * 1000.0);
|
eprintln!("📊 [APPLY] Step 8: Rebuilt MIDI event cache for {} clips in {:.2}ms", midi_fetched, step8_start.elapsed().as_secs_f64() * 1000.0);
|
||||||
|
|
||||||
// Sync beats/frames derived fields on MIDI events (migration for old documents)
|
|
||||||
{
|
|
||||||
let bpm = self.action_executor.document().bpm;
|
|
||||||
let fps = self.action_executor.document().framerate;
|
|
||||||
for events in self.midi_event_cache.values_mut() {
|
|
||||||
for ev in events.iter_mut() {
|
|
||||||
if ev.timestamp_beats == 0.0 && ev.timestamp.abs() > 1e-9 {
|
|
||||||
ev.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset playback state
|
// Reset playback state
|
||||||
self.playback_time = 0.0;
|
self.playback_time = 0.0;
|
||||||
self.is_playing = false;
|
self.is_playing = false;
|
||||||
|
|
@ -4002,15 +4020,11 @@ impl EditorApp {
|
||||||
/// Rebuild a MIDI event cache entry from backend note format.
|
/// Rebuild a MIDI event cache entry from backend note format.
|
||||||
/// Called after undo/redo to keep the cache consistent with the backend.
|
/// Called after undo/redo to keep the cache consistent with the backend.
|
||||||
fn rebuild_midi_cache_entry(&mut self, clip_id: u32, notes: &[(f64, u8, u8, f64)]) {
|
fn rebuild_midi_cache_entry(&mut self, clip_id: u32, notes: &[(f64, u8, u8, f64)]) {
|
||||||
let bpm = self.action_executor.document().bpm;
|
|
||||||
let fps = self.action_executor.document().framerate;
|
|
||||||
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(notes.len() * 2);
|
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(notes.len() * 2);
|
||||||
for &(start_time, note, velocity, duration) in notes {
|
for &(start_time, note, velocity, duration) in notes {
|
||||||
let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity);
|
let on = daw_backend::audio::midi::MidiEvent::note_on(daw_backend::Beats(start_time), 0, note, velocity);
|
||||||
on.sync_from_seconds(bpm, fps);
|
|
||||||
events.push(on);
|
events.push(on);
|
||||||
let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + duration, 0, note, 0);
|
let off = daw_backend::audio::midi::MidiEvent::note_off(daw_backend::Beats(start_time + duration), 0, note, 0);
|
||||||
off.sync_from_seconds(bpm, fps);
|
|
||||||
events.push(off);
|
events.push(off);
|
||||||
}
|
}
|
||||||
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||||
|
|
@ -4048,14 +4062,14 @@ impl EditorApp {
|
||||||
let frontend_clip_id = self.action_executor.document_mut().add_audio_clip(clip);
|
let frontend_clip_id = self.action_executor.document_mut().add_audio_clip(clip);
|
||||||
|
|
||||||
println!("Imported MIDI '{}' ({:.1}s, {} total events) - Frontend ID: {}, Backend ID: {}",
|
println!("Imported MIDI '{}' ({:.1}s, {} total events) - Frontend ID: {}, Backend ID: {}",
|
||||||
name, duration, event_count, frontend_clip_id, backend_clip_id);
|
name, duration.beats_to_f64(), event_count, frontend_clip_id, backend_clip_id);
|
||||||
|
|
||||||
Some(ImportedAssetInfo {
|
Some(ImportedAssetInfo {
|
||||||
clip_id: frontend_clip_id,
|
clip_id: frontend_clip_id,
|
||||||
clip_type: panes::DragClipType::AudioMidi,
|
clip_type: panes::DragClipType::AudioMidi,
|
||||||
name,
|
name,
|
||||||
dimensions: None,
|
dimensions: None,
|
||||||
duration,
|
duration: duration.beats_to_f64(),
|
||||||
linked_audio_clip_id: None,
|
linked_audio_clip_id: None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -5032,7 +5046,7 @@ impl eframe::App for EditorApp {
|
||||||
if let Some(doc_clip_id) = doc_clip_id {
|
if let Some(doc_clip_id) = doc_clip_id {
|
||||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
||||||
if clip.is_recording() {
|
if clip.is_recording() {
|
||||||
clip.duration = duration;
|
clip.duration = duration.seconds_to_f64();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5202,24 +5216,18 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
// Update the clip's duration so the timeline bar grows
|
// Update the clip's duration so the timeline bar grows
|
||||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
||||||
clip.duration = duration;
|
clip.duration = duration.beats_to_f64();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update midi_event_cache with notes captured so far
|
// Update midi_event_cache with notes captured so far.
|
||||||
// (inlined to avoid conflicting &mut self borrow)
|
// Notes from get_notes_with_active() are already in beats.
|
||||||
{
|
{
|
||||||
let bpm = self.action_executor.document().bpm;
|
|
||||||
let fps = self.action_executor.document().framerate;
|
|
||||||
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(notes.len() * 2);
|
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(notes.len() * 2);
|
||||||
for &(start_time, note, velocity, dur) in ¬es {
|
for &(start_beats, note, velocity, dur_beats) in ¬es {
|
||||||
let mut on = daw_backend::audio::midi::MidiEvent::note_on(start_time, 0, note, velocity);
|
events.push(daw_backend::audio::midi::MidiEvent::note_on(start_beats, 0, note, velocity));
|
||||||
on.sync_from_seconds(bpm, fps);
|
events.push(daw_backend::audio::midi::MidiEvent::note_off(start_beats + dur_beats, 0, note, 0));
|
||||||
events.push(on);
|
|
||||||
let mut off = daw_backend::audio::midi::MidiEvent::note_off(start_time + dur, 0, note, 0);
|
|
||||||
off.sync_from_seconds(bpm, fps);
|
|
||||||
events.push(off);
|
|
||||||
}
|
}
|
||||||
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||||
self.midi_event_cache.insert(clip_id, events);
|
self.midi_event_cache.insert(clip_id, events);
|
||||||
|
|
@ -5236,13 +5244,7 @@ impl eframe::App for EditorApp {
|
||||||
match controller.query_midi_clip(track_id, clip_id) {
|
match controller.query_midi_clip(track_id, clip_id) {
|
||||||
Ok(midi_clip_data) => {
|
Ok(midi_clip_data) => {
|
||||||
drop(controller);
|
drop(controller);
|
||||||
let bpm = self.action_executor.document().bpm;
|
self.midi_event_cache.insert(clip_id, midi_clip_data.events.clone());
|
||||||
let fps = self.action_executor.document().framerate;
|
|
||||||
let mut final_events = midi_clip_data.events.clone();
|
|
||||||
for ev in &mut final_events {
|
|
||||||
ev.sync_from_seconds(bpm, fps);
|
|
||||||
}
|
|
||||||
self.midi_event_cache.insert(clip_id, final_events);
|
|
||||||
|
|
||||||
// Update document clip with final duration and name
|
// Update document clip with final duration and name
|
||||||
let doc_clip_id = self.action_executor.document()
|
let doc_clip_id = self.action_executor.document()
|
||||||
|
|
@ -5655,10 +5657,14 @@ impl eframe::App for EditorApp {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Checked actions show "✔ Label"; hidden actions are not rendered at all
|
// Checked actions show "✔ Label"; hidden actions are not rendered at all
|
||||||
let checked: &[crate::menu::MenuAction] = if self.count_in_enabled && self.metronome_enabled {
|
let master_track_shown = self.pane_instances.values().any(|p| {
|
||||||
&[crate::menu::MenuAction::ToggleCountIn]
|
if let panes::PaneInstance::Timeline(t) = p { t.show_master_track } else { false }
|
||||||
} else {
|
});
|
||||||
&[]
|
let checked: &[crate::menu::MenuAction] = match (self.count_in_enabled && self.metronome_enabled, master_track_shown) {
|
||||||
|
(true, true) => &[crate::menu::MenuAction::ToggleCountIn, crate::menu::MenuAction::ShowMasterTrack],
|
||||||
|
(true, false) => &[crate::menu::MenuAction::ToggleCountIn],
|
||||||
|
(false, true) => &[crate::menu::MenuAction::ShowMasterTrack],
|
||||||
|
(false, false) => &[],
|
||||||
};
|
};
|
||||||
let hidden: &[crate::menu::MenuAction] = if timeline_is_measures && self.metronome_enabled {
|
let hidden: &[crate::menu::MenuAction] = if timeline_is_measures && self.metronome_enabled {
|
||||||
&[]
|
&[]
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,7 @@ pub enum MenuAction {
|
||||||
AddTestClip, // For testing: adds a test clip to the asset library
|
AddTestClip, // For testing: adds a test clip to the asset library
|
||||||
DeleteLayer,
|
DeleteLayer,
|
||||||
ToggleLayerVisibility,
|
ToggleLayerVisibility,
|
||||||
|
ShowMasterTrack,
|
||||||
|
|
||||||
// Timeline menu
|
// Timeline menu
|
||||||
NewKeyframe,
|
NewKeyframe,
|
||||||
|
|
@ -412,6 +413,7 @@ impl MenuItemDef {
|
||||||
const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip to Library", action: MenuAction::AddTestClip, shortcut: None };
|
const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip to Library", action: MenuAction::AddTestClip, shortcut: None };
|
||||||
const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None };
|
const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None };
|
||||||
const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None };
|
const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None };
|
||||||
|
const SHOW_MASTER_TRACK: Self = Self { label: "Show Master Track", action: MenuAction::ShowMasterTrack, shortcut: None };
|
||||||
|
|
||||||
// Timeline menu items
|
// Timeline menu items
|
||||||
const NEW_KEYFRAME: Self = Self { label: "New Keyframe", action: MenuAction::NewKeyframe, shortcut: Some(Shortcut::new(ShortcutKey::K, NO_CTRL, NO_SHIFT, NO_ALT)) };
|
const NEW_KEYFRAME: Self = Self { label: "New Keyframe", action: MenuAction::NewKeyframe, shortcut: Some(Shortcut::new(ShortcutKey::K, NO_CTRL, NO_SHIFT, NO_ALT)) };
|
||||||
|
|
@ -533,6 +535,8 @@ impl MenuItemDef {
|
||||||
MenuDef::Separator,
|
MenuDef::Separator,
|
||||||
MenuDef::Item(&Self::DELETE_LAYER),
|
MenuDef::Item(&Self::DELETE_LAYER),
|
||||||
MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY),
|
MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY),
|
||||||
|
MenuDef::Separator,
|
||||||
|
MenuDef::Item(&Self::SHOW_MASTER_TRACK),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Timeline menu
|
// Timeline menu
|
||||||
|
|
|
||||||
|
|
@ -391,10 +391,10 @@ fn generate_midi_thumbnail(
|
||||||
|
|
||||||
// Draw note events
|
// Draw note events
|
||||||
for event in events {
|
for event in events {
|
||||||
if !event.is_note_on() || event.timestamp > preview_duration {
|
if !event.is_note_on() || event.timestamp.beats_to_f64() > preview_duration {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (timestamp, note_number) = (event.timestamp, event.data1);
|
let (timestamp, note_number) = (event.timestamp.beats_to_f64(), event.data1);
|
||||||
|
|
||||||
let x = ((timestamp / preview_duration) * size as f64) as usize;
|
let x = ((timestamp / preview_duration) * size as f64) as usize;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1147,7 +1147,7 @@ impl InfopanelPane {
|
||||||
|
|
||||||
let clip_dur = document.get_clip_duration(&ci.clip_id)
|
let clip_dur = document.get_clip_duration(&ci.clip_id)
|
||||||
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start);
|
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start);
|
||||||
let total_dur = ci.total_duration(clip_dur);
|
let total_dur = ci.total_duration(clip_dur, document.tempo_map());
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Duration:");
|
ui.label("Duration:");
|
||||||
ui.label(format!("{:.2}s", total_dur));
|
ui.label(format!("{:.2}s", total_dur));
|
||||||
|
|
@ -1215,10 +1215,10 @@ impl InfopanelPane {
|
||||||
let mut pending: std::collections::HashMap<u8, (f64, u8)> = std::collections::HashMap::new();
|
let mut pending: std::collections::HashMap<u8, (f64, u8)> = std::collections::HashMap::new();
|
||||||
for event in events {
|
for event in events {
|
||||||
if event.is_note_on() {
|
if event.is_note_on() {
|
||||||
pending.insert(event.data1, (event.timestamp, event.data2));
|
pending.insert(event.data1, (event.timestamp.beats_to_f64(), event.data2));
|
||||||
} else if event.is_note_off() {
|
} else if event.is_note_off() {
|
||||||
if let Some((start, v)) = pending.remove(&event.data1) {
|
if let Some((start, v)) = pending.remove(&event.data1) {
|
||||||
notes.push((start, event.data1, v, event.timestamp - start));
|
notes.push((start, event.data1, v, event.timestamp.beats_to_f64() - start));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,10 +255,10 @@ impl PianoRollPane {
|
||||||
for event in events {
|
for event in events {
|
||||||
let channel = event.status & 0x0F;
|
let channel = event.status & 0x0F;
|
||||||
if event.is_note_on() {
|
if event.is_note_on() {
|
||||||
active.insert(event.data1, (event.timestamp, event.data2, channel));
|
active.insert(event.data1, (event.timestamp.beats_to_f64(), event.data2, channel));
|
||||||
} else if event.is_note_off() {
|
} else if event.is_note_off() {
|
||||||
if let Some((start, vel, ch)) = active.remove(&event.data1) {
|
if let Some((start, vel, ch)) = active.remove(&event.data1) {
|
||||||
let duration = (event.timestamp - start).max(MIN_NOTE_DURATION);
|
let duration = (event.timestamp.beats_to_f64() - start).max(MIN_NOTE_DURATION);
|
||||||
notes.push(ResolvedNote {
|
notes.push(ResolvedNote {
|
||||||
note: event.data1,
|
note: event.data1,
|
||||||
channel: ch,
|
channel: ch,
|
||||||
|
|
@ -292,13 +292,13 @@ impl PianoRollPane {
|
||||||
|
|
||||||
// ── Ruler interval calculation ───────────────────────────────────────
|
// ── Ruler interval calculation ───────────────────────────────────────
|
||||||
|
|
||||||
fn ruler_interval(&self, bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 {
|
fn ruler_interval(&self, tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 {
|
||||||
let min_pixel_gap = 80.0;
|
let min_pixel_gap = 80.0;
|
||||||
let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64;
|
let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64;
|
||||||
|
|
||||||
// Use beat-aligned intervals
|
// Use beat-aligned intervals
|
||||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
|
||||||
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
|
let measure_dur = lightningbeam_core::beat_time::measure_duration(0.0, tempo_map, time_sig);
|
||||||
let beat_intervals = [
|
let beat_intervals = [
|
||||||
beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0,
|
beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0,
|
||||||
measure_dur, measure_dur * 2.0, measure_dur * 4.0,
|
measure_dur, measure_dur * 2.0, measure_dur * 4.0,
|
||||||
|
|
@ -315,8 +315,8 @@ impl PianoRollPane {
|
||||||
|
|
||||||
} // end impl PianoRollPane (snap helpers follow as free functions)
|
} // end impl PianoRollPane (snap helpers follow as free functions)
|
||||||
|
|
||||||
fn snap_to_value(t: f64, snap: SnapValue, bpm: f64) -> f64 {
|
fn snap_to_value(t: f64, snap: SnapValue, tempo_map: &daw_backend::TempoMap) -> f64 {
|
||||||
let beat = 60.0 / bpm;
|
let beat = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
|
||||||
match snap {
|
match snap {
|
||||||
SnapValue::None => t,
|
SnapValue::None => t,
|
||||||
SnapValue::Whole => round_to_grid(t, beat * 4.0),
|
SnapValue::Whole => round_to_grid(t, beat * 4.0),
|
||||||
|
|
@ -347,7 +347,7 @@ fn snap_swing(t: f64, cell: f64, ratio: f64) -> f64 {
|
||||||
*cands.iter().min_by(|&&a, &&b| (a - t).abs().partial_cmp(&(b - t).abs()).unwrap()).unwrap()
|
*cands.iter().min_by(|&&a, &&b| (a - t).abs().partial_cmp(&(b - t).abs()).unwrap()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue {
|
fn detect_snap(notes: &[&ResolvedNote], tempo_map: &daw_backend::TempoMap) -> SnapValue {
|
||||||
const EPS: f64 = 0.002;
|
const EPS: f64 = 0.002;
|
||||||
if notes.is_empty() { return SnapValue::None; }
|
if notes.is_empty() { return SnapValue::None; }
|
||||||
let order = [
|
let order = [
|
||||||
|
|
@ -359,7 +359,7 @@ fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue {
|
||||||
SnapValue::SixteenthTriplet, SnapValue::ThirtySecondTriplet,
|
SnapValue::SixteenthTriplet, SnapValue::ThirtySecondTriplet,
|
||||||
];
|
];
|
||||||
for &sv in &order {
|
for &sv in &order {
|
||||||
if notes.iter().all(|n| (snap_to_value(n.start_time, sv, bpm) - n.start_time).abs() < EPS) {
|
if notes.iter().all(|n| (snap_to_value(n.start_time, sv, tempo_map) - n.start_time).abs() < EPS) {
|
||||||
return sv;
|
return sv;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -425,7 +425,7 @@ impl PianoRollPane {
|
||||||
let clip_doc_id = clip_doc_id; // doc-side AudioClip uuid
|
let clip_doc_id = clip_doc_id; // doc-side AudioClip uuid
|
||||||
let duration = mc.external_duration;
|
let duration = mc.external_duration;
|
||||||
let instance_uuid = Uuid::nil(); // no doc-side instance uuid yet
|
let instance_uuid = Uuid::nil(); // no doc-side instance uuid yet
|
||||||
clip_data.push((mc.clip_id, mc.external_start, mc.internal_start, duration, instance_uuid));
|
clip_data.push((mc.clip_id, mc.external_start.beats_to_f64(), mc.internal_start.beats_to_f64(), duration.beats_to_f64(), instance_uuid));
|
||||||
let _ = clip_doc_id; // used above for the if-let pattern
|
let _ = clip_doc_id; // used above for the if-let pattern
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -435,7 +435,7 @@ impl PianoRollPane {
|
||||||
for instance in &audio_layer.clip_instances {
|
for instance in &audio_layer.clip_instances {
|
||||||
if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
|
if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
|
||||||
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type {
|
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type {
|
||||||
let duration = instance.effective_duration(clip.duration);
|
let duration = instance.effective_duration(clip.duration, document.tempo_map());
|
||||||
clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id));
|
clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -456,8 +456,8 @@ impl PianoRollPane {
|
||||||
self.snap_user_changed = false;
|
self.snap_user_changed = false;
|
||||||
if self.snap_value != SnapValue::None && !self.selected_note_indices.is_empty() {
|
if self.snap_value != SnapValue::None && !self.selected_note_indices.is_empty() {
|
||||||
if let Some(clip_id) = self.selected_clip_id {
|
if let Some(clip_id) = self.selected_clip_id {
|
||||||
let bpm = shared.action_executor.document().bpm;
|
let tempo_map = shared.action_executor.document().tempo_map().clone();
|
||||||
self.quantize_selected_notes(clip_id, bpm, shared);
|
self.quantize_selected_notes(clip_id, &tempo_map, shared);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -484,11 +484,11 @@ impl PianoRollPane {
|
||||||
|
|
||||||
// Render grid (clipped to grid area)
|
// Render grid (clipped to grid area)
|
||||||
let grid_painter = ui.painter_at(grid_rect);
|
let grid_painter = ui.painter_at(grid_rect);
|
||||||
let (grid_bpm, grid_time_sig) = {
|
let (grid_tempo_map, grid_time_sig) = {
|
||||||
let doc = shared.action_executor.document();
|
let doc = shared.action_executor.document();
|
||||||
(doc.bpm, doc.time_signature.clone())
|
(doc.tempo_map().clone(), doc.time_signature.clone())
|
||||||
};
|
};
|
||||||
self.render_grid(&grid_painter, grid_rect, grid_bpm, &grid_time_sig);
|
self.render_grid(&grid_painter, grid_rect, &grid_tempo_map, &grid_time_sig);
|
||||||
|
|
||||||
// Render clip boundaries and notes
|
// Render clip boundaries and notes
|
||||||
for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data {
|
for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data {
|
||||||
|
|
@ -621,7 +621,7 @@ impl PianoRollPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect,
|
fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect,
|
||||||
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
|
tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||||
// Horizontal lines (note separators)
|
// Horizontal lines (note separators)
|
||||||
for note in MIN_NOTE..=MAX_NOTE {
|
for note in MIN_NOTE..=MAX_NOTE {
|
||||||
let y = self.note_to_y(note, grid_rect);
|
let y = self.note_to_y(note, grid_rect);
|
||||||
|
|
@ -648,9 +648,9 @@ impl PianoRollPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertical lines (beat-aligned time grid)
|
// Vertical lines (beat-aligned time grid)
|
||||||
let interval = self.ruler_interval(bpm, time_sig);
|
let interval = self.ruler_interval(tempo_map, time_sig);
|
||||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
let beat_dur = lightningbeam_core::beat_time::beat_duration(0.0, tempo_map);
|
||||||
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
|
let measure_dur = lightningbeam_core::beat_time::measure_duration(0.0, tempo_map, time_sig);
|
||||||
|
|
||||||
let start = (self.viewport_start_time / interval).floor() as i64;
|
let start = (self.viewport_start_time / interval).floor() as i64;
|
||||||
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
||||||
|
|
@ -675,7 +675,7 @@ impl PianoRollPane {
|
||||||
|
|
||||||
// Labels at measure boundaries
|
// Labels at measure boundaries
|
||||||
if is_measure && x > grid_rect.min.x + 20.0 {
|
if is_measure && x > grid_rect.min.x + 20.0 {
|
||||||
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
|
let pos = lightningbeam_core::beat_time::time_to_measure(time, tempo_map, time_sig);
|
||||||
painter.text(
|
painter.text(
|
||||||
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
||||||
Align2::LEFT_TOP,
|
Align2::LEFT_TOP,
|
||||||
|
|
@ -685,7 +685,7 @@ impl PianoRollPane {
|
||||||
);
|
);
|
||||||
} else if is_beat && !is_measure && x > grid_rect.min.x + 20.0
|
} else if is_beat && !is_measure && x > grid_rect.min.x + 20.0
|
||||||
&& beat_dur as f32 * self.pixels_per_second > 40.0 {
|
&& beat_dur as f32 * self.pixels_per_second > 40.0 {
|
||||||
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
|
let pos = lightningbeam_core::beat_time::time_to_measure(time, tempo_map, time_sig);
|
||||||
painter.text(
|
painter.text(
|
||||||
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
||||||
Align2::LEFT_TOP,
|
Align2::LEFT_TOP,
|
||||||
|
|
@ -708,8 +708,8 @@ impl PianoRollPane {
|
||||||
) -> f32 {
|
) -> f32 {
|
||||||
let mut peak = 0.0f32;
|
let mut peak = 0.0f32;
|
||||||
for ev in events {
|
for ev in events {
|
||||||
if ev.timestamp > note_end + 0.01 { break; }
|
if ev.timestamp.beats_to_f64() > note_end + 0.01 { break; }
|
||||||
if ev.timestamp >= note_start - 0.01
|
if ev.timestamp.beats_to_f64() >= note_start - 0.01
|
||||||
&& (ev.status & 0xF0) == 0xE0
|
&& (ev.status & 0xF0) == 0xE0
|
||||||
&& (ev.status & 0x0F) == channel
|
&& (ev.status & 0x0F) == channel
|
||||||
{
|
{
|
||||||
|
|
@ -770,7 +770,7 @@ impl PianoRollPane {
|
||||||
};
|
};
|
||||||
let timestamp = note_start + t * note_duration;
|
let timestamp = note_start + t * note_duration;
|
||||||
let (lsb, msb) = encode_bend(normalized);
|
let (lsb, msb) = encode_bend(normalized);
|
||||||
events.push(MidiEvent::new(timestamp, 0xE0 | channel, lsb, msb));
|
events.push(MidiEvent::new(daw_backend::Beats(timestamp), 0xE0 | channel, lsb, msb));
|
||||||
}
|
}
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
@ -794,11 +794,11 @@ impl PianoRollPane {
|
||||||
let ch = ev.status & 0x0F;
|
let ch = ev.status & 0x0F;
|
||||||
let msg = ev.status & 0xF0;
|
let msg = ev.status & 0xF0;
|
||||||
if msg == 0x90 && ev.data2 > 0 {
|
if msg == 0x90 && ev.data2 > 0 {
|
||||||
active.insert((ev.data1, ch), ev.timestamp);
|
active.insert((ev.data1, ch), ev.timestamp.beats_to_f64());
|
||||||
} else if msg == 0x80 || (msg == 0x90 && ev.data2 == 0) {
|
} else if msg == 0x80 || (msg == 0x90 && ev.data2 == 0) {
|
||||||
if let Some(start) = active.remove(&(ev.data1, ch)) {
|
if let Some(start) = active.remove(&(ev.data1, ch)) {
|
||||||
// Overlaps target range and is NOT the note we're assigning
|
// Overlaps target range and is NOT the note we're assigning
|
||||||
if start < note_end && ev.timestamp > note_start
|
if start < note_end && ev.timestamp.beats_to_f64() > note_start
|
||||||
&& !(ev.data1 == note_pitch && ch == current_channel)
|
&& !(ev.data1 == note_pitch && ch == current_channel)
|
||||||
{
|
{
|
||||||
used[ch as usize] = true;
|
used[ch as usize] = true;
|
||||||
|
|
@ -828,9 +828,9 @@ impl PianoRollPane {
|
||||||
fn find_cc1_for_note(events: &[daw_backend::audio::midi::MidiEvent], note_start: f64, note_end: f64, channel: u8) -> u8 {
|
fn find_cc1_for_note(events: &[daw_backend::audio::midi::MidiEvent], note_start: f64, note_end: f64, channel: u8) -> u8 {
|
||||||
let mut cc1 = 0u8;
|
let mut cc1 = 0u8;
|
||||||
for ev in events {
|
for ev in events {
|
||||||
if ev.timestamp > note_end { break; }
|
if ev.timestamp.beats_to_f64() > note_end { break; }
|
||||||
if (ev.status & 0xF0) == 0xB0 && (ev.status & 0x0F) == channel && ev.data1 == 1 {
|
if (ev.status & 0xF0) == 0xB0 && (ev.status & 0x0F) == channel && ev.data1 == 1 {
|
||||||
if ev.timestamp <= note_start {
|
if ev.timestamp.beats_to_f64() <= note_start {
|
||||||
cc1 = ev.data2;
|
cc1 = ev.data2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -950,7 +950,7 @@ impl PianoRollPane {
|
||||||
let ts = note.start_time + t as f64 * note.duration;
|
let ts = note.start_time + t as f64 * note.duration;
|
||||||
let mut existing_norm = 0.0f32;
|
let mut existing_norm = 0.0f32;
|
||||||
for ev in events {
|
for ev in events {
|
||||||
if ev.timestamp > ts { break; }
|
if ev.timestamp.beats_to_f64() > ts { break; }
|
||||||
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == drag_ch {
|
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == drag_ch {
|
||||||
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
|
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
|
||||||
existing_norm = (raw - 8192) as f32 / 8192.0;
|
existing_norm = (raw - 8192) as f32 / 8192.0;
|
||||||
|
|
@ -991,7 +991,7 @@ impl PianoRollPane {
|
||||||
// Find last pitch bend event at or before ts
|
// Find last pitch bend event at or before ts
|
||||||
let mut bend_norm = 0.0f32;
|
let mut bend_norm = 0.0f32;
|
||||||
for ev in events {
|
for ev in events {
|
||||||
if ev.timestamp > ts { break; }
|
if ev.timestamp.beats_to_f64() > ts { break; }
|
||||||
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == note.channel {
|
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == note.channel {
|
||||||
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
|
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
|
||||||
bend_norm = (raw - 8192) as f32 / 8192.0;
|
bend_norm = (raw - 8192) as f32 / 8192.0;
|
||||||
|
|
@ -1037,9 +1037,9 @@ impl PianoRollPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect,
|
fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect,
|
||||||
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
|
tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||||
// Collect visible time grid positions
|
// Collect visible time grid positions
|
||||||
let interval = self.ruler_interval(bpm, time_sig);
|
let interval = self.ruler_interval(tempo_map, time_sig);
|
||||||
let start = (self.viewport_start_time / interval).floor() as i64;
|
let start = (self.viewport_start_time / interval).floor() as i64;
|
||||||
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
||||||
let end = (end_time / interval).ceil() as i64;
|
let end = (end_time / interval).ceil() as i64;
|
||||||
|
|
@ -1374,10 +1374,10 @@ impl PianoRollPane {
|
||||||
if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) {
|
if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) {
|
||||||
let clip_start = selected_clip.1;
|
let clip_start = selected_clip.1;
|
||||||
let trim_start = selected_clip.2;
|
let trim_start = selected_clip.2;
|
||||||
let bpm = shared.action_executor.document().bpm;
|
let tempo_map = shared.action_executor.document().tempo_map();
|
||||||
let clip_local_time = snap_to_value(
|
let clip_local_time = snap_to_value(
|
||||||
(time - clip_start).max(0.0) + trim_start,
|
(time - clip_start).max(0.0) + trim_start,
|
||||||
self.snap_value, bpm,
|
self.snap_value, tempo_map,
|
||||||
);
|
);
|
||||||
self.creating_note = Some(TempNote {
|
self.creating_note = Some(TempNote {
|
||||||
note,
|
note,
|
||||||
|
|
@ -1395,8 +1395,8 @@ impl PianoRollPane {
|
||||||
self.selection_rect = Some((pos, pos));
|
self.selection_rect = Some((pos, pos));
|
||||||
self.drag_mode = Some(DragMode::SelectRect);
|
self.drag_mode = Some(DragMode::SelectRect);
|
||||||
|
|
||||||
let bpm = shared.action_executor.document().bpm;
|
let tempo_map = shared.action_executor.document().tempo_map();
|
||||||
let seek_time = snap_to_value(time.max(0.0), self.snap_value, bpm);
|
let seek_time = snap_to_value(time.max(0.0), self.snap_value, tempo_map);
|
||||||
*shared.playback_time = seek_time;
|
*shared.playback_time = seek_time;
|
||||||
if let Some(ctrl) = shared.audio_controller.as_ref() {
|
if let Some(ctrl) = shared.audio_controller.as_ref() {
|
||||||
if let Ok(mut c) = ctrl.lock() { c.seek(seek_time); }
|
if let Ok(mut c) = ctrl.lock() { c.seek(seek_time); }
|
||||||
|
|
@ -1538,7 +1538,7 @@ impl PianoRollPane {
|
||||||
let ts = note_start + t * note_duration;
|
let ts = note_start + t * note_duration;
|
||||||
let mut bend = 0.0f32;
|
let mut bend = 0.0f32;
|
||||||
for ev in &new_events {
|
for ev in &new_events {
|
||||||
if ev.timestamp > ts { break; }
|
if ev.timestamp.beats_to_f64() > ts { break; }
|
||||||
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel {
|
if (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel {
|
||||||
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
|
let raw = ((ev.data2 as i16) << 7) | (ev.data1 as i16);
|
||||||
bend = (raw - 8192) as f32 / 8192.0;
|
bend = (raw - 8192) as f32 / 8192.0;
|
||||||
|
|
@ -1550,7 +1550,8 @@ impl PianoRollPane {
|
||||||
// Remove old bend events in range before writing combined
|
// Remove old bend events in range before writing combined
|
||||||
new_events.retain(|ev| {
|
new_events.retain(|ev| {
|
||||||
let is_bend = (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel;
|
let is_bend = (ev.status & 0xF0) == 0xE0 && (ev.status & 0x0F) == target_channel;
|
||||||
let in_range = ev.timestamp >= note_start - 0.001 && ev.timestamp <= note_start + note_duration + 0.01;
|
let ts = ev.timestamp.beats_to_f64();
|
||||||
|
let in_range = ts >= note_start - 0.001 && ts <= note_start + note_duration + 0.01;
|
||||||
!(is_bend && in_range)
|
!(is_bend && in_range)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1568,20 +1569,15 @@ impl PianoRollPane {
|
||||||
let combined = (existing_norm[i] + zone_norm).clamp(-1.0, 1.0);
|
let combined = (existing_norm[i] + zone_norm).clamp(-1.0, 1.0);
|
||||||
let (lsb, msb) = encode_bend(combined);
|
let (lsb, msb) = encode_bend(combined);
|
||||||
let ts = note_start + i as f64 / num_steps as f64 * note_duration;
|
let ts = note_start + i as f64 / num_steps as f64 * note_duration;
|
||||||
new_events.push(daw_backend::audio::midi::MidiEvent::new(ts, 0xE0 | target_channel, lsb, msb));
|
new_events.push(daw_backend::audio::midi::MidiEvent::new(daw_backend::Beats(ts), 0xE0 | target_channel, lsb, msb));
|
||||||
}
|
}
|
||||||
// For End zone: reset just after note ends so it doesn't bleed into next note
|
// For End zone: reset just after note ends so it doesn't bleed into next note
|
||||||
if zone == PitchBendZone::End {
|
if zone == PitchBendZone::End {
|
||||||
let (lsb, msb) = encode_bend(0.0);
|
let (lsb, msb) = encode_bend(0.0);
|
||||||
new_events.push(daw_backend::audio::midi::MidiEvent::new(note_start + note_duration + 0.005, 0xE0 | target_channel, lsb, msb));
|
new_events.push(daw_backend::audio::midi::MidiEvent::new(daw_backend::Beats(note_start + note_duration + 0.005), 0xE0 | target_channel, lsb, msb));
|
||||||
}
|
}
|
||||||
|
|
||||||
new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal));
|
new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
{
|
|
||||||
let doc = shared.action_executor.document();
|
|
||||||
let bpm = doc.bpm; let fps = doc.framerate;
|
|
||||||
for ev in &mut new_events { ev.sync_from_seconds(bpm, fps); }
|
|
||||||
}
|
|
||||||
self.push_events_action("Set pitch bend", clip_id, old_events, new_events.clone(), shared);
|
self.push_events_action("Set pitch bend", clip_id, old_events, new_events.clone(), shared);
|
||||||
shared.midi_event_cache.insert(clip_id, new_events);
|
shared.midi_event_cache.insert(clip_id, new_events);
|
||||||
}
|
}
|
||||||
|
|
@ -1737,8 +1733,8 @@ impl PianoRollPane {
|
||||||
fn update_cache_from_resolved(clip_id: u32, resolved: &[ResolvedNote], shared: &mut SharedPaneState) {
|
fn update_cache_from_resolved(clip_id: u32, resolved: &[ResolvedNote], shared: &mut SharedPaneState) {
|
||||||
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(resolved.len() * 2);
|
let mut events: Vec<daw_backend::audio::midi::MidiEvent> = Vec::with_capacity(resolved.len() * 2);
|
||||||
for n in resolved {
|
for n in resolved {
|
||||||
events.push(daw_backend::audio::midi::MidiEvent::note_on(n.start_time, 0, n.note, n.velocity));
|
events.push(daw_backend::audio::midi::MidiEvent::note_on(daw_backend::Beats(n.start_time), 0, n.note, n.velocity));
|
||||||
events.push(daw_backend::audio::midi::MidiEvent::note_off(n.start_time + n.duration, 0, n.note, 0));
|
events.push(daw_backend::audio::midi::MidiEvent::note_off(daw_backend::Beats(n.start_time + n.duration), 0, n.note, 0));
|
||||||
}
|
}
|
||||||
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
|
||||||
shared.midi_event_cache.insert(clip_id, events);
|
shared.midi_event_cache.insert(clip_id, events);
|
||||||
|
|
@ -1787,12 +1783,12 @@ impl PianoRollPane {
|
||||||
let resolved = Self::resolve_notes(events);
|
let resolved = Self::resolve_notes(events);
|
||||||
let old_notes = Self::notes_to_backend_format(&resolved);
|
let old_notes = Self::notes_to_backend_format(&resolved);
|
||||||
|
|
||||||
let bpm = shared.action_executor.document().bpm;
|
let tempo_map = shared.action_executor.document().tempo_map();
|
||||||
let mut new_resolved = resolved.clone();
|
let mut new_resolved = resolved.clone();
|
||||||
for &idx in &self.selected_note_indices {
|
for &idx in &self.selected_note_indices {
|
||||||
if idx < new_resolved.len() {
|
if idx < new_resolved.len() {
|
||||||
let raw_time = (new_resolved[idx].start_time + dt).max(0.0);
|
let raw_time = (new_resolved[idx].start_time + dt).max(0.0);
|
||||||
new_resolved[idx].start_time = snap_to_value(raw_time, self.snap_value, bpm);
|
new_resolved[idx].start_time = snap_to_value(raw_time, self.snap_value, tempo_map);
|
||||||
new_resolved[idx].note = (new_resolved[idx].note as i32 + dn).clamp(0, 127) as u8;
|
new_resolved[idx].note = (new_resolved[idx].note as i32 + dn).clamp(0, 127) as u8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1968,7 +1964,7 @@ impl PianoRollPane {
|
||||||
shared.pending_actions.push(Box::new(action));
|
shared.pending_actions.push(Box::new(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn quantize_selected_notes(&mut self, clip_id: u32, bpm: f64, shared: &mut SharedPaneState) {
|
fn quantize_selected_notes(&mut self, clip_id: u32, tempo_map: &daw_backend::TempoMap, shared: &mut SharedPaneState) {
|
||||||
let events = match shared.midi_event_cache.get(&clip_id) { Some(e) => e, None => return };
|
let events = match shared.midi_event_cache.get(&clip_id) { Some(e) => e, None => return };
|
||||||
let resolved = Self::resolve_notes(events);
|
let resolved = Self::resolve_notes(events);
|
||||||
let old_notes = Self::notes_to_backend_format(&resolved);
|
let old_notes = Self::notes_to_backend_format(&resolved);
|
||||||
|
|
@ -1976,7 +1972,7 @@ impl PianoRollPane {
|
||||||
for &idx in &self.selected_note_indices {
|
for &idx in &self.selected_note_indices {
|
||||||
if idx < new_resolved.len() {
|
if idx < new_resolved.len() {
|
||||||
new_resolved[idx].start_time =
|
new_resolved[idx].start_time =
|
||||||
snap_to_value(new_resolved[idx].start_time, self.snap_value, bpm).max(0.0);
|
snap_to_value(new_resolved[idx].start_time, self.snap_value, tempo_map).max(0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let new_notes = Self::notes_to_backend_format(&new_resolved);
|
let new_notes = Self::notes_to_backend_format(&new_resolved);
|
||||||
|
|
@ -2075,11 +2071,11 @@ impl PianoRollPane {
|
||||||
// Dot grid background (visible where the spectrogram doesn't draw)
|
// Dot grid background (visible where the spectrogram doesn't draw)
|
||||||
let grid_painter = ui.painter_at(view_rect);
|
let grid_painter = ui.painter_at(view_rect);
|
||||||
{
|
{
|
||||||
let (dot_bpm, dot_ts) = {
|
let (dot_tempo_map, dot_ts) = {
|
||||||
let doc = shared.action_executor.document();
|
let doc = shared.action_executor.document();
|
||||||
(doc.bpm, doc.time_signature.clone())
|
(doc.tempo_map().clone(), doc.time_signature.clone())
|
||||||
};
|
};
|
||||||
self.render_dot_grid(&grid_painter, view_rect, dot_bpm, &dot_ts);
|
self.render_dot_grid(&grid_painter, view_rect, &dot_tempo_map, &dot_ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find audio pool index for the active layer's clips
|
// Find audio pool index for the active layer's clips
|
||||||
|
|
@ -2323,7 +2319,7 @@ impl PaneRenderer for PianoRollPane {
|
||||||
for ev in cached.iter_mut() {
|
for ev in cached.iter_mut() {
|
||||||
if ev.is_note_on() && ev.data1 == sn.note
|
if ev.is_note_on() && ev.data1 == sn.note
|
||||||
&& (ev.status & 0x0F) == sn.channel
|
&& (ev.status & 0x0F) == sn.channel
|
||||||
&& (ev.timestamp - sn.start_time).abs() < 1e-6
|
&& (ev.timestamp.beats_to_f64() - sn.start_time).abs() < 1e-6
|
||||||
{
|
{
|
||||||
ev.data2 = new_vel;
|
ev.data2 = new_vel;
|
||||||
}
|
}
|
||||||
|
|
@ -2360,21 +2356,16 @@ impl PaneRenderer for PianoRollPane {
|
||||||
let is_cc1 = (ev.status & 0xF0) == 0xB0
|
let is_cc1 = (ev.status & 0xF0) == 0xB0
|
||||||
&& (ev.status & 0x0F) == sn.channel
|
&& (ev.status & 0x0F) == sn.channel
|
||||||
&& ev.data1 == 1;
|
&& ev.data1 == 1;
|
||||||
let at_start = (ev.timestamp - sn.start_time).abs() < 0.001;
|
let at_start = (ev.timestamp.beats_to_f64() - sn.start_time).abs() < 0.001;
|
||||||
!(is_cc1 && at_start)
|
!(is_cc1 && at_start)
|
||||||
});
|
});
|
||||||
if new_cc1 > 0 {
|
if new_cc1 > 0 {
|
||||||
new_events.push(daw_backend::audio::midi::MidiEvent::new(
|
new_events.push(daw_backend::audio::midi::MidiEvent::new(
|
||||||
sn.start_time, 0xB0 | sn.channel, 1, new_cc1,
|
daw_backend::Beats(sn.start_time), 0xB0 | sn.channel, 1, new_cc1,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal));
|
new_events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
{
|
|
||||||
let doc = shared.action_executor.document();
|
|
||||||
let bpm = doc.bpm; let fps = doc.framerate;
|
|
||||||
for ev in &mut new_events { ev.sync_from_seconds(bpm, fps); }
|
|
||||||
}
|
|
||||||
self.push_events_action("Set modulation", clip_id, old_events, new_events.clone(), shared);
|
self.push_events_action("Set modulation", clip_id, old_events, new_events.clone(), shared);
|
||||||
shared.midi_event_cache.insert(clip_id, new_events);
|
shared.midi_event_cache.insert(clip_id, new_events);
|
||||||
}
|
}
|
||||||
|
|
@ -2416,7 +2407,7 @@ impl PaneRenderer for PianoRollPane {
|
||||||
// Snap-to dropdown — only in Measures mode
|
// Snap-to dropdown — only in Measures mode
|
||||||
let doc = shared.action_executor.document();
|
let doc = shared.action_executor.document();
|
||||||
let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures;
|
let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures;
|
||||||
let bpm = doc.bpm;
|
let tempo_map = doc.tempo_map();
|
||||||
drop(doc);
|
drop(doc);
|
||||||
|
|
||||||
if is_measures {
|
if is_measures {
|
||||||
|
|
@ -2429,7 +2420,7 @@ impl PaneRenderer for PianoRollPane {
|
||||||
let sel: Vec<&ResolvedNote> = self.selected_note_indices.iter()
|
let sel: Vec<&ResolvedNote> = self.selected_note_indices.iter()
|
||||||
.filter_map(|&i| resolved.get(i))
|
.filter_map(|&i| resolved.get(i))
|
||||||
.collect();
|
.collect();
|
||||||
self.snap_value = detect_snap(&sel, bpm);
|
self.snap_value = detect_snap(&sel, tempo_map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1438,7 +1438,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||||
effect_def,
|
effect_def,
|
||||||
effect_instance.timeline_start,
|
effect_instance.timeline_start,
|
||||||
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
|
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, self.ctx.document.tempo_map()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Acquire temp buffer for effect output (HDR format)
|
// Acquire temp buffer for effect output (HDR format)
|
||||||
|
|
@ -1793,7 +1793,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
||||||
// Skip clip instances not active at current time
|
// Skip clip instances not active at current time
|
||||||
let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
||||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur);
|
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur, self.ctx.document.tempo_map());
|
||||||
if self.ctx.playback_time < clip_instance.timeline_start || self.ctx.playback_time >= instance_end {
|
if self.ctx.playback_time < clip_instance.timeline_start || self.ctx.playback_time >= instance_end {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -2214,7 +2214,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
// Find clip instance visible at playback time
|
// Find clip instance visible at playback time
|
||||||
let visible_clip = video_layer.clip_instances.iter().find(|inst| {
|
let visible_clip = video_layer.clip_instances.iter().find(|inst| {
|
||||||
let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(0.0);
|
let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(0.0);
|
||||||
let effective_duration = inst.effective_duration(clip_duration);
|
let effective_duration = inst.effective_duration(clip_duration, self.ctx.document.tempo_map());
|
||||||
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
|
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -10287,7 +10287,7 @@ impl StagePane {
|
||||||
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
|
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
|
||||||
video_layer.clip_instances.iter().find(|inst| {
|
video_layer.clip_instances.iter().find(|inst| {
|
||||||
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(0.0);
|
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(0.0);
|
||||||
let effective_duration = inst.effective_duration(clip_duration);
|
let effective_duration = inst.effective_duration(clip_duration, document.tempo_map());
|
||||||
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
|
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
|
||||||
}).map(|inst| inst.id)
|
}).map(|inst| inst.id)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue