Compare commits
No commits in common. "6924fc0ffe456ce1f23ac6c49d929b959d95d989" and "9f67a82e3eee391e6a0c1c6db8491696d9eeba94" have entirely different histories.
6924fc0ffe
...
9f67a82e3e
File diff suppressed because it is too large
Load Diff
|
|
@ -211,7 +211,6 @@ pub fn render_to_memory(
|
|||
settings.sample_rate,
|
||||
settings.channels,
|
||||
false,
|
||||
None, // export never runs with a recording in flight
|
||||
);
|
||||
|
||||
// Calculate how many samples we actually need from this chunk
|
||||
|
|
@ -558,7 +557,6 @@ fn export_mp3<P: AsRef<Path>>(
|
|||
settings.sample_rate,
|
||||
settings.channels,
|
||||
false,
|
||||
None, // export never runs with a recording in flight
|
||||
);
|
||||
|
||||
// Calculate how many samples we need from this chunk
|
||||
|
|
@ -729,7 +727,6 @@ fn export_aac<P: AsRef<Path>>(
|
|||
settings.sample_rate,
|
||||
settings.channels,
|
||||
false,
|
||||
None, // export never runs with a recording in flight
|
||||
);
|
||||
|
||||
// Calculate how many samples we need from this chunk
|
||||
|
|
|
|||
|
|
@ -383,7 +383,6 @@ impl Project {
|
|||
sample_rate: u32,
|
||||
channels: u32,
|
||||
live_only: bool,
|
||||
recording_midi: Option<(TrackId, MidiClipId)>,
|
||||
) {
|
||||
output.fill(0.0);
|
||||
|
||||
|
|
@ -392,7 +391,6 @@ impl Project {
|
|||
// Create initial render context
|
||||
let ctx = RenderContext {
|
||||
live_only,
|
||||
recording_midi,
|
||||
..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len())
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,61 +5,6 @@ use crate::time::{Beats, Seconds};
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cycle-recording bookkeeping attached to a recording that started with a cycle region armed.
|
||||
///
|
||||
/// Takes are sliced **geometrically** at stop, in exact `loop_len_frames` multiples — not at the
|
||||
/// instant the wrap was detected. The playhead advances before the capture block in `process()`, so
|
||||
/// the wrap instant isn't sample-exact against the buffer that was just captured, but the geometry
|
||||
/// is. `wrap_count` therefore only decides *whether* this is a multi-take recording, never where the
|
||||
/// cuts land.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CycleRecordInfo {
|
||||
/// Where the cycle region starts, in beats. Takes are laid down here, not at the punch-in point.
|
||||
pub loop_start: Beats,
|
||||
/// The cycle region's length in beats — what the take folder records as `recorded_loop_beats`.
|
||||
pub loop_len_beats: Beats,
|
||||
/// One cycle pass, in frames. The take size.
|
||||
pub loop_len_frames: usize,
|
||||
/// Frames between the region start and where capture actually began. Non-zero only for a
|
||||
/// punch-in (record while already rolling); take 1 gets this much silence prepended so it still
|
||||
/// spans the whole region.
|
||||
pub lead_pad_frames: usize,
|
||||
/// How many times the transport wrapped during this recording. Zero normally means the user
|
||||
/// stopped before completing a pass, which stays an ordinary single recording — unless
|
||||
/// `force_takes` says otherwise.
|
||||
pub wrap_count: usize,
|
||||
/// Cut takes even if the transport never wrapped.
|
||||
///
|
||||
/// Set when the region already holds takes: a further recording there is another take, however
|
||||
/// short, and it gets padded out to the region like any partial pass. Without this a run that
|
||||
/// stopped before the loop came round would land as a separate overlapping clip instead of
|
||||
/// joining the take list.
|
||||
///
|
||||
/// The editor decides this at record start, because whether takes already exist is document
|
||||
/// state the engine can't see.
|
||||
pub force_takes: bool,
|
||||
}
|
||||
|
||||
/// Min/max waveform peaks for a finished buffer of interleaved samples.
|
||||
///
|
||||
/// The live recording path builds its peaks incrementally as samples arrive; cycle takes don't
|
||||
/// exist until the recording is sliced at stop, so they get theirs in one pass here.
|
||||
pub fn compute_peaks(samples: &[f32], channels: u32, frames_per_peak: usize) -> Vec<WaveformPeak> {
|
||||
let samples_per_peak = (frames_per_peak * channels.max(1) as usize).max(1);
|
||||
samples
|
||||
.chunks(samples_per_peak)
|
||||
.map(|chunk| {
|
||||
let mut min = 0.0f32;
|
||||
let mut max = 0.0f32;
|
||||
for s in chunk {
|
||||
min = min.min(*s);
|
||||
max = max.max(*s);
|
||||
}
|
||||
WaveformPeak { min, max }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// State of an active recording session
|
||||
pub struct RecordingState {
|
||||
/// Track being recorded to
|
||||
|
|
@ -90,8 +35,6 @@ pub struct RecordingState {
|
|||
pub frames_per_peak: usize,
|
||||
/// All recorded audio data accumulated in memory (written to disk at finalization)
|
||||
pub audio_data: Vec<f32>,
|
||||
/// Cycle-recording bookkeeping, when a cycle region was armed at record start.
|
||||
pub cycle: Option<CycleRecordInfo>,
|
||||
}
|
||||
|
||||
impl RecordingState {
|
||||
|
|
@ -126,69 +69,9 @@ impl RecordingState {
|
|||
waveform_buffer: Vec::new(),
|
||||
frames_per_peak,
|
||||
audio_data: Vec::new(),
|
||||
cycle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Slice the recording into cycle takes: one per pass, each spanning the FULL cycle region.
|
||||
///
|
||||
/// Partial passes are padded with silence — the head of take 1 for a punch-in, the tail of the
|
||||
/// last take when the user stops mid-pass — so every take is the same length and aligned to the
|
||||
/// region. That uniformity is what makes comping-via-split work: take 1 on the left half and
|
||||
/// take 3 on the right always line up.
|
||||
///
|
||||
/// Returns `None` if this wasn't a cycle recording or the transport never wrapped (an ordinary
|
||||
/// single recording, which keeps the existing path untouched).
|
||||
pub fn slice_takes(&self) -> Option<Vec<Vec<f32>>> {
|
||||
let cycle = self.cycle?;
|
||||
if (cycle.wrap_count == 0 && !cycle.force_takes) || cycle.loop_len_frames == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ch = self.channels.max(1) as usize;
|
||||
let take_len = cycle.loop_len_frames * ch;
|
||||
let lead = cycle.lead_pad_frames * ch;
|
||||
|
||||
// The recording as positioned *within the region*: silence for the gap between the region
|
||||
// start and the punch-in, then the captured audio. Slicing this at whole-take boundaries is
|
||||
// the whole trick — take 1 comes out short-by-`lead` at the front, already padded.
|
||||
let virtual_len = lead + self.audio_data.len();
|
||||
let take_count = virtual_len.div_ceil(take_len);
|
||||
|
||||
let mut takes: Vec<Vec<f32>> = Vec::with_capacity(take_count);
|
||||
for i in 0..take_count {
|
||||
let mut take = vec![0.0f32; take_len];
|
||||
let take_begin = i * take_len;
|
||||
for slot in 0..take_len {
|
||||
// Position in the virtual (lead-padded) buffer.
|
||||
let v = take_begin + slot;
|
||||
if v < lead {
|
||||
continue; // still in the punch-in silence
|
||||
}
|
||||
match self.audio_data.get(v - lead) {
|
||||
Some(s) => take[slot] = *s,
|
||||
None => break, // past the end of capture; the rest stays silent
|
||||
}
|
||||
}
|
||||
takes.push(take);
|
||||
}
|
||||
|
||||
// A final take holding only a sliver of real audio is a stop artifact (the user hit stop a
|
||||
// moment after the wrap), not a performance. Drop it — but only if it's actually a PARTIAL
|
||||
// pass, and never the only take. A pass that filled the region is a real take no matter how
|
||||
// short the region is.
|
||||
const MIN_TAKE_SECONDS: f64 = 0.05;
|
||||
if takes.len() > 1 {
|
||||
let last_real_samples = virtual_len - (takes.len() - 1) * take_len;
|
||||
let last_real_seconds = (last_real_samples / ch) as f64 / self.sample_rate as f64;
|
||||
if last_real_samples < take_len && last_real_seconds < MIN_TAKE_SECONDS {
|
||||
takes.pop();
|
||||
}
|
||||
}
|
||||
|
||||
Some(takes)
|
||||
}
|
||||
|
||||
/// Add samples to the accumulation buffer
|
||||
/// Returns true if a flush occurred
|
||||
pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> {
|
||||
|
|
@ -306,26 +189,6 @@ pub struct MidiRecordingState {
|
|||
active_notes: HashMap<u8, ActiveMidiNote>,
|
||||
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats
|
||||
pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
|
||||
/// The cycle region's length in beats, if one was armed at record start.
|
||||
///
|
||||
/// A cycle MIDI recording is anchored at the region start (`start_time == loop_start`), which is
|
||||
/// what makes MERGE fall out for free: the transport always wraps back into the region, so every
|
||||
/// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each
|
||||
/// other with no folding needed.
|
||||
pub cycle_loop_len: Option<Beats>,
|
||||
/// Whether the transport actually came round. Distinct from `cycle_loop_len`, which only says a
|
||||
/// region was armed: the clip only pins to the full region once a pass has completed, so until
|
||||
/// then the bar still grows with the playhead.
|
||||
pub wrapped: bool,
|
||||
/// Cut takes even if the transport never wrapped — see [`CycleRecordInfo::force_takes`].
|
||||
pub force_takes: bool,
|
||||
/// Which cycle pass is currently being recorded (0-based). Bumped at each wrap.
|
||||
current_pass: usize,
|
||||
/// The pass each completed note belongs to, parallel to `completed_notes`.
|
||||
///
|
||||
/// Only meaningful in "separate takes" mode, where each pass becomes its own MIDI clip. Merge
|
||||
/// mode ignores it — all passes fold into one clip, which is the whole point.
|
||||
note_pass: Vec<usize>,
|
||||
}
|
||||
|
||||
impl MidiRecordingState {
|
||||
|
|
@ -336,28 +199,9 @@ impl MidiRecordingState {
|
|||
start_time,
|
||||
active_notes: HashMap::new(),
|
||||
completed_notes: Vec::new(),
|
||||
cycle_loop_len: None,
|
||||
wrapped: false,
|
||||
force_takes: false,
|
||||
current_pass: 0,
|
||||
note_pass: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a finished note, tagging it with the pass it was played in.
|
||||
///
|
||||
/// Every completion goes through here so `completed_notes` and `note_pass` can't drift apart.
|
||||
fn push_completed(&mut self, note: &ActiveMidiNote, end_time: Beats) {
|
||||
let note_start = note.start_time.max(self.start_time);
|
||||
self.completed_notes.push((
|
||||
note_start - self.start_time,
|
||||
note.note,
|
||||
note.velocity,
|
||||
end_time - note_start,
|
||||
));
|
||||
self.note_pass.push(self.current_pass);
|
||||
}
|
||||
|
||||
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) {
|
||||
self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time });
|
||||
}
|
||||
|
|
@ -367,38 +211,16 @@ impl MidiRecordingState {
|
|||
if absolute_time <= self.start_time {
|
||||
return;
|
||||
}
|
||||
self.push_completed(&active_note, absolute_time);
|
||||
let note_start = active_note.start_time.max(self.start_time);
|
||||
self.completed_notes.push((
|
||||
note_start - self.start_time,
|
||||
active_note.note,
|
||||
active_note.velocity,
|
||||
absolute_time - note_start,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Completed notes grouped by cycle pass — one bucket per pass, in recording order.
|
||||
///
|
||||
/// Used by "separate takes" mode, where each pass becomes its own MIDI clip.
|
||||
///
|
||||
/// An *interior* pass in which nothing was played still yields an empty take, so take N in the
|
||||
/// folder is always pass N on the transport — otherwise the numbering would silently shift and
|
||||
/// "take 3" would stop meaning "the third time round". A *trailing* empty pass is dropped
|
||||
/// though: that's what you get by hitting stop shortly after a wrap, and it's a stop artifact
|
||||
/// rather than a take you played. (Same reasoning as the audio path's short-final-take rule.)
|
||||
pub fn notes_by_pass(&self, passes: usize) -> Vec<Vec<(Beats, u8, u8, Beats)>> {
|
||||
let mut buckets = vec![Vec::new(); passes.max(1)];
|
||||
for (note, &pass) in self.completed_notes.iter().zip(self.note_pass.iter()) {
|
||||
if let Some(bucket) = buckets.get_mut(pass) {
|
||||
bucket.push(*note);
|
||||
}
|
||||
}
|
||||
// Never drop the only take.
|
||||
while buckets.len() > 1 && buckets.last().is_some_and(|b| b.is_empty()) {
|
||||
buckets.pop();
|
||||
}
|
||||
buckets
|
||||
}
|
||||
|
||||
/// How many cycle passes this recording covered (1 if the transport never wrapped).
|
||||
pub fn pass_count(&self) -> usize {
|
||||
self.current_pass + 1
|
||||
}
|
||||
|
||||
pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] {
|
||||
&self.completed_notes
|
||||
}
|
||||
|
|
@ -407,28 +229,18 @@ impl MidiRecordingState {
|
|||
self.completed_notes.len()
|
||||
}
|
||||
|
||||
/// The still-held notes, given a provisional duration running to `current_time`.
|
||||
///
|
||||
/// These belong to whatever pass is in progress, so a per-pass view can append them as-is.
|
||||
pub fn active_notes_with_provisional_end(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
|
||||
self.active_notes
|
||||
.values()
|
||||
.map(|active| {
|
||||
let note_start = active.start_time.max(self.start_time);
|
||||
(
|
||||
note_start - self.start_time,
|
||||
active.note,
|
||||
active.velocity,
|
||||
(current_time - note_start).max(Beats::ZERO),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all completed notes plus currently-held notes with a provisional duration.
|
||||
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
|
||||
let mut notes = self.completed_notes.clone();
|
||||
notes.extend(self.active_notes_with_provisional_end(current_time));
|
||||
for active in self.active_notes.values() {
|
||||
let note_start = active.start_time.max(self.start_time);
|
||||
notes.push((
|
||||
note_start - self.start_time,
|
||||
active.note,
|
||||
active.velocity,
|
||||
(current_time - note_start).max(Beats::ZERO),
|
||||
));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
|
|
@ -440,259 +252,13 @@ impl MidiRecordingState {
|
|||
let active_notes: Vec<_> = self.active_notes.drain().collect();
|
||||
|
||||
for (_note_num, active_note) in active_notes {
|
||||
self.push_completed(&active_note, end_time);
|
||||
let note_start = active_note.start_time.max(self.start_time);
|
||||
self.completed_notes.push((
|
||||
note_start - self.start_time,
|
||||
active_note.note,
|
||||
active_note.velocity,
|
||||
end_time - note_start,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a transport cycle wrap during MIDI recording.
|
||||
///
|
||||
/// Note times are stored as offsets from `start_time`, and the playhead jumps *backwards* at a
|
||||
/// wrap — so a note still held across the boundary would otherwise get a nonsensical (negative)
|
||||
/// duration, or never be closed at all. Write its note-off at `region_end` (exactly as
|
||||
/// `close_active_notes` does when recording stops), then re-open it at `region_start` so a key
|
||||
/// the player is still physically holding keeps being captured in the next pass. Mirrors the
|
||||
/// way `handle_start_midi_recording` re-injects already-held notes at the recording start.
|
||||
pub fn wrap_at_cycle(&mut self, region_end: Beats, region_start: Beats) {
|
||||
// Snapshot the held notes (close_active_notes drains them and loses the velocities).
|
||||
let held: Vec<(u8, u8)> = self
|
||||
.active_notes
|
||||
.values()
|
||||
.map(|n| (n.note, n.velocity))
|
||||
.collect();
|
||||
|
||||
// Close first, so a note held across the boundary has its tail attributed to the pass that's
|
||||
// ending; then advance, so the re-opened half belongs to the pass that's beginning.
|
||||
self.close_active_notes(region_end);
|
||||
self.current_pass += 1;
|
||||
|
||||
for (note, velocity) in held {
|
||||
self.note_on(note, velocity, region_start);
|
||||
}
|
||||
|
||||
// A pass has completed, so from here the clip spans the whole region rather than however long
|
||||
// the user happens to hold the record button.
|
||||
self.cycle_loop_len = Some(region_end - region_start);
|
||||
self.wrapped = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cycle_tests {
|
||||
use super::*;
|
||||
|
||||
/// A recording state holding `audio_data`, armed for cycle recording. Mono, 100 Hz, so a frame
|
||||
/// is a sample and 5 frames is 50 ms (exactly the min-take threshold).
|
||||
fn rec_forced(audio: Vec<f32>, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState {
|
||||
let mut r = rec(audio, loop_len_frames, lead_pad_frames, wraps);
|
||||
if let Some(c) = r.cycle.as_mut() {
|
||||
c.force_takes = true;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn rec(audio: Vec<f32>, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState {
|
||||
let mut r = RecordingState::new(
|
||||
0,
|
||||
0,
|
||||
PathBuf::from("/dev/null"),
|
||||
WavWriter::create(&PathBuf::from("/dev/null"), 100, 1).expect("wav writer"),
|
||||
100,
|
||||
1,
|
||||
Beats(0.0),
|
||||
1.0,
|
||||
);
|
||||
r.audio_data = audio;
|
||||
r.cycle = Some(CycleRecordInfo {
|
||||
loop_start: Beats(0.0),
|
||||
loop_len_beats: Beats(4.0),
|
||||
loop_len_frames,
|
||||
lead_pad_frames,
|
||||
wrap_count: wraps,
|
||||
force_takes: false,
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_wrap_is_not_a_cycle_recording() {
|
||||
// Stopping before the transport ever wraps stays an ordinary single recording — the whole
|
||||
// point of triggering on the wrap rather than on the cycle region merely existing.
|
||||
let r = rec(vec![1.0; 10], 4, 0, 0);
|
||||
assert!(r.slice_takes().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_takes_makes_a_partial_pass_a_take() {
|
||||
// Recording over a region that already holds takes: this run is another take however short
|
||||
// it ran, so it's cut and padded like any partial pass rather than landing as a separate
|
||||
// overlapping clip. Two real frames of an 8-frame region -> one take, silence for the rest.
|
||||
let takes = rec_forced(vec![1.0, 2.0], 8, 0, 0)
|
||||
.slice_takes()
|
||||
.expect("forced takes");
|
||||
assert_eq!(takes.len(), 1);
|
||||
assert_eq!(takes[0], vec![1.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_are_cut_at_exact_loop_multiples() {
|
||||
// 12 frames of audio, 4-frame loop, started at the region start => 3 clean takes.
|
||||
let audio: Vec<f32> = (1..=12).map(|i| i as f32).collect();
|
||||
let takes = rec(audio, 4, 0, 2).slice_takes().expect("cycle takes");
|
||||
assert_eq!(takes.len(), 3);
|
||||
assert_eq!(takes[0], vec![1.0, 2.0, 3.0, 4.0]);
|
||||
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]);
|
||||
assert_eq!(takes[2], vec![9.0, 10.0, 11.0, 12.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punch_in_pads_the_head_of_take_one() {
|
||||
// Punched in 2 frames into the region: take 1 gets 2 frames of silence at the FRONT so it
|
||||
// still spans the whole region and lines up with every other take.
|
||||
let audio: Vec<f32> = (1..=10).map(|i| i as f32).collect();
|
||||
let takes = rec(audio, 4, 2, 2).slice_takes().expect("cycle takes");
|
||||
assert_eq!(takes.len(), 3);
|
||||
assert_eq!(takes[0], vec![0.0, 0.0, 1.0, 2.0]);
|
||||
assert_eq!(takes[1], vec![3.0, 4.0, 5.0, 6.0]);
|
||||
assert_eq!(takes[2], vec![7.0, 8.0, 9.0, 10.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_mid_pass_pads_the_tail_of_the_last_take() {
|
||||
// 13 frames, 8-frame loop => the second take holds 5 real frames (50 ms at 100 Hz, right at
|
||||
// the keep threshold) and 3 of silence.
|
||||
let audio: Vec<f32> = (1..=13).map(|i| i as f32).collect();
|
||||
let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes");
|
||||
assert_eq!(takes.len(), 2);
|
||||
assert_eq!(takes[1], vec![9.0, 10.0, 11.0, 12.0, 13.0, 0.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_take_is_the_same_length() {
|
||||
// Uniform length is the invariant comping-via-split depends on.
|
||||
let audio: Vec<f32> = (1..=23).map(|i| i as f32).collect();
|
||||
let takes = rec(audio, 8, 3, 3).slice_takes().expect("cycle takes");
|
||||
assert!(takes.iter().all(|t| t.len() == 8), "takes must be uniform");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sliver_of_a_final_take_is_dropped() {
|
||||
// Stopped 1 frame (10 ms at 100 Hz) after the wrap — below the 50 ms floor, so that stub of
|
||||
// a take is a stop artifact and goes.
|
||||
let audio: Vec<f32> = (1..=9).map(|i| i as f32).collect();
|
||||
let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes");
|
||||
assert_eq!(takes.len(), 1, "a 10ms tail take should be dropped");
|
||||
assert_eq!(takes[0].len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_final_take_is_never_dropped() {
|
||||
// Regression: the sliver rule must only fire on a PARTIAL pass. A pass that filled the
|
||||
// region is a real take however short the region is — an earlier version compared a full
|
||||
// take's duration to the floor and silently ate it.
|
||||
let audio: Vec<f32> = (1..=8).map(|i| i as f32).collect();
|
||||
let takes = rec(audio, 4, 0, 1).slice_takes().expect("cycle takes");
|
||||
assert_eq!(takes.len(), 2, "both passes filled the region");
|
||||
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod midi_cycle_tests {
|
||||
use super::*;
|
||||
|
||||
/// A MIDI recording anchored at the region start (beat 0), region 4 beats long.
|
||||
fn rec() -> MidiRecordingState {
|
||||
MidiRecordingState::new(0, 0, Beats(0.0))
|
||||
}
|
||||
|
||||
/// One pass of the transport around a 4-beat region.
|
||||
fn wrap(r: &mut MidiRecordingState) {
|
||||
r.wrap_at_cycle(Beats(4.0), Beats(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_are_bucketed_by_the_pass_they_were_played_in() {
|
||||
let mut r = rec();
|
||||
r.note_on(60, 100, Beats(1.0));
|
||||
r.note_off(60, Beats(2.0)); // pass 0
|
||||
wrap(&mut r);
|
||||
r.note_on(62, 100, Beats(1.0));
|
||||
r.note_off(62, Beats(2.0)); // pass 1
|
||||
wrap(&mut r);
|
||||
r.note_on(64, 100, Beats(1.0));
|
||||
r.note_off(64, Beats(2.0)); // pass 2
|
||||
|
||||
assert_eq!(r.pass_count(), 3);
|
||||
let by_pass = r.notes_by_pass(r.pass_count());
|
||||
let pitches: Vec<Vec<u8>> = by_pass
|
||||
.iter()
|
||||
.map(|p| p.iter().map(|n| n.1).collect())
|
||||
.collect();
|
||||
assert_eq!(pitches, vec![vec![60], vec![62], vec![64]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_held_across_a_wrap_splits_between_the_two_passes() {
|
||||
// The key is still down at the boundary: the sounding half belongs to the pass that's
|
||||
// ending, and the re-opened half to the pass that's beginning. Getting the pass bump on the
|
||||
// wrong side of close_active_notes would file the whole note under one pass.
|
||||
let mut r = rec();
|
||||
r.note_on(60, 100, Beats(3.0));
|
||||
wrap(&mut r); // still held
|
||||
r.note_off(60, Beats(1.0)); // released 1 beat into the next pass
|
||||
|
||||
assert_eq!(r.pass_count(), 2);
|
||||
let by_pass = r.notes_by_pass(r.pass_count());
|
||||
assert_eq!(by_pass[0].len(), 1, "the held half lands in the pass that ended");
|
||||
assert_eq!(by_pass[1].len(), 1, "the re-opened half lands in the next pass");
|
||||
// Pass 0's half runs from beat 3 to the region end at 4.
|
||||
assert_eq!(by_pass[0][0].0, Beats(3.0));
|
||||
assert_eq!(by_pass[0][0].3, Beats(1.0));
|
||||
// Pass 1's half starts at the region start and runs to the release.
|
||||
assert_eq!(by_pass[1][0].0, Beats(0.0));
|
||||
assert_eq!(by_pass[1][0].3, Beats(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_interior_pass_still_yields_an_empty_take() {
|
||||
// Take N in the folder must be pass N on the transport, even if nothing was played — else
|
||||
// the take numbering silently shifts under the user.
|
||||
let mut r = rec();
|
||||
r.note_on(60, 100, Beats(1.0));
|
||||
r.note_off(60, Beats(2.0)); // pass 0
|
||||
wrap(&mut r);
|
||||
wrap(&mut r); // pass 1: played nothing
|
||||
r.note_on(64, 100, Beats(1.0));
|
||||
r.note_off(64, Beats(2.0)); // pass 2
|
||||
|
||||
let by_pass = r.notes_by_pass(r.pass_count());
|
||||
assert_eq!(by_pass.len(), 3);
|
||||
assert_eq!(by_pass[1].len(), 0, "the silent pass is still take 2");
|
||||
assert_eq!(by_pass[2][0].1, 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_empty_pass_is_dropped() {
|
||||
// Hitting stop shortly after a wrap leaves a pass you never played into. That's a stop
|
||||
// artifact, not a take — unlike a silent pass in the middle, which was a deliberate rest.
|
||||
let mut r = rec();
|
||||
r.note_on(60, 100, Beats(1.0));
|
||||
r.note_off(60, Beats(2.0)); // pass 0
|
||||
wrap(&mut r);
|
||||
r.note_on(62, 100, Beats(1.0));
|
||||
r.note_off(62, Beats(2.0)); // pass 1
|
||||
wrap(&mut r); // pass 2 begins... and the user hits stop
|
||||
|
||||
assert_eq!(r.pass_count(), 3);
|
||||
let by_pass = r.notes_by_pass(r.pass_count());
|
||||
assert_eq!(by_pass.len(), 2, "the empty trailing pass is not a take");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_recording_still_yields_one_take() {
|
||||
let mut r = rec();
|
||||
wrap(&mut r);
|
||||
wrap(&mut r);
|
||||
assert_eq!(r.notes_by_pass(r.pass_count()).len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use super::automation::{AutomationLane, AutomationLaneId, ParameterId};
|
||||
use super::clip::{AudioClipInstance, AudioClipInstanceId};
|
||||
use super::midi::{MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent};
|
||||
use super::midi::{MidiClipInstance, MidiClipInstanceId, MidiEvent};
|
||||
use super::midi_pool::MidiClipPool;
|
||||
use super::node_graph::AudioGraph;
|
||||
use super::node_graph::nodes::{AudioInputNode, AudioOutputNode};
|
||||
|
|
@ -43,13 +43,6 @@ pub struct RenderContext<'a> {
|
|||
/// Used after pause/stop to route note-off tails through the normal group hierarchy
|
||||
/// without re-triggering notes from clips at the paused position.
|
||||
pub live_only: bool,
|
||||
/// The MIDI recording in progress, if any: (track being recorded to, clip being recorded into).
|
||||
///
|
||||
/// On that track, every OTHER clip is silenced for the duration of the recording. You're playing
|
||||
/// a part into this region — hearing what's already there (a previous take, say) fighting with
|
||||
/// what you're playing now is just noise. The clip being recorded into is exempt, because in
|
||||
/// merge mode that's exactly what you DO want to hear: the overdub you've been building up.
|
||||
pub recording_midi: Option<(TrackId, MidiClipId)>,
|
||||
}
|
||||
|
||||
impl<'a> RenderContext<'a> {
|
||||
|
|
@ -68,7 +61,6 @@ impl<'a> RenderContext<'a> {
|
|||
buffer_size,
|
||||
time_stretch: 1.0,
|
||||
live_only: false,
|
||||
recording_midi: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -872,21 +864,9 @@ impl MidiTrack {
|
|||
let playhead_beats = ctx.playhead_beats();
|
||||
let buffer_end_beats = ctx.buffer_end_beats();
|
||||
|
||||
// While recording into this track, every clip EXCEPT the one being recorded into is
|
||||
// silenced. Otherwise a take folder already sitting in the cycle region would play its
|
||||
// active take underneath you on every pass, fighting the part you're trying to record.
|
||||
// The recording clip itself is exempt: in merge mode that's the overdub monitoring.
|
||||
let muted_clip = match ctx.recording_midi {
|
||||
Some((track_id, clip_id)) if track_id == self.id => Some(clip_id),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Collect MIDI events from all clip instances that overlap with current beat range
|
||||
let mut currently_active = HashSet::new();
|
||||
for instance in &self.clip_instances {
|
||||
if muted_clip.is_some_and(|recording| instance.clip_id != recording) {
|
||||
continue;
|
||||
}
|
||||
if instance.overlaps_range(playhead_beats, buffer_end_beats) {
|
||||
currently_active.insert(instance.id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
pub mod types;
|
||||
|
||||
pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse, TrimRange};
|
||||
pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse};
|
||||
|
|
|
|||
|
|
@ -8,21 +8,6 @@ use crate::audio::node_graph::nodes::LoopMode;
|
|||
use crate::io::WaveformPeak;
|
||||
use crate::time::{Beats, Seconds};
|
||||
|
||||
/// A clip's internal (content) boundaries, tagged with the domain they're measured in.
|
||||
///
|
||||
/// A clip's content time is SECONDS for sampled audio but BEATS for MIDI — the same polymorphism
|
||||
/// `ClipInstance::trim_start`/`trim_end` carry. Passing these as bare `f64`s meant the caller and
|
||||
/// the engine could disagree about the unit with nothing to catch it: an audio trim of "1.0" was
|
||||
/// once stored as `Beats(1.0)` for the clip's external duration, so a 1-second split played back as
|
||||
/// half a second at 120 BPM. Tagging the domain makes that a type error instead of a bug report.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum TrimRange {
|
||||
/// Sampled-audio content time.
|
||||
Seconds { start: Seconds, end: Seconds },
|
||||
/// MIDI content time.
|
||||
Beats { start: Beats, end: Beats },
|
||||
}
|
||||
|
||||
/// Commands sent from UI/control thread to audio thread
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Command {
|
||||
|
|
@ -34,7 +19,7 @@ pub enum Command {
|
|||
/// Pause playback (maintains position)
|
||||
Pause,
|
||||
/// Seek to a specific position in seconds
|
||||
Seek(Seconds),
|
||||
Seek(f64),
|
||||
|
||||
// Track management commands
|
||||
/// Set track volume (0.0 = silence, 1.0 = unity gain)
|
||||
|
|
@ -46,12 +31,13 @@ pub enum Command {
|
|||
|
||||
// Clip management commands
|
||||
/// Move a clip to a new timeline position (track_id, clip_id, new_external_start)
|
||||
MoveClip(TrackId, ClipId, Beats),
|
||||
/// Trim a clip's internal boundaries — which portion of the source content is used.
|
||||
TrimClip(TrackId, ClipId, TrimRange),
|
||||
MoveClip(TrackId, ClipId, f64),
|
||||
/// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end)
|
||||
/// This changes which portion of the source content is used
|
||||
TrimClip(TrackId, ClipId, f64, f64),
|
||||
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
|
||||
/// If duration > internal duration, the clip will loop
|
||||
ExtendClip(TrackId, ClipId, Beats),
|
||||
ExtendClip(TrackId, ClipId, f64),
|
||||
|
||||
// Metatrack management commands
|
||||
/// Create a new metatrack with a name and optional parent group
|
||||
|
|
@ -67,15 +53,15 @@ pub enum Command {
|
|||
SetTimeStretch(TrackId, f32),
|
||||
/// Set metatrack time offset in seconds (track_id, offset)
|
||||
/// Positive = shift content later, negative = shift earlier
|
||||
SetOffset(TrackId, Seconds),
|
||||
SetOffset(TrackId, f64),
|
||||
/// Set metatrack pitch shift in semitones (track_id, semitones) - for future use
|
||||
SetPitchShift(TrackId, f32),
|
||||
/// Set metatrack trim start in seconds (track_id, trim_start)
|
||||
/// Children won't hear content before this point
|
||||
SetTrimStart(TrackId, Seconds),
|
||||
SetTrimStart(TrackId, f64),
|
||||
/// Set metatrack trim end in seconds (track_id, trim_end)
|
||||
/// None means no end trim
|
||||
SetTrimEnd(TrackId, Option<Seconds>),
|
||||
SetTrimEnd(TrackId, Option<f64>),
|
||||
|
||||
// Audio track commands
|
||||
/// Create a new audio track with a name and optional parent group
|
||||
|
|
@ -94,14 +80,14 @@ pub enum Command {
|
|||
/// Add a MIDI clip to the pool without placing it on a track
|
||||
AddMidiClipToPool(MidiClip),
|
||||
/// Create a new MIDI clip on a track (track_id, start_time, duration)
|
||||
CreateMidiClip(TrackId, Beats, Beats),
|
||||
CreateMidiClip(TrackId, f64, f64),
|
||||
/// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration)
|
||||
AddMidiNote(TrackId, MidiClipId, Beats, u8, u8, Beats),
|
||||
AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64),
|
||||
/// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time)
|
||||
AddLoadedMidiClip(TrackId, MidiClip, Beats),
|
||||
AddLoadedMidiClip(TrackId, MidiClip, f64),
|
||||
/// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>)
|
||||
/// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes
|
||||
UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(Beats, u8, u8, Beats)>),
|
||||
UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>),
|
||||
/// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing.
|
||||
UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>),
|
||||
/// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support
|
||||
|
|
@ -117,9 +103,9 @@ pub enum Command {
|
|||
/// Create a new automation lane on a track (track_id, parameter_id)
|
||||
CreateAutomationLane(TrackId, ParameterId),
|
||||
/// Add an automation point to a lane (track_id, lane_id, time, value, curve)
|
||||
AddAutomationPoint(TrackId, AutomationLaneId, Beats, f32, CurveType),
|
||||
AddAutomationPoint(TrackId, AutomationLaneId, f64, f32, CurveType),
|
||||
/// Remove an automation point at a specific time (track_id, lane_id, time, tolerance)
|
||||
RemoveAutomationPoint(TrackId, AutomationLaneId, Beats, Beats),
|
||||
RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64),
|
||||
/// Clear all automation points from a lane (track_id, lane_id)
|
||||
ClearAutomationLane(TrackId, AutomationLaneId),
|
||||
/// Remove an automation lane (track_id, lane_id)
|
||||
|
|
@ -127,24 +113,9 @@ pub enum Command {
|
|||
/// Enable/disable an automation lane (track_id, lane_id, enabled)
|
||||
SetAutomationLaneEnabled(TrackId, AutomationLaneId, bool),
|
||||
|
||||
// Transport cycle (loop) region
|
||||
/// Set the cycle region the transport loops over, in beats (None clears it).
|
||||
/// Authored in beats so it survives tempo changes.
|
||||
SetLoopRegion(Option<(Beats, Beats)>),
|
||||
/// Enable/disable wrapping at the cycle region's end.
|
||||
SetLoopEnabled(bool),
|
||||
/// How a cycle MIDI recording treats its passes.
|
||||
///
|
||||
/// `false` (default) = MERGE: every pass overdubs into one clip. `true` = SEPARATE TAKES: each
|
||||
/// pass becomes its own MIDI clip, and the editor folds them into a take folder — the same shape
|
||||
/// audio always gets.
|
||||
SetCycleMidiSeparateTakes(bool),
|
||||
|
||||
// Recording commands
|
||||
/// Start recording on a track (track_id, start_time)
|
||||
/// (track, start_time, force_takes — cut takes even if the transport never wraps, because the
|
||||
/// region already holds takes and this is another one)
|
||||
StartRecording(TrackId, Beats, bool),
|
||||
StartRecording(TrackId, Beats),
|
||||
/// Stop the current recording
|
||||
StopRecording,
|
||||
/// Pause the current recording
|
||||
|
|
@ -154,8 +125,7 @@ pub enum Command {
|
|||
|
||||
// MIDI Recording commands
|
||||
/// Start MIDI recording on a track (track_id, clip_id, start_time)
|
||||
/// (track, clip, start_time, force_takes — see [`Command::StartRecording`])
|
||||
StartMidiRecording(TrackId, MidiClipId, Beats, bool),
|
||||
StartMidiRecording(TrackId, MidiClipId, Beats),
|
||||
/// Stop the current MIDI recording
|
||||
StopMidiRecording,
|
||||
|
||||
|
|
@ -267,9 +237,9 @@ pub enum Command {
|
|||
|
||||
// Automation Input Node commands
|
||||
/// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in)
|
||||
AutomationAddKeyframe(TrackId, u32, Beats, f32, String, (f32, f32), (f32, f32)),
|
||||
AutomationAddKeyframe(TrackId, u32, f64, f32, String, (f32, f32), (f32, f32)),
|
||||
/// Remove a keyframe from an AutomationInput node (track_id, node_id, time)
|
||||
AutomationRemoveKeyframe(TrackId, u32, Beats),
|
||||
AutomationRemoveKeyframe(TrackId, u32, f64),
|
||||
/// Set the display name of an AutomationInput node (track_id, node_id, name)
|
||||
AutomationSetName(TrackId, u32, String),
|
||||
|
||||
|
|
@ -301,7 +271,7 @@ pub enum Command {
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum AudioEvent {
|
||||
/// Current playback position in seconds
|
||||
PlaybackPosition(Seconds),
|
||||
PlaybackPosition(f64),
|
||||
/// Playback has stopped (reached end of audio)
|
||||
PlaybackStopped,
|
||||
/// Audio buffer underrun detected
|
||||
|
|
@ -322,35 +292,6 @@ pub enum AudioEvent {
|
|||
RecordingProgress(ClipId, Seconds),
|
||||
/// Recording stopped (clip_id, pool_index, waveform)
|
||||
RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
|
||||
/// A MIDI recording that wrapped the cycle region at least once, in SEPARATE TAKES mode.
|
||||
///
|
||||
/// One MIDI clip per pass, in recording order. (Merge mode emits the ordinary
|
||||
/// `MidiRecordingStopped` instead — all passes are already folded into the one clip.)
|
||||
MidiCycleRecordingStopped {
|
||||
track_id: TrackId,
|
||||
/// One pool MIDI clip per pass. The first is the clip the recording started on.
|
||||
clip_ids: Vec<MidiClipId>,
|
||||
/// Where the takes sit on the timeline — the cycle region's start.
|
||||
loop_start: Beats,
|
||||
/// The region's length in beats: every take spans exactly this.
|
||||
loop_len_beats: Beats,
|
||||
},
|
||||
/// A recording that wrapped the cycle region at least once, and so became multi-take.
|
||||
///
|
||||
/// Each take spans the full region and they're all the same length (partial passes are padded
|
||||
/// with silence), so the editor can promote the recording clip straight to a take folder.
|
||||
CycleRecordingStopped {
|
||||
clip_id: ClipId,
|
||||
/// One entry per pass: (audio pool index, waveform peaks), in recording order.
|
||||
takes: Vec<(usize, Vec<WaveformPeak>)>,
|
||||
/// Where the takes sit on the timeline — the cycle region's start, not the punch-in point.
|
||||
loop_start: Beats,
|
||||
/// The region's length in beats (what the take folder stores as `recorded_loop_beats`).
|
||||
loop_len_beats: Beats,
|
||||
/// The same length in seconds — the take folder's content duration, which is seconds-domain
|
||||
/// for audio.
|
||||
loop_len_seconds: Seconds,
|
||||
},
|
||||
/// Recording error (error_message)
|
||||
RecordingError(String),
|
||||
/// MIDI recording stopped (track_id, clip_id, note_count)
|
||||
|
|
@ -401,7 +342,7 @@ pub enum AudioEvent {
|
|||
WaveformChunksReady {
|
||||
pool_index: usize,
|
||||
detail_level: u8,
|
||||
chunks: Vec<(u32, (Seconds, Seconds), Vec<WaveformPeak>)>,
|
||||
chunks: Vec<(u32, (f64, f64), Vec<WaveformPeak>)>,
|
||||
},
|
||||
|
||||
/// An audio file has been imported and is ready for playback.
|
||||
|
|
@ -412,7 +353,7 @@ pub enum AudioEvent {
|
|||
path: String,
|
||||
channels: u32,
|
||||
sample_rate: u32,
|
||||
duration: Seconds,
|
||||
duration: f64,
|
||||
format: crate::io::audio_file::AudioFormat,
|
||||
},
|
||||
|
||||
|
|
@ -486,7 +427,7 @@ pub enum Query {
|
|||
/// Export audio to file (settings, output_path)
|
||||
ExportAudio(crate::audio::ExportSettings, std::path::PathBuf),
|
||||
/// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID
|
||||
AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, Beats),
|
||||
AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64),
|
||||
/// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID
|
||||
/// The clip must already exist in the MidiClipPool
|
||||
AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance),
|
||||
|
|
@ -531,21 +472,16 @@ pub struct OscilloscopeData {
|
|||
}
|
||||
|
||||
/// MIDI clip data for serialization
|
||||
///
|
||||
/// `Beats`/`Seconds` are `#[serde(transparent)]`, so naming the domain here costs nothing on disk —
|
||||
/// the `.beam` still holds a plain number.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MidiClipData {
|
||||
/// MIDI content length is musical, so beats.
|
||||
pub duration: Beats,
|
||||
pub duration: f64,
|
||||
pub events: Vec<crate::audio::midi::MidiEvent>,
|
||||
}
|
||||
|
||||
/// Automation keyframe data for serialization
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AutomationKeyframeData {
|
||||
/// Automation x-axes are all beats.
|
||||
pub time: Beats,
|
||||
pub time: f64,
|
||||
pub value: f32,
|
||||
pub interpolation: String,
|
||||
pub ease_out: (f32, f32),
|
||||
|
|
@ -582,7 +518,7 @@ pub enum QueryResponse {
|
|||
/// Pool waveform data
|
||||
PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>),
|
||||
/// Pool file info (duration, sample_rate, channels)
|
||||
PoolFileInfo(Result<(Seconds, u32, u32), String>),
|
||||
PoolFileInfo(Result<(f64, u32, u32), String>),
|
||||
/// Audio exported
|
||||
AudioExported(Result<(), String>),
|
||||
/// MIDI clip instance added (returns instance ID)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub use audio::{
|
|||
TrackNode,
|
||||
};
|
||||
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
|
||||
pub use time::{Beats, ContentTime, Seconds};
|
||||
pub use time::{Beats, Seconds};
|
||||
pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
|
||||
pub use command::{AudioEvent, Command, OscilloscopeData};
|
||||
pub use command::types::AutomationKeyframeData;
|
||||
|
|
|
|||
|
|
@ -16,50 +16,6 @@ pub struct Beats(pub f64);
|
|||
#[serde(transparent)]
|
||||
pub struct Seconds(pub f64);
|
||||
|
||||
/// A time *inside a clip's own content*, in whatever unit that clip measures content in.
|
||||
///
|
||||
/// Clip content time is domain-polymorphic: SECONDS for sampled audio, video and vector, but BEATS
|
||||
/// for MIDI (musical, so it survives tempo changes). `ClipInstance::trim_start`/`trim_end` are
|
||||
/// content times, and storing them as bare `f64`s is what let a seconds delta get added to a MIDI
|
||||
/// clip's beats trim — splitting a MIDI clip at beat 4 landed at beat 2 at 120 BPM.
|
||||
///
|
||||
/// This type is deliberately a **dead end**: it has no `.to_seconds()`, no `.to_beats()`, and no
|
||||
/// arithmetic with `Seconds` or `Beats`. Content times can be compared and combined with each other
|
||||
/// (that's domain-safe — both operands are in the same clip's domain), but the only way to get a
|
||||
/// real timeline duration out is to resolve it against the clip that knows the domain, via
|
||||
/// `AudioClip::resolve_content_time` / `Document::resolve_content_time`. So a passthrough costs
|
||||
/// nothing, and mixing domains won't compile.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ContentTime(pub f64);
|
||||
|
||||
impl ContentTime {
|
||||
pub const ZERO: Self = Self(0.0);
|
||||
|
||||
pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) }
|
||||
pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) }
|
||||
|
||||
/// The raw magnitude, with the domain discarded.
|
||||
///
|
||||
/// Only for code that is *already* working in this clip's content domain (trim arithmetic,
|
||||
/// serialization, drawing a waveform whose x-axis is the clip's own content). If you are about
|
||||
/// to combine this with a timeline position, resolve it against the clip instead.
|
||||
pub fn raw(self) -> f64 { self.0 }
|
||||
}
|
||||
|
||||
impl Add for ContentTime {
|
||||
type Output = Self;
|
||||
fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) }
|
||||
}
|
||||
impl Sub for ContentTime {
|
||||
type Output = Self;
|
||||
fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) }
|
||||
}
|
||||
impl Rem for ContentTime {
|
||||
type Output = Self;
|
||||
fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) }
|
||||
}
|
||||
|
||||
impl Beats {
|
||||
pub const ZERO: Self = Self(0.0);
|
||||
|
||||
|
|
|
|||
|
|
@ -556,7 +556,7 @@ pub fn run_tui(
|
|||
while let Ok(event) = rx.pop() {
|
||||
match event {
|
||||
AudioEvent::PlaybackPosition(pos) => {
|
||||
app.update_playback_position(pos.seconds_to_f64());
|
||||
app.update_playback_position(pos);
|
||||
}
|
||||
AudioEvent::PlaybackStopped => {
|
||||
app.set_playing(false);
|
||||
|
|
|
|||
|
|
@ -3628,7 +3628,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightningbeam-editor"
|
||||
version = "1.0.9-alpha"
|
||||
version = "1.0.8-alpha"
|
||||
dependencies = [
|
||||
"beamdsp",
|
||||
"bytemuck",
|
||||
|
|
|
|||
|
|
@ -47,113 +47,6 @@ pub struct BackendContext<'a> {
|
|||
// Future: pub video_controller: Option<&'a mut VideoController>,
|
||||
}
|
||||
|
||||
impl BackendContext<'_> {
|
||||
/// Hand a clip instance to the audio engine and record it in the instance→backend map.
|
||||
///
|
||||
/// Take folders are resolved through the instance's `active_take`, so the backend gets whichever
|
||||
/// take is selected. Returns the backend track and instance IDs, or `None` when there's nothing
|
||||
/// to sync yet (a recording in progress, or an empty take folder).
|
||||
///
|
||||
/// Lives here rather than in any one action because more than one action needs it: adding an
|
||||
/// instance, and switching a take folder's active take (which is a remove + re-add, there being
|
||||
/// no in-place pool-swap command). Keeping one implementation keeps the trim/duration
|
||||
/// conversions — the easy thing to get subtly wrong, since `trim_*` is SECONDS while
|
||||
/// `timeline_*` is BEATS — from drifting between copies.
|
||||
pub fn add_clip_instance(
|
||||
&mut self,
|
||||
document: &Document,
|
||||
layer_id: &Uuid,
|
||||
instance: &crate::clip::ClipInstance,
|
||||
) -> Result<Option<(daw_backend::TrackId, BackendClipInstanceId)>, String> {
|
||||
use crate::clip::ResolvedContent;
|
||||
|
||||
let clip = document
|
||||
.get_audio_clip(&instance.clip_id)
|
||||
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
||||
|
||||
let track_id = *self
|
||||
.layer_to_track_map
|
||||
.get(layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?;
|
||||
|
||||
let resolved = instance.resolve(clip);
|
||||
let content = clip.content_duration();
|
||||
let internal_start = instance.trim_start;
|
||||
let internal_end = instance
|
||||
.trim_end
|
||||
.unwrap_or(daw_backend::ContentTime(content.native()));
|
||||
let start_time = instance.timeline_start;
|
||||
|
||||
// How long the clip occupies the timeline, in BEATS. `effective_duration_beats` resolves the
|
||||
// content window in the clip's own domain — beats content carries over directly, wall-clock
|
||||
// content converts at the clip's position — so neither kind can be read as the other here.
|
||||
let effective_duration = instance.effective_duration_beats(content, document.tempo_map());
|
||||
|
||||
let controller = self
|
||||
.audio_controller
|
||||
.as_mut()
|
||||
.ok_or_else(|| "Audio controller not available".to_string())?;
|
||||
|
||||
let backend_id = match resolved {
|
||||
ResolvedContent::Midi { midi_clip_id } => {
|
||||
use daw_backend::command::{Query, QueryResponse};
|
||||
|
||||
// MIDI content time IS beats, so the trims carry straight over.
|
||||
let midi_instance = daw_backend::MidiClipInstance::new(
|
||||
0, // assigned by the backend
|
||||
midi_clip_id,
|
||||
daw_backend::Beats(internal_start.raw()),
|
||||
daw_backend::Beats(internal_end.raw()),
|
||||
start_time,
|
||||
effective_duration,
|
||||
);
|
||||
|
||||
match controller
|
||||
.send_query(Query::AddMidiClipInstanceSync(track_id, midi_instance))?
|
||||
{
|
||||
QueryResponse::MidiClipInstanceAdded(Ok(id)) => BackendClipInstanceId::Midi(id),
|
||||
QueryResponse::MidiClipInstanceAdded(Err(e)) => return Err(e),
|
||||
_ => return Err("Unexpected query response".to_string()),
|
||||
}
|
||||
}
|
||||
ResolvedContent::Audio { audio_pool_index } => {
|
||||
// Sampled-audio content time is SECONDS; the backend's start/duration are BEATS.
|
||||
let id = controller.add_audio_clip(
|
||||
track_id,
|
||||
audio_pool_index,
|
||||
start_time,
|
||||
effective_duration,
|
||||
daw_backend::Seconds(internal_start.raw()),
|
||||
);
|
||||
BackendClipInstanceId::Audio(id)
|
||||
}
|
||||
// Nothing to sync until it has content.
|
||||
ResolvedContent::Recording => return Ok(None),
|
||||
};
|
||||
|
||||
self.clip_instance_to_backend_map
|
||||
.insert(instance.id, backend_id);
|
||||
|
||||
Ok(Some((track_id, backend_id)))
|
||||
}
|
||||
|
||||
/// Remove a clip instance's backend clip and drop it from the instance→backend map.
|
||||
pub fn remove_clip_instance(
|
||||
&mut self,
|
||||
track_id: daw_backend::TrackId,
|
||||
backend_id: BackendClipInstanceId,
|
||||
instance_id: Uuid,
|
||||
) {
|
||||
if let Some(controller) = self.audio_controller.as_mut() {
|
||||
match backend_id {
|
||||
BackendClipInstanceId::Midi(id) => controller.remove_midi_clip(track_id, id),
|
||||
BackendClipInstanceId::Audio(id) => controller.remove_audio_clip(track_id, id),
|
||||
}
|
||||
}
|
||||
self.clip_instance_to_backend_map.remove(&instance_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Action trait for undo/redo operations
|
||||
///
|
||||
/// Each action must be able to execute (apply changes) and rollback (undo changes).
|
||||
|
|
|
|||
|
|
@ -89,11 +89,10 @@ impl Action for AddClipInstanceAction {
|
|||
// `get_clip_duration` is the content length in seconds; the placement span
|
||||
// must be beats (the timeline is beats-domain), so convert via the clip's
|
||||
// typed helper rather than treating the seconds span as beats.
|
||||
// The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
|
||||
let clip_content = document.clip_trim_duration(&self.clip_instance.clip_id)
|
||||
let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id)
|
||||
.ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?;
|
||||
let effective_duration = self.clip_instance
|
||||
.effective_duration_beats(clip_content, document.tempo_map());
|
||||
.effective_duration_beats(clip_duration, document.tempo_map());
|
||||
|
||||
// Auto-adjust position for audio/video layers to avoid overlaps
|
||||
let adjusted_start = document.find_nearest_valid_position(
|
||||
|
|
@ -197,23 +196,113 @@ impl Action for AddClipInstanceAction {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// Add via the shared BackendContext helper — the same one SetActiveTakeAction uses, so
|
||||
// the trim/duration conversions (and take-folder resolution) live in exactly one place.
|
||||
if let Some((track_id, backend_id)) =
|
||||
backend.add_clip_instance(document, &self.layer_id, &self.clip_instance)?
|
||||
{
|
||||
self.backend_track_id = Some(track_id);
|
||||
match backend_id {
|
||||
crate::action::BackendClipInstanceId::Midi(id) => {
|
||||
self.backend_midi_instance_id = Some(id)
|
||||
}
|
||||
crate::action::BackendClipInstanceId::Audio(id) => {
|
||||
self.backend_audio_instance_id = Some(id)
|
||||
// Look up the clip from the document
|
||||
let clip = document
|
||||
.get_audio_clip(&self.clip_instance.clip_id)
|
||||
.ok_or_else(|| "Audio clip not found".to_string())?;
|
||||
|
||||
// Look up backend track ID from layer mapping
|
||||
let backend_track_id = backend
|
||||
.layer_to_track_map
|
||||
.get(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?;
|
||||
|
||||
// Get audio controller
|
||||
let controller = backend
|
||||
.audio_controller
|
||||
.as_mut()
|
||||
.ok_or_else(|| "Audio controller not available".to_string())?;
|
||||
|
||||
// Handle different clip types
|
||||
use crate::clip::AudioClipType;
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
// Create a MIDI clip instance referencing the existing clip in the backend pool
|
||||
// No need to add to pool again - it was added during MIDI import
|
||||
use daw_backend::command::{Query, QueryResponse};
|
||||
|
||||
// Calculate internal start/end from trim parameters
|
||||
let internal_start = self.clip_instance.trim_start;
|
||||
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
let external_start = self.clip_instance.timeline_start;
|
||||
|
||||
// Calculate external duration (for looping if timeline_duration is set).
|
||||
// MIDI trims are beats-domain, so the fallback span is beats too.
|
||||
let external_duration = self.clip_instance.timeline_duration
|
||||
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
||||
|
||||
// Create MidiClipInstance
|
||||
let instance = daw_backend::MidiClipInstance::new(
|
||||
0, // Instance ID will be assigned by backend
|
||||
*midi_clip_id,
|
||||
daw_backend::Beats(internal_start),
|
||||
daw_backend::Beats(internal_end),
|
||||
external_start,
|
||||
external_duration,
|
||||
);
|
||||
|
||||
// Send query to add instance and get instance ID
|
||||
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
|
||||
|
||||
match controller.send_query(query)? {
|
||||
QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => {
|
||||
self.backend_track_id = Some(*backend_track_id);
|
||||
self.backend_midi_instance_id = Some(instance_id);
|
||||
|
||||
// Add to global clip instance mapping
|
||||
backend.clip_instance_to_backend_map.insert(
|
||||
self.clip_instance.id,
|
||||
crate::action::BackendClipInstanceId::Midi(instance_id)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e),
|
||||
_ => Err("Unexpected query response".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
AudioClipType::Sampled { audio_pool_index } => {
|
||||
// `trim_*` / `clip.duration` are in SECONDS (audio content time),
|
||||
// while `timeline_*` and the backend's `duration` are in BEATS.
|
||||
let internal_start = self.clip_instance.trim_start;
|
||||
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
let start_time = self.clip_instance.timeline_start;
|
||||
// `effective_duration` is in BEATS. When `timeline_duration` is set
|
||||
// it already is; otherwise the clip occupies its natural content
|
||||
// length, so convert that seconds-span to beats at the clip's start
|
||||
// (NOT `internal_end - internal_start`, which is seconds — that was
|
||||
// the seconds-as-beats bug that made clips stop early off 60 BPM).
|
||||
let effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| {
|
||||
let tempo_map = document.tempo_map();
|
||||
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
||||
- start_time
|
||||
});
|
||||
|
||||
Ok(())
|
||||
let instance_id = controller.add_audio_clip(
|
||||
*backend_track_id,
|
||||
*audio_pool_index,
|
||||
start_time,
|
||||
effective_duration,
|
||||
daw_backend::Seconds(internal_start),
|
||||
);
|
||||
|
||||
self.backend_track_id = Some(*backend_track_id);
|
||||
self.backend_audio_instance_id = Some(instance_id);
|
||||
|
||||
// Add to global clip instance mapping
|
||||
backend.clip_instance_to_backend_map.insert(
|
||||
self.clip_instance.id,
|
||||
crate::action::BackendClipInstanceId::Audio(instance_id)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
AudioClipType::Recording => {
|
||||
// Recording clips are not synced to backend until finalized
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> {
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
//! Append freshly-recorded takes to an existing take folder.
|
||||
//!
|
||||
//! Cycle-recording over a region that already holds a take folder should *add* to that folder, not
|
||||
//! drop a second clip on top of it. Otherwise the takes from your second attempt are stranded in a
|
||||
//! separate, overlapping clip and you can't audition them against the first.
|
||||
//!
|
||||
//! The recorded content already exists in the backend pools by the time this runs (the engine put it
|
||||
//! there at stop), so this action only touches the document — plus the one backend clip the instance
|
||||
//! plays, which has to be repointed at the newly-active take.
|
||||
|
||||
use crate::action::{Action, BackendClipInstanceId, BackendContext};
|
||||
use crate::clip::AudioTake;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that appends takes to an instance's take list and selects the last of them.
|
||||
pub struct AppendTakesAction {
|
||||
layer_id: Uuid,
|
||||
/// The instance whose take list is being extended (and whose active take changes).
|
||||
instance_id: Uuid,
|
||||
/// The takes to add, in recording order.
|
||||
new_takes: Vec<AudioTake>,
|
||||
|
||||
// Stored during execute for rollback.
|
||||
old_take_count: usize,
|
||||
old_active_take: Option<usize>,
|
||||
executed: bool,
|
||||
}
|
||||
|
||||
impl AppendTakesAction {
|
||||
pub fn new(layer_id: Uuid, instance_id: Uuid, new_takes: Vec<AudioTake>) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
instance_id,
|
||||
new_takes,
|
||||
old_take_count: 0,
|
||||
old_active_take: None,
|
||||
executed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap the instance's backend clip to whatever take the document now says is active.
|
||||
///
|
||||
/// Same remove + re-add as `SetActiveTakeAction` — there's no in-place pool-swap command.
|
||||
fn resync(&self, backend: &mut BackendContext, document: &Document) -> Result<(), String> {
|
||||
let instance = document
|
||||
.get_layer(&self.layer_id)
|
||||
.and_then(|l| match l {
|
||||
AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == self.instance_id),
|
||||
_ => None,
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
|
||||
|
||||
let existing: Option<BackendClipInstanceId> = backend
|
||||
.clip_instance_to_backend_map
|
||||
.get(&self.instance_id)
|
||||
.copied();
|
||||
let track_id = backend.layer_to_track_map.get(&self.layer_id).copied();
|
||||
if let (Some(backend_id), Some(track_id)) = (existing, track_id) {
|
||||
backend.remove_clip_instance(track_id, backend_id, self.instance_id);
|
||||
}
|
||||
|
||||
backend.add_clip_instance(document, &self.layer_id, &instance)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for AppendTakesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let layer = document
|
||||
.get_layer_mut(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||
let AnyLayer::Audio(audio_layer) = layer else {
|
||||
return Err("Takes only exist on audio layers".to_string());
|
||||
};
|
||||
let instance = audio_layer
|
||||
.clip_instances
|
||||
.iter_mut()
|
||||
.find(|ci| ci.id == self.instance_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
|
||||
|
||||
// Only record the pre-state on the first execute; a redo must not overwrite it with the
|
||||
// post-state left behind by the previous run.
|
||||
if !self.executed {
|
||||
self.old_take_count = instance.takes.len();
|
||||
self.old_active_take = instance.active_take;
|
||||
}
|
||||
|
||||
// Number the new takes on from what's already there. Existing names are left alone — the
|
||||
// user may well have renamed them, and renumbering would clobber that.
|
||||
let base = instance.takes.len();
|
||||
for (i, take) in self.new_takes.iter().enumerate() {
|
||||
let mut take = take.clone();
|
||||
if take.name.is_empty() {
|
||||
take.name = format!("Take {}", base + i + 1);
|
||||
}
|
||||
instance.takes.push(take);
|
||||
}
|
||||
|
||||
// Land on the take just recorded, GarageBand-style.
|
||||
instance.active_take = Some(instance.takes.len() - 1);
|
||||
self.executed = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer_mut(&self.layer_id) {
|
||||
if let Some(instance) = audio_layer
|
||||
.clip_instances
|
||||
.iter_mut()
|
||||
.find(|ci| ci.id == self.instance_id)
|
||||
{
|
||||
instance.takes.truncate(self.old_take_count);
|
||||
instance.active_take = self.old_active_take;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
format!("Record {} take(s)", self.new_takes.len())
|
||||
}
|
||||
|
||||
fn execute_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
) -> Result<(), String> {
|
||||
self.resync(backend, document)
|
||||
}
|
||||
|
||||
fn rollback_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
) -> Result<(), String> {
|
||||
self.resync(backend, document)
|
||||
}
|
||||
}
|
||||
|
|
@ -91,7 +91,7 @@ impl Action for LoopClipInstancesAction {
|
|||
|
||||
impl LoopClipInstancesAction {
|
||||
fn sync_backend(&self, backend: &mut crate::action::BackendContext, document: &Document, rollback: bool) -> Result<(), String> {
|
||||
use crate::clip::ResolvedContent;
|
||||
use crate::clip::AudioClipType;
|
||||
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
|
|
@ -128,22 +128,26 @@ impl LoopClipInstancesAction {
|
|||
(new_dur, new_lb)
|
||||
};
|
||||
|
||||
// Natural content length as a beats span (the fallback when no explicit
|
||||
// timeline_duration is set). Resolved in the clip's own domain, so MIDI's beats
|
||||
// content carries over directly rather than being read as seconds.
|
||||
let content_window_beats = instance.effective_duration_beats(
|
||||
clip.content_duration(),
|
||||
document.tempo_map(),
|
||||
);
|
||||
let content_window = {
|
||||
let trim_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
(trim_end - instance.trim_start).max(0.0) // seconds
|
||||
};
|
||||
// Natural content length as a beats span at the clip's start (the
|
||||
// fallback when no explicit timeline_duration is set).
|
||||
let tempo_map = document.tempo_map();
|
||||
let content_window_beats = tempo_map.seconds_to_beats(
|
||||
tempo_map.beats_to_seconds(instance.timeline_start)
|
||||
+ daw_backend::Seconds(content_window),
|
||||
) - instance.timeline_start;
|
||||
let right_duration = target_duration.unwrap_or(content_window_beats);
|
||||
let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO);
|
||||
let external_duration = left_duration + right_duration;
|
||||
let external_start = instance.timeline_start - left_duration;
|
||||
|
||||
let get_backend_clip_id = |inst_id: &Uuid| -> Result<u32, String> {
|
||||
match &instance.resolve(clip) {
|
||||
ResolvedContent::Midi { midi_clip_id } => Ok(*midi_clip_id),
|
||||
ResolvedContent::Audio { .. } => {
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => Ok(*midi_clip_id),
|
||||
AudioClipType::Sampled { .. } => {
|
||||
let backend_id = backend.clip_instance_to_backend_map.get(inst_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?;
|
||||
match backend_id {
|
||||
|
|
@ -151,7 +155,7 @@ impl LoopClipInstancesAction {
|
|||
_ => Err("Expected audio instance ID for sampled clip".to_string()),
|
||||
}
|
||||
}
|
||||
ResolvedContent::Recording => Err("Cannot sync recording clip".to_string()),
|
||||
AudioClipType::Recording => Err("Cannot sync recording clip".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,419 +0,0 @@
|
|||
//! Take management: delete and rename the takes on a clip instance.
|
||||
//!
|
||||
//! Takes live on the INSTANCE, so both of these are naturally scoped to the one the user clicked —
|
||||
//! deleting a take from one half of a comped split leaves the other half's list alone.
|
||||
|
||||
use crate::action::{Action, BackendClipInstanceId, BackendContext};
|
||||
use crate::clip::{AudioTake, ClipInstance};
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// The instance a take action targets, looked up mutably.
|
||||
fn instance_mut<'a>(
|
||||
document: &'a mut Document,
|
||||
layer_id: &Uuid,
|
||||
instance_id: &Uuid,
|
||||
) -> Result<&'a mut ClipInstance, String> {
|
||||
let layer = document
|
||||
.get_layer_mut(layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
||||
let AnyLayer::Audio(audio_layer) = layer else {
|
||||
return Err("Takes only exist on audio layers".to_string());
|
||||
};
|
||||
audio_layer
|
||||
.clip_instances
|
||||
.iter_mut()
|
||||
.find(|ci| ci.id == *instance_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not found", instance_id))
|
||||
}
|
||||
|
||||
/// Swap an instance's backend clip to whatever take the document now says is active.
|
||||
///
|
||||
/// The same remove + re-add as `SetActiveTakeAction` — there's no in-place pool-swap command.
|
||||
fn resync(
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
layer_id: &Uuid,
|
||||
instance_id: &Uuid,
|
||||
) -> Result<(), String> {
|
||||
let instance = document
|
||||
.get_layer(layer_id)
|
||||
.and_then(|l| match l {
|
||||
AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == *instance_id),
|
||||
_ => None,
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Clip instance {} not found", instance_id))?;
|
||||
|
||||
let existing: Option<BackendClipInstanceId> = backend
|
||||
.clip_instance_to_backend_map
|
||||
.get(instance_id)
|
||||
.copied();
|
||||
let track_id = backend.layer_to_track_map.get(layer_id).copied();
|
||||
if let (Some(backend_id), Some(track_id)) = (existing, track_id) {
|
||||
backend.remove_clip_instance(track_id, backend_id, *instance_id);
|
||||
}
|
||||
|
||||
backend.add_clip_instance(document, layer_id, &instance)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a take from an instance's take list.
|
||||
///
|
||||
/// The take's recorded audio/MIDI stays in the backend pool — undo has to be able to put it back,
|
||||
/// and other instances (the other half of a split, say) may still be playing it.
|
||||
pub struct DeleteTakeAction {
|
||||
layer_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
take_index: usize,
|
||||
|
||||
// Stored during execute for rollback.
|
||||
removed: Option<AudioTake>,
|
||||
old_active_take: Option<usize>,
|
||||
}
|
||||
|
||||
impl DeleteTakeAction {
|
||||
pub fn new(layer_id: Uuid, instance_id: Uuid, take_index: usize) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
instance_id,
|
||||
take_index,
|
||||
removed: None,
|
||||
old_active_take: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for DeleteTakeAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
|
||||
|
||||
if self.take_index >= instance.takes.len() {
|
||||
return Err(format!("Take {} does not exist", self.take_index + 1));
|
||||
}
|
||||
// An instance with no takes at all would fall back to the clip's own content, which for a
|
||||
// cycle recording is a take we may just have deleted. Refuse rather than strand it.
|
||||
if instance.takes.len() == 1 {
|
||||
return Err("Can't delete the only take".to_string());
|
||||
}
|
||||
|
||||
self.old_active_take = instance.active_take;
|
||||
self.removed = Some(instance.takes.remove(self.take_index));
|
||||
|
||||
// Everything above the removed take shifts down one, so the selection has to move with it.
|
||||
// Deleting the *active* take lands on the one that took its place (or the new last take, if
|
||||
// it was at the end) — that keeps the clip sounding rather than silently picking take 1.
|
||||
let active = instance.active_take.unwrap_or(0);
|
||||
instance.active_take = Some(if active > self.take_index {
|
||||
active - 1
|
||||
} else if active == self.take_index {
|
||||
self.take_index.min(instance.takes.len() - 1)
|
||||
} else {
|
||||
active
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let Some(take) = self.removed.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
|
||||
let at = self.take_index.min(instance.takes.len());
|
||||
instance.takes.insert(at, take);
|
||||
instance.active_take = self.old_active_take;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
format!("Delete take {}", self.take_index + 1)
|
||||
}
|
||||
|
||||
fn execute_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
) -> Result<(), String> {
|
||||
resync(backend, document, &self.layer_id, &self.instance_id)
|
||||
}
|
||||
|
||||
fn rollback_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
) -> Result<(), String> {
|
||||
resync(backend, document, &self.layer_id, &self.instance_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Throw away every take except the one that's playing.
|
||||
///
|
||||
/// The tidy-up once you've picked your keeper. Scoped to this instance, so on a comped split it
|
||||
/// prunes the half you clicked and leaves the other half's alternatives intact.
|
||||
pub struct DeleteUnusedTakesAction {
|
||||
layer_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
|
||||
// Stored during execute for rollback.
|
||||
old_takes: Vec<AudioTake>,
|
||||
old_active_take: Option<usize>,
|
||||
}
|
||||
|
||||
impl DeleteUnusedTakesAction {
|
||||
pub fn new(layer_id: Uuid, instance_id: Uuid) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
instance_id,
|
||||
old_takes: Vec::new(),
|
||||
old_active_take: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for DeleteUnusedTakesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
|
||||
if instance.takes.len() < 2 {
|
||||
return Err("Nothing to delete".to_string());
|
||||
}
|
||||
|
||||
let keep = instance.active_take_index();
|
||||
self.old_takes = instance.takes.clone();
|
||||
self.old_active_take = instance.active_take;
|
||||
|
||||
let kept = instance.takes.remove(keep);
|
||||
instance.takes.clear();
|
||||
instance.takes.push(kept);
|
||||
instance.active_take = Some(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
|
||||
instance.takes = std::mem::take(&mut self.old_takes);
|
||||
instance.active_take = self.old_active_take;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"Delete unused takes".to_string()
|
||||
}
|
||||
|
||||
// The take that plays doesn't change, so the backend clip is already correct.
|
||||
}
|
||||
|
||||
/// Rename a take. Document-only — which take *plays* doesn't change, so the backend is untouched.
|
||||
pub struct RenameTakeAction {
|
||||
layer_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
take_index: usize,
|
||||
new_name: String,
|
||||
old_name: String,
|
||||
}
|
||||
|
||||
impl RenameTakeAction {
|
||||
pub fn new(layer_id: Uuid, instance_id: Uuid, take_index: usize, new_name: String) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
instance_id,
|
||||
take_index,
|
||||
new_name,
|
||||
old_name: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for RenameTakeAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
|
||||
let take = instance
|
||||
.takes
|
||||
.get_mut(self.take_index)
|
||||
.ok_or_else(|| format!("Take {} does not exist", self.take_index + 1))?;
|
||||
self.old_name = std::mem::replace(&mut take.name, self.new_name.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
|
||||
if let Some(take) = instance.takes.get_mut(self.take_index) {
|
||||
take.name = self.old_name.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
format!("Rename take to \"{}\"", self.new_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clip::TakeContent;
|
||||
use crate::layer::AudioLayer;
|
||||
|
||||
/// A document with one audio layer holding one instance with 4 takes (pools 10..13).
|
||||
fn doc_with_takes() -> (Document, Uuid, Uuid) {
|
||||
let mut document = Document::new("Test");
|
||||
let clip = crate::clip::AudioClip::new_sampled("Cycle rec", 10, 2.0);
|
||||
let clip_id = document.add_audio_clip(clip);
|
||||
|
||||
let mut instance = ClipInstance::new(clip_id);
|
||||
instance.takes = (10..14)
|
||||
.map(|pool| AudioTake {
|
||||
name: format!("Take {}", pool - 9),
|
||||
content: TakeContent::Audio { audio_pool_index: pool },
|
||||
})
|
||||
.collect();
|
||||
let instance_id = instance.id;
|
||||
|
||||
let mut layer = AudioLayer::new("Layer");
|
||||
layer.clip_instances.push(instance);
|
||||
let layer_id = document.root.add_child(AnyLayer::Audio(layer));
|
||||
(document, layer_id, instance_id)
|
||||
}
|
||||
|
||||
fn takes_of(document: &Document, layer_id: &Uuid, instance_id: &Uuid) -> ClipInstance {
|
||||
let AnyLayer::Audio(al) = document.get_layer(layer_id).unwrap() else { panic!() };
|
||||
al.clip_instances.iter().find(|ci| ci.id == *instance_id).unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_take_below_the_active_one_shifts_the_selection_down() {
|
||||
// Everything above the removed take shifts down one, so a selection above it has to move
|
||||
// with it — otherwise the instance silently starts playing a different take.
|
||||
let (mut document, layer_id, instance_id) = doc_with_takes();
|
||||
{
|
||||
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
|
||||
al.clip_instances[0].active_take = Some(3); // playing pool 13
|
||||
}
|
||||
|
||||
DeleteTakeAction::new(layer_id, instance_id, 1)
|
||||
.execute(&mut document)
|
||||
.expect("delete");
|
||||
|
||||
let inst = takes_of(&document, &layer_id, &instance_id);
|
||||
assert_eq!(inst.takes.len(), 3);
|
||||
assert_eq!(inst.active_take, Some(2), "index shifted down with the take");
|
||||
assert_eq!(
|
||||
inst.takes[inst.active_take_index()].content,
|
||||
TakeContent::Audio { audio_pool_index: 13 },
|
||||
"still playing the same take it was",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_active_take_lands_on_its_replacement() {
|
||||
// Deleting what you're listening to should hand you the take that took its place, not
|
||||
// silently jump you back to take 1.
|
||||
let (mut document, layer_id, instance_id) = doc_with_takes();
|
||||
{
|
||||
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
|
||||
al.clip_instances[0].active_take = Some(1); // playing pool 11
|
||||
}
|
||||
|
||||
DeleteTakeAction::new(layer_id, instance_id, 1)
|
||||
.execute(&mut document)
|
||||
.expect("delete");
|
||||
|
||||
let inst = takes_of(&document, &layer_id, &instance_id);
|
||||
assert_eq!(inst.active_take, Some(1));
|
||||
assert_eq!(
|
||||
inst.takes[1].content,
|
||||
TakeContent::Audio { audio_pool_index: 12 },
|
||||
"the take that slid into the deleted one's place",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_last_take_in_the_list_steps_back() {
|
||||
let (mut document, layer_id, instance_id) = doc_with_takes();
|
||||
{
|
||||
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
|
||||
al.clip_instances[0].active_take = Some(3);
|
||||
}
|
||||
|
||||
DeleteTakeAction::new(layer_id, instance_id, 3)
|
||||
.execute(&mut document)
|
||||
.expect("delete");
|
||||
|
||||
let inst = takes_of(&document, &layer_id, &instance_id);
|
||||
assert_eq!(inst.active_take, Some(2), "there is no take 4 to land on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_only_take_cannot_be_deleted() {
|
||||
let (mut document, layer_id, instance_id) = doc_with_takes();
|
||||
{
|
||||
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
|
||||
al.clip_instances[0].takes.truncate(1);
|
||||
}
|
||||
assert!(DeleteTakeAction::new(layer_id, instance_id, 0)
|
||||
.execute(&mut document)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_a_delete_puts_the_take_back_where_it_was() {
|
||||
let (mut document, layer_id, instance_id) = doc_with_takes();
|
||||
{
|
||||
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
|
||||
al.clip_instances[0].active_take = Some(2);
|
||||
}
|
||||
|
||||
let mut action = DeleteTakeAction::new(layer_id, instance_id, 1);
|
||||
action.execute(&mut document).expect("delete");
|
||||
action.rollback(&mut document).expect("undo");
|
||||
|
||||
let inst = takes_of(&document, &layer_id, &instance_id);
|
||||
assert_eq!(inst.takes.len(), 4);
|
||||
assert_eq!(
|
||||
inst.takes[1].content,
|
||||
TakeContent::Audio { audio_pool_index: 11 },
|
||||
"restored at its original index",
|
||||
);
|
||||
assert_eq!(inst.active_take, Some(2), "and the selection with it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_unused_takes_keeps_the_one_thats_playing() {
|
||||
let (mut document, layer_id, instance_id) = doc_with_takes();
|
||||
{
|
||||
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
|
||||
al.clip_instances[0].active_take = Some(2); // pool 12 — the keeper
|
||||
}
|
||||
|
||||
let mut action = DeleteUnusedTakesAction::new(layer_id, instance_id);
|
||||
action.execute(&mut document).expect("prune");
|
||||
|
||||
let inst = takes_of(&document, &layer_id, &instance_id);
|
||||
assert_eq!(inst.takes.len(), 1);
|
||||
assert_eq!(inst.active_take, Some(0));
|
||||
assert_eq!(
|
||||
inst.takes[0].content,
|
||||
TakeContent::Audio { audio_pool_index: 12 },
|
||||
"the take that was playing survives, and nothing else",
|
||||
);
|
||||
|
||||
action.rollback(&mut document).expect("undo");
|
||||
let inst = takes_of(&document, &layer_id, &instance_id);
|
||||
assert_eq!(inst.takes.len(), 4);
|
||||
assert_eq!(inst.active_take, Some(2), "back to what was playing before");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_take_round_trips() {
|
||||
let (mut document, layer_id, instance_id) = doc_with_takes();
|
||||
let mut action =
|
||||
RenameTakeAction::new(layer_id, instance_id, 2, "The good one".to_string());
|
||||
|
||||
action.execute(&mut document).expect("rename");
|
||||
assert_eq!(takes_of(&document, &layer_id, &instance_id).takes[2].name, "The good one");
|
||||
|
||||
action.rollback(&mut document).expect("undo");
|
||||
assert_eq!(takes_of(&document, &layer_id, &instance_id).takes[2].name, "Take 3");
|
||||
}
|
||||
}
|
||||
|
|
@ -14,10 +14,6 @@ pub mod move_clip_instances;
|
|||
pub mod reorder_clip_instances;
|
||||
pub mod paint_bucket;
|
||||
pub mod remove_effect;
|
||||
pub mod set_cycle_region;
|
||||
pub mod append_takes;
|
||||
pub mod manage_takes;
|
||||
pub mod set_active_take;
|
||||
pub mod set_document_properties;
|
||||
pub mod set_instance_properties;
|
||||
pub mod set_layer_properties;
|
||||
|
|
@ -54,10 +50,6 @@ pub mod set_text_content;
|
|||
pub mod resize_text_box;
|
||||
|
||||
pub use add_clip_instance::AddClipInstanceAction;
|
||||
pub use set_cycle_region::SetCycleRegionAction;
|
||||
pub use append_takes::AppendTakesAction;
|
||||
pub use manage_takes::{DeleteTakeAction, DeleteUnusedTakesAction, RenameTakeAction};
|
||||
pub use set_active_take::SetActiveTakeAction;
|
||||
pub use add_effect::AddEffectAction;
|
||||
pub use add_layer::AddLayerAction;
|
||||
pub use add_shape::AddShapeAction;
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ impl Action for MoveClipInstancesAction {
|
|||
|
||||
let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| {
|
||||
let inst = clip_instances.iter().find(|ci| &ci.id == id)?;
|
||||
let dur = document.clip_trim_duration(&inst.clip_id)?;
|
||||
let dur = document.get_clip_duration(&inst.clip_id)?;
|
||||
let eff = inst.effective_duration_beats(dur, document.tempo_map());
|
||||
Some((*id, *old_start, eff))
|
||||
}).collect();
|
||||
|
|
@ -190,7 +190,7 @@ impl Action for MoveClipInstancesAction {
|
|||
|
||||
fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::clip::ResolvedContent;
|
||||
use crate::clip::AudioClipType;
|
||||
|
||||
// Get audio controller
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
|
|
@ -211,9 +211,8 @@ impl Action for MoveClipInstancesAction {
|
|||
// Check if this clip has a metatrack
|
||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start));
|
||||
// A vector clip's content is wall-clock, so its content times ARE seconds.
|
||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
|
||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -247,12 +246,12 @@ impl Action for MoveClipInstancesAction {
|
|||
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
||||
|
||||
// Handle move based on clip type
|
||||
match &instance.resolve(clip) {
|
||||
ResolvedContent::Midi { midi_clip_id } => {
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
// For MIDI: move_clip expects the pool clip ID
|
||||
controller.move_clip(*track_id, *midi_clip_id, *new_start);
|
||||
}
|
||||
ResolvedContent::Audio { .. } => {
|
||||
AudioClipType::Sampled { .. } => {
|
||||
// For sampled audio: move_clip expects the instance ID
|
||||
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
||||
|
|
@ -264,7 +263,7 @@ impl Action for MoveClipInstancesAction {
|
|||
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
||||
}
|
||||
}
|
||||
ResolvedContent::Recording => {
|
||||
AudioClipType::Recording => {
|
||||
// Recording clips cannot be moved - skip
|
||||
}
|
||||
}
|
||||
|
|
@ -276,7 +275,7 @@ impl Action for MoveClipInstancesAction {
|
|||
|
||||
fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::clip::ResolvedContent;
|
||||
use crate::clip::AudioClipType;
|
||||
|
||||
// Get audio controller
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
|
|
@ -296,9 +295,8 @@ impl Action for MoveClipInstancesAction {
|
|||
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start));
|
||||
// A vector clip's content is wall-clock, so its content times ARE seconds.
|
||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
|
||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -332,12 +330,12 @@ impl Action for MoveClipInstancesAction {
|
|||
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
||||
|
||||
// Handle move based on clip type (restore old position)
|
||||
match &instance.resolve(clip) {
|
||||
ResolvedContent::Midi { midi_clip_id } => {
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
// For MIDI: move_clip expects the pool clip ID
|
||||
controller.move_clip(*track_id, *midi_clip_id, *old_start);
|
||||
}
|
||||
ResolvedContent::Audio { .. } => {
|
||||
AudioClipType::Sampled { .. } => {
|
||||
// For sampled audio: move_clip expects the instance ID
|
||||
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
||||
|
|
@ -349,7 +347,7 @@ impl Action for MoveClipInstancesAction {
|
|||
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
||||
}
|
||||
}
|
||||
ResolvedContent::Recording => {
|
||||
AudioClipType::Recording => {
|
||||
// Recording clips cannot be moved - skip
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,23 +138,92 @@ impl Action for RemoveClipInstancesAction {
|
|||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
) -> Result<(), String> {
|
||||
if backend.audio_controller.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
use crate::clip::AudioClipType;
|
||||
|
||||
// Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same
|
||||
// helper the add and split actions use, so the trim/duration conversions (and take-folder
|
||||
// resolution) stay in exactly one place instead of being copied into every action that has
|
||||
// to put a clip back.
|
||||
let saved = std::mem::take(&mut self.saved);
|
||||
for (layer_id, instance) in &saved {
|
||||
if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) {
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Re-add clips that were removed from backend
|
||||
for (layer_id, instance) in &self.saved {
|
||||
let layer = match document.get_layer(layer_id) {
|
||||
Some(l) => l,
|
||||
None => continue,
|
||||
};
|
||||
if !matches!(layer, AnyLayer::Audio(_)) {
|
||||
continue;
|
||||
}
|
||||
// A missing track/clip just means there's nothing to restore on the backend.
|
||||
let _ = backend.add_clip_instance(document, layer_id, instance);
|
||||
|
||||
let track_id = match backend.layer_to_track_map.get(layer_id) {
|
||||
Some(id) => *id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let clip = match document.get_audio_clip(&instance.clip_id) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
use daw_backend::command::{Query, QueryResponse};
|
||||
|
||||
let internal_start = instance.trim_start;
|
||||
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
let external_start = instance.timeline_start;
|
||||
// MIDI trims are beats-domain, so the fallback span is beats too.
|
||||
let external_duration = instance
|
||||
.timeline_duration
|
||||
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
||||
|
||||
let midi_instance = daw_backend::MidiClipInstance::new(
|
||||
0,
|
||||
*midi_clip_id,
|
||||
daw_backend::Beats(internal_start),
|
||||
daw_backend::Beats(internal_end),
|
||||
external_start,
|
||||
external_duration,
|
||||
);
|
||||
|
||||
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
|
||||
if let Ok(QueryResponse::MidiClipInstanceAdded(Ok(new_id))) =
|
||||
controller.send_query(query)
|
||||
{
|
||||
backend.clip_instance_to_backend_map.insert(
|
||||
instance.id,
|
||||
BackendClipInstanceId::Midi(new_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
AudioClipType::Sampled { audio_pool_index } => {
|
||||
let internal_start = instance.trim_start;
|
||||
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
let start_time = instance.timeline_start;
|
||||
// Fallback span is the content seconds converted to beats at the
|
||||
// clip's start (not the seconds span treated as beats).
|
||||
let effective_duration = instance.timeline_duration.unwrap_or_else(|| {
|
||||
let tempo_map = document.tempo_map();
|
||||
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
||||
- start_time
|
||||
});
|
||||
|
||||
let new_id = controller.add_audio_clip(
|
||||
track_id,
|
||||
*audio_pool_index,
|
||||
start_time,
|
||||
effective_duration,
|
||||
daw_backend::Seconds(internal_start),
|
||||
);
|
||||
backend.clip_instance_to_backend_map.insert(
|
||||
instance.id,
|
||||
BackendClipInstanceId::Audio(new_id),
|
||||
);
|
||||
}
|
||||
AudioClipType::Recording => {}
|
||||
}
|
||||
}
|
||||
self.saved = saved;
|
||||
|
||||
// Clear saved backend IDs
|
||||
self.saved_backend_ids.clear();
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
//! Choose which take of a take-folder clip an instance plays.
|
||||
//!
|
||||
//! The take list lives on the *clip* but the selection lives on the *instance*, so two instances of
|
||||
//! the same folder can play different takes. Splitting a take-folder instance clones it, which is
|
||||
//! what makes comping work: set take 1 on the left half and take 3 on the right, and you've comped.
|
||||
//!
|
||||
//! There's no in-place pool-swap command in the backend, so switching a take means removing the
|
||||
//! instance's backend clip and re-adding it against the new take's audio/MIDI resource. Both halves
|
||||
//! of that go through `BackendContext`, which is also what `AddClipInstanceAction` uses.
|
||||
|
||||
use crate::action::{Action, BackendClipInstanceId, BackendContext};
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that points a clip instance at a different take of its take folder.
|
||||
#[derive(Clone)]
|
||||
pub struct SetActiveTakeAction {
|
||||
layer_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
new_take: Option<usize>,
|
||||
old_take: Option<usize>,
|
||||
/// The backend track/clip the instance was on before we swapped, so rollback can undo it.
|
||||
backend_track_id: Option<daw_backend::TrackId>,
|
||||
}
|
||||
|
||||
impl SetActiveTakeAction {
|
||||
pub fn new(layer_id: Uuid, instance_id: Uuid, new_take: usize, old_take: Option<usize>) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
instance_id,
|
||||
new_take: Some(new_take),
|
||||
old_take,
|
||||
backend_track_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the instance at `take`, mutating the document. Shared by execute and rollback.
|
||||
fn apply(&self, document: &mut Document, take: Option<usize>) -> Result<(), String> {
|
||||
let layer = document
|
||||
.get_layer_mut(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||
let AnyLayer::Audio(audio_layer) = layer else {
|
||||
return Err("Take folders only exist on audio layers".to_string());
|
||||
};
|
||||
let instance = audio_layer
|
||||
.clip_instances
|
||||
.iter_mut()
|
||||
.find(|ci| ci.id == self.instance_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
|
||||
instance.active_take = take;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Swap the instance's backend clip to whatever take the document now says is active.
|
||||
///
|
||||
/// Called after the document has already been mutated, so re-adding just re-resolves the
|
||||
/// instance — `BackendContext::add_clip_instance` reads `active_take` itself.
|
||||
fn resync(&mut self, backend: &mut BackendContext, document: &Document) -> Result<(), String> {
|
||||
let instance = document
|
||||
.get_layer(&self.layer_id)
|
||||
.and_then(|l| match l {
|
||||
AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == self.instance_id),
|
||||
_ => None,
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
|
||||
|
||||
// Drop the old backend clip first. Its track comes from the map we're about to overwrite,
|
||||
// so read it before add_clip_instance replaces the entry.
|
||||
let existing: Option<BackendClipInstanceId> = backend
|
||||
.clip_instance_to_backend_map
|
||||
.get(&self.instance_id)
|
||||
.copied();
|
||||
let track_id = backend.layer_to_track_map.get(&self.layer_id).copied();
|
||||
if let (Some(backend_id), Some(track_id)) = (existing, track_id) {
|
||||
backend.remove_clip_instance(track_id, backend_id, self.instance_id);
|
||||
}
|
||||
|
||||
let added = backend.add_clip_instance(document, &self.layer_id, &instance)?;
|
||||
self.backend_track_id = added.map(|(track_id, _)| track_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SetActiveTakeAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
self.apply(document, self.new_take)
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
self.apply(document, self.old_take)
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
match self.new_take {
|
||||
Some(i) => format!("Select take {}", i + 1),
|
||||
None => "Select take".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
) -> Result<(), String> {
|
||||
self.resync(backend, document)
|
||||
}
|
||||
|
||||
fn rollback_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
) -> Result<(), String> {
|
||||
self.resync(backend, document)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
//! Set the transport cycle (loop) region.
|
||||
//!
|
||||
//! The cycle region is document state (it's saved in the `.beam`), so changing it goes through the
|
||||
//! action system like any other edit: it's undoable and it marks the document modified.
|
||||
//!
|
||||
//! The region is stored in **beats** so it stays put musically across tempo changes. Callers commit
|
||||
//! one action per gesture (e.g. on drag release, or a toggle click) rather than one per frame —
|
||||
//! the timeline previews the drag from its own local state, exactly like a clip drag does.
|
||||
|
||||
use crate::action::{Action, BackendContext};
|
||||
use crate::document::Document;
|
||||
use daw_backend::Beats;
|
||||
|
||||
/// Action that sets the cycle region and/or whether the transport loops over it.
|
||||
#[derive(Clone)]
|
||||
pub struct SetCycleRegionAction {
|
||||
old_region: Option<(Beats, Beats)>,
|
||||
old_enabled: bool,
|
||||
new_region: Option<(Beats, Beats)>,
|
||||
new_enabled: bool,
|
||||
}
|
||||
|
||||
impl SetCycleRegionAction {
|
||||
/// Build from the document's current state and the desired new region/enabled flag.
|
||||
pub fn new(
|
||||
document: &Document,
|
||||
new_region: Option<(Beats, Beats)>,
|
||||
new_enabled: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
old_region: document.cycle_region,
|
||||
old_enabled: document.cycle_enabled,
|
||||
new_region,
|
||||
new_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle looping on/off, leaving the region itself alone.
|
||||
pub fn toggle_enabled(document: &Document) -> Self {
|
||||
Self::new(document, document.cycle_region, !document.cycle_enabled)
|
||||
}
|
||||
|
||||
/// True if this action would not actually change anything (lets callers skip a no-op undo entry).
|
||||
pub fn is_noop(&self) -> bool {
|
||||
self.old_region == self.new_region && self.old_enabled == self.new_enabled
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SetCycleRegionAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
document.cycle_region = self.new_region;
|
||||
document.cycle_enabled = self.new_enabled;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
document.cycle_region = self.old_region;
|
||||
document.cycle_enabled = self.old_enabled;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"Set cycle region".to_string()
|
||||
}
|
||||
|
||||
fn execute_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
_document: &Document,
|
||||
) -> Result<(), String> {
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
controller.set_loop_region(self.new_region);
|
||||
controller.set_loop_enabled(self.new_enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
_document: &Document,
|
||||
) -> Result<(), String> {
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
controller.set_loop_region(self.old_region);
|
||||
controller.set_loop_enabled(self.old_enabled);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ use crate::action::{Action, BackendContext};
|
|||
use crate::clip::ClipInstance;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use daw_backend::ContentTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that splits a clip instance at a specific timeline position
|
||||
|
|
@ -26,7 +25,7 @@ pub struct SplitClipInstanceAction {
|
|||
|
||||
// Stored during execute for rollback
|
||||
/// Original trim_end value of the left (original) instance
|
||||
original_trim_end: Option<ContentTime>,
|
||||
original_trim_end: Option<f64>,
|
||||
/// Original timeline_duration value of the left (original) instance (beats)
|
||||
original_timeline_duration: Option<daw_backend::Beats>,
|
||||
/// ID of the new (right) instance created by the split
|
||||
|
|
@ -123,15 +122,13 @@ impl Action for SplitClipInstanceAction {
|
|||
.find(|ci| ci.id == self.instance_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
|
||||
|
||||
// The clip's content duration in its OWN domain — seconds for audio/video/vector, beats for
|
||||
// MIDI. All the content math below is trim-domain, so it has to be done in whichever domain
|
||||
// this clip uses; a seconds duration would silently be added to a MIDI clip's beats trim.
|
||||
let trim_duration = document
|
||||
.clip_trim_duration(&instance.clip_id)
|
||||
// Get the clip's duration
|
||||
let clip_duration = document
|
||||
.get_clip_duration(&instance.clip_id)
|
||||
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
||||
|
||||
// Calculate the effective duration and timeline end (both in beats)
|
||||
let effective_duration = instance.effective_duration(trim_duration, document.tempo_map());
|
||||
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
|
||||
let timeline_end = instance.timeline_start + effective_duration;
|
||||
|
||||
// Validate: split_time must be strictly within the clip's timeline span
|
||||
|
|
@ -149,30 +146,27 @@ impl Action for SplitClipInstanceAction {
|
|||
self.original_trim_end = instance.trim_end;
|
||||
self.original_timeline_duration = instance.timeline_duration;
|
||||
|
||||
// Check if this is a looping clip. `content_duration` is a trim-domain
|
||||
// span (seconds), so `clip_duration` must be unwrapped as seconds.
|
||||
let is_looping = instance.timeline_duration.is_some();
|
||||
let content_duration = ContentTime(instance.content_window(trim_duration).native());
|
||||
let content_duration = instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()) - instance.trim_start;
|
||||
|
||||
// Timeline split point (beats).
|
||||
let time_into_clip = self.split_time - instance.timeline_start;
|
||||
let left_duration = time_into_clip;
|
||||
let right_duration = effective_duration - left_duration;
|
||||
|
||||
// How far the split lands into the clip's *content*, expressed in the content domain: beats
|
||||
// content takes the beats delta directly, wall-clock content takes the seconds delta.
|
||||
// How far the split lands into the clip's *content* (seconds, trim domain).
|
||||
let tempo_map = document.tempo_map();
|
||||
let time_into_content = ContentTime(match trim_duration {
|
||||
crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(),
|
||||
crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time)
|
||||
- tempo_map.beats_to_seconds(instance.timeline_start))
|
||||
.seconds_to_f64(),
|
||||
});
|
||||
let time_into_clip_secs = (tempo_map.beats_to_seconds(self.split_time)
|
||||
- tempo_map.beats_to_seconds(instance.timeline_start)).seconds_to_f64();
|
||||
|
||||
// Calculate the content split point (content domain).
|
||||
let content_split_time = if is_looping && content_duration > ContentTime::ZERO {
|
||||
// Calculate content split time (seconds)
|
||||
let content_split_time = if is_looping {
|
||||
// For looping clips, wrap around content
|
||||
instance.trim_start + (time_into_content % content_duration)
|
||||
instance.trim_start + (time_into_clip_secs % content_duration)
|
||||
} else {
|
||||
instance.trim_start + time_into_content
|
||||
instance.trim_start + time_into_clip_secs
|
||||
};
|
||||
|
||||
// Clone the instance for the right side
|
||||
|
|
@ -363,63 +357,119 @@ impl Action for SplitClipInstanceAction {
|
|||
.get_audio_clip(&new_instance.clip_id)
|
||||
.ok_or_else(|| "Audio clip not found".to_string())?;
|
||||
|
||||
use crate::clip::ResolvedContent;
|
||||
if matches!(original_instance.resolve(clip), ResolvedContent::Recording) {
|
||||
return Err("Cannot split a clip that is currently recording".to_string());
|
||||
}
|
||||
|
||||
// A split is: shorten the left half's backend clip, then add the right half as a new one.
|
||||
//
|
||||
// 1. Trim the left (original) instance. `trim_range` tags the bounds with the clip's own
|
||||
// content domain, so a MIDI clip's beats trims can't be sent as seconds.
|
||||
let left_trim = clip.trim_range(
|
||||
original_instance.trim_start,
|
||||
original_instance
|
||||
.trim_end
|
||||
.unwrap_or(ContentTime(clip.content_duration().native())),
|
||||
);
|
||||
let new_instance = new_instance.clone();
|
||||
|
||||
let backend_track_id = *backend
|
||||
// Look up backend track ID from layer mapping
|
||||
let backend_track_id = backend
|
||||
.layer_to_track_map
|
||||
.get(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?;
|
||||
let left_backend_id = backend
|
||||
.clip_instance_to_backend_map
|
||||
.get(&self.instance_id)
|
||||
.copied();
|
||||
|
||||
{
|
||||
let controller = backend
|
||||
.audio_controller
|
||||
.as_mut()
|
||||
.ok_or_else(|| "Audio controller not available".to_string())?;
|
||||
match left_backend_id {
|
||||
Some(crate::action::BackendClipInstanceId::Midi(id))
|
||||
| Some(crate::action::BackendClipInstanceId::Audio(id)) => {
|
||||
controller.trim_clip(backend_track_id, id, left_trim);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Get audio controller
|
||||
let controller = backend
|
||||
.audio_controller
|
||||
.as_mut()
|
||||
.ok_or_else(|| "Audio controller not available".to_string())?;
|
||||
|
||||
// 2. Add the right (new) instance via the shared helper — same one AddClipInstanceAction
|
||||
// uses, so the trim/duration conversions live in exactly one place.
|
||||
if let Some((track_id, backend_id)) =
|
||||
backend.add_clip_instance(document, &self.layer_id, &new_instance)?
|
||||
{
|
||||
self.backend_track_id = Some(track_id);
|
||||
match backend_id {
|
||||
crate::action::BackendClipInstanceId::Midi(id) => {
|
||||
self.backend_midi_instance_id = Some(id)
|
||||
// Handle different clip types
|
||||
use crate::clip::AudioClipType;
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
use daw_backend::command::{Query, QueryResponse};
|
||||
|
||||
// 1. Trim the original (left) instance
|
||||
let orig_internal_start = original_instance.trim_start;
|
||||
let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
|
||||
// Look up the original backend instance ID
|
||||
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
|
||||
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
||||
{
|
||||
controller.trim_clip(*backend_track_id, *orig_backend_id, orig_internal_start, orig_internal_end);
|
||||
}
|
||||
crate::action::BackendClipInstanceId::Audio(id) => {
|
||||
self.backend_audio_instance_id = Some(id)
|
||||
|
||||
// 2. Add the new (right) instance
|
||||
let internal_start = new_instance.trim_start;
|
||||
let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
let external_start = new_instance.timeline_start;
|
||||
// MIDI trims are beats-domain, so the fallback span is beats too.
|
||||
let external_duration = new_instance
|
||||
.timeline_duration
|
||||
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
||||
|
||||
let instance = daw_backend::MidiClipInstance::new(
|
||||
0,
|
||||
*midi_clip_id,
|
||||
daw_backend::Beats(internal_start),
|
||||
daw_backend::Beats(internal_end),
|
||||
external_start,
|
||||
external_duration,
|
||||
);
|
||||
|
||||
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
|
||||
|
||||
match controller.send_query(query)? {
|
||||
QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => {
|
||||
self.backend_track_id = Some(*backend_track_id);
|
||||
self.backend_midi_instance_id = Some(instance_id);
|
||||
|
||||
backend.clip_instance_to_backend_map.insert(
|
||||
new_instance_id,
|
||||
crate::action::BackendClipInstanceId::Midi(instance_id),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e),
|
||||
_ => Err("Unexpected query response".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
AudioClipType::Sampled { audio_pool_index } => {
|
||||
// 1. Trim the original (left) instance
|
||||
let orig_internal_start = original_instance.trim_start;
|
||||
let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
|
||||
Ok(())
|
||||
// Look up the original backend instance ID
|
||||
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
|
||||
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
||||
{
|
||||
controller.trim_clip(*backend_track_id, *orig_backend_id, orig_internal_start, orig_internal_end);
|
||||
}
|
||||
|
||||
// 2. Add the new (right) instance
|
||||
let internal_start = new_instance.trim_start;
|
||||
let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
let start_time = new_instance.timeline_start;
|
||||
// Fallback span is the content seconds converted to beats at the
|
||||
// clip's start (not the seconds span treated as beats).
|
||||
let effective_duration = new_instance.timeline_duration.unwrap_or_else(|| {
|
||||
let tempo_map = document.tempo_map();
|
||||
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
||||
- start_time
|
||||
});
|
||||
|
||||
let instance_id = controller.add_audio_clip(
|
||||
*backend_track_id,
|
||||
*audio_pool_index,
|
||||
start_time,
|
||||
effective_duration,
|
||||
daw_backend::Seconds(internal_start),
|
||||
);
|
||||
|
||||
self.backend_track_id = Some(*backend_track_id);
|
||||
self.backend_audio_instance_id = Some(instance_id);
|
||||
|
||||
backend.clip_instance_to_backend_map.insert(
|
||||
new_instance_id,
|
||||
crate::action::BackendClipInstanceId::Audio(instance_id),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
AudioClipType::Recording => {
|
||||
// Recording clips cannot be split
|
||||
Err("Cannot split a clip that is currently recording".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback_backend(
|
||||
|
|
@ -449,28 +499,26 @@ impl Action for SplitClipInstanceAction {
|
|||
if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) {
|
||||
if let Some(clip) = document.get_audio_clip(&instance.clip_id) {
|
||||
let orig_internal_start = instance.trim_start;
|
||||
let orig_internal_end = self
|
||||
.original_trim_end
|
||||
.unwrap_or(ContentTime(clip.content_duration().native()));
|
||||
let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native());
|
||||
|
||||
// Restore based on clip type
|
||||
use crate::clip::ResolvedContent;
|
||||
match &instance.resolve(clip) {
|
||||
ResolvedContent::Midi { .. } => {
|
||||
use crate::clip::AudioClipType;
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { .. } => {
|
||||
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
|
||||
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
||||
{
|
||||
controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
|
||||
controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end);
|
||||
}
|
||||
}
|
||||
ResolvedContent::Audio { .. } => {
|
||||
AudioClipType::Sampled { .. } => {
|
||||
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
|
||||
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
||||
{
|
||||
controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
|
||||
controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end);
|
||||
}
|
||||
}
|
||||
ResolvedContent::Recording => {
|
||||
AudioClipType::Recording => {
|
||||
// Recording clips - nothing to rollback
|
||||
}
|
||||
}
|
||||
|
|
@ -506,8 +554,8 @@ mod tests {
|
|||
// Create a clip instance at timeline 0, with trim 0-10 (10 seconds)
|
||||
let mut clip_instance = ClipInstance::new(clip_id);
|
||||
clip_instance.timeline_start = daw_backend::Beats::ZERO;
|
||||
clip_instance.trim_start = ContentTime::ZERO;
|
||||
clip_instance.trim_end = Some(ContentTime(10.0));
|
||||
clip_instance.trim_start = 0.0;
|
||||
clip_instance.trim_end = Some(10.0);
|
||||
let instance_id = clip_instance.id;
|
||||
vector_layer.clip_instances.push(clip_instance);
|
||||
|
||||
|
|
@ -527,42 +575,4 @@ mod tests {
|
|||
let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0));
|
||||
assert_eq!(action.description(), "Split clip instance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_a_midi_clip_stays_in_the_beats_domain() {
|
||||
// Regression: `trim_start`/`trim_end` are domain-polymorphic — SECONDS for audio/video/
|
||||
// vector, but BEATS for MIDI (the backend takes MIDI trims as `Beats`). Split used to map
|
||||
// the split point into the clip's content in seconds unconditionally, so on a MIDI clip it
|
||||
// added a seconds delta to a beats offset. At anything but 60 BPM the right half started at
|
||||
// the wrong place in the content.
|
||||
//
|
||||
// At 120 BPM, beat 4 is 2 SECONDS in. The right half must trim to beat 4, not "4 seconds"
|
||||
// (= beat 8) and not 2 (the seconds value).
|
||||
let mut document = Document::new("Test");
|
||||
document.set_bpm(120.0);
|
||||
|
||||
// 8-beat MIDI clip at the timeline origin.
|
||||
let clip = crate::clip::AudioClip::new_midi("Midi", 1, daw_backend::Beats(8.0));
|
||||
let clip_id = document.add_audio_clip(clip);
|
||||
|
||||
let mut audio_layer = crate::layer::AudioLayer::new("Layer 1");
|
||||
let mut instance = ClipInstance::new(clip_id);
|
||||
instance.timeline_start = daw_backend::Beats::ZERO;
|
||||
instance.trim_start = ContentTime::ZERO;
|
||||
instance.trim_end = Some(ContentTime(8.0)); // beats
|
||||
let instance_id = instance.id;
|
||||
audio_layer.clip_instances.push(instance);
|
||||
let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer));
|
||||
|
||||
let mut action = SplitClipInstanceAction::new(layer_id, instance_id, daw_backend::Beats(4.0));
|
||||
action.execute(&mut document).expect("split");
|
||||
let new_id = action.new_instance_id().expect("right instance");
|
||||
|
||||
let AnyLayer::Audio(al) = document.get_layer(&layer_id).unwrap() else { panic!() };
|
||||
let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap();
|
||||
let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
|
||||
|
||||
assert_eq!(right.trim_start, ContentTime(4.0), "right half must start 4 BEATS into the content");
|
||||
assert_eq!(left.trim_end, Some(ContentTime(4.0)), "left half must end 4 BEATS into the content");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::action::Action;
|
|||
use crate::clip::ClipInstance;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use daw_backend::{Beats, ContentTime, Seconds};
|
||||
use daw_backend::{Beats, Seconds};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -32,57 +32,15 @@ pub struct TrimClipInstancesAction {
|
|||
pub struct TrimData {
|
||||
/// For TrimLeft: trim_start value
|
||||
/// For TrimRight: trim_end value (Option because it can be None)
|
||||
///
|
||||
/// A content time — measured in the clip's own domain (seconds for audio/video/vector, beats
|
||||
/// for MIDI), so it must be resolved against the clip before meeting a timeline position.
|
||||
pub trim_value: Option<ContentTime>,
|
||||
pub trim_value: Option<f64>,
|
||||
/// For TrimLeft: timeline_start value (where the clip appears on timeline, beats)
|
||||
/// For TrimRight: unused (None)
|
||||
pub timeline_start: Option<Beats>,
|
||||
}
|
||||
|
||||
/// A wall-clock gap on the timeline, expressed in a clip's content domain.
|
||||
///
|
||||
/// Trim validation clamps how far a clip may be dragged against the empty space next to it, and that
|
||||
/// space is measured on the timeline (seconds) while the trim lives in the clip's content domain. For
|
||||
/// wall-clock content they're the same number; for MIDI (beats content) the gap has to be converted
|
||||
/// at the clip's position, or a seconds gap silently clamps a beats trim.
|
||||
fn gap_to_content(
|
||||
gap: Seconds,
|
||||
clip_content: crate::clip::ClipDuration,
|
||||
timeline_start: Beats,
|
||||
tempo_map: &crate::tempo_map::TempoMap,
|
||||
) -> ContentTime {
|
||||
match clip_content {
|
||||
crate::clip::ClipDuration::Seconds(_) => ContentTime(gap.seconds_to_f64()),
|
||||
crate::clip::ClipDuration::Beats(_) => {
|
||||
let beats = tempo_map
|
||||
.seconds_to_beats(tempo_map.beats_to_seconds(timeline_start) + gap)
|
||||
- timeline_start;
|
||||
ContentTime(beats.beats_to_f64())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse: a content-domain span as wall-clock seconds at the clip's position.
|
||||
fn content_to_secs(
|
||||
span: ContentTime,
|
||||
clip_content: crate::clip::ClipDuration,
|
||||
timeline_start: Beats,
|
||||
tempo_map: &crate::tempo_map::TempoMap,
|
||||
) -> Seconds {
|
||||
match clip_content {
|
||||
crate::clip::ClipDuration::Seconds(_) => Seconds(span.raw()),
|
||||
crate::clip::ClipDuration::Beats(_) => {
|
||||
tempo_map.beats_to_seconds(timeline_start + Beats(span.raw()))
|
||||
- tempo_map.beats_to_seconds(timeline_start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrimData {
|
||||
/// Create TrimData for left trim
|
||||
pub fn left(trim_start: ContentTime, timeline_start: Beats) -> Self {
|
||||
pub fn left(trim_start: f64, timeline_start: Beats) -> Self {
|
||||
Self {
|
||||
trim_value: Some(trim_start),
|
||||
timeline_start: Some(timeline_start),
|
||||
|
|
@ -90,7 +48,7 @@ impl TrimData {
|
|||
}
|
||||
|
||||
/// Create TrimData for right trim
|
||||
pub fn right(trim_end: Option<ContentTime>) -> Self {
|
||||
pub fn right(trim_end: Option<f64>) -> Self {
|
||||
Self {
|
||||
trim_value: trim_end,
|
||||
timeline_start: None,
|
||||
|
|
@ -234,8 +192,7 @@ impl Action for TrimClipInstancesAction {
|
|||
.find(|ci| &ci.id == instance_id)
|
||||
.ok_or_else(|| format!("Instance {} not found", instance_id))?;
|
||||
|
||||
// The clip's content duration in ITS OWN domain, so trims resolve correctly for MIDI.
|
||||
let clip_content = document.clip_trim_duration(&instance.clip_id)
|
||||
let clip_duration = document.get_clip_duration(&instance.clip_id)
|
||||
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
||||
|
||||
let mut clamped_new = new.clone();
|
||||
|
|
@ -247,34 +204,23 @@ impl Action for TrimClipInstancesAction {
|
|||
{
|
||||
// If extending to the left (new_trim < old_trim)
|
||||
if should_validate && new_trim < old_trim {
|
||||
let tempo_map = document.tempo_map();
|
||||
|
||||
// Max leftward extension: the gap's wall-clock span, converted into
|
||||
// the clip's content domain so it can clamp a content-domain trim.
|
||||
let max_extend = gap_to_content(
|
||||
document.find_max_trim_extend_left(
|
||||
layer_id,
|
||||
instance_id,
|
||||
instance.timeline_start,
|
||||
),
|
||||
clip_content,
|
||||
// Max leftward extension as content seconds (the gap's wall-clock span).
|
||||
let max_extend_secs = document.find_max_trim_extend_left(
|
||||
layer_id,
|
||||
instance_id,
|
||||
instance.timeline_start,
|
||||
tempo_map,
|
||||
);
|
||||
).seconds_to_f64();
|
||||
|
||||
// Calculate how much we want to extend (content seconds)
|
||||
let desired_extend = old_trim - new_trim;
|
||||
let actual_extend = desired_extend.min(max_extend);
|
||||
let clamped_trim_start = old_trim - actual_extend;
|
||||
|
||||
// Move the timeline left by the same span, as wall-clock seconds.
|
||||
let shift = content_to_secs(
|
||||
actual_extend,
|
||||
clip_content,
|
||||
instance.timeline_start,
|
||||
tempo_map,
|
||||
);
|
||||
// Clamp to max allowed
|
||||
let actual_extend = desired_extend.min(max_extend_secs);
|
||||
let clamped_trim_start = old_trim - actual_extend;
|
||||
// Move the timeline left by the same wall-clock seconds.
|
||||
let tempo_map = document.tempo_map();
|
||||
let clamped_timeline_start = tempo_map
|
||||
.seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - shift)
|
||||
.seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - Seconds(actual_extend))
|
||||
.max(Beats::ZERO);
|
||||
|
||||
clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
|
||||
|
|
@ -282,39 +228,36 @@ impl Action for TrimClipInstancesAction {
|
|||
}
|
||||
}
|
||||
TrimType::TrimRight => {
|
||||
let content_end = ContentTime(clip_content.native());
|
||||
let old_trim_end = old.trim_value.unwrap_or(content_end);
|
||||
let new_trim_end = new.trim_value.unwrap_or(content_end);
|
||||
let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64());
|
||||
let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64());
|
||||
|
||||
// If extending to the right (new_trim_end > old_trim_end)
|
||||
if should_validate && new_trim_end > old_trim_end {
|
||||
let tempo_map = document.tempo_map();
|
||||
// Current effective duration in beats (content seconds
|
||||
// converted to beats at the clip's start).
|
||||
let content_secs = Seconds(old_trim_end - instance.trim_start);
|
||||
let current_effective_duration = tempo_map.seconds_to_beats(
|
||||
tempo_map.beats_to_seconds(instance.timeline_start) + content_secs,
|
||||
) - instance.timeline_start;
|
||||
|
||||
// How long the clip currently occupies the timeline, in beats. Resolved
|
||||
// in the clip's own domain, so a MIDI clip's beats content isn't run
|
||||
// through the seconds→beats conversion a second time.
|
||||
let current_effective_duration = instance
|
||||
.effective_duration_beats(clip_content, tempo_map);
|
||||
|
||||
// Max rightward extension: the gap's wall-clock span, in content domain.
|
||||
let max_extend = gap_to_content(
|
||||
document.find_max_trim_extend_right(
|
||||
layer_id,
|
||||
instance_id,
|
||||
instance.timeline_start,
|
||||
current_effective_duration,
|
||||
),
|
||||
clip_content,
|
||||
// Max rightward extension as content seconds (the gap's wall-clock span).
|
||||
let max_extend_secs = document.find_max_trim_extend_right(
|
||||
layer_id,
|
||||
instance_id,
|
||||
instance.timeline_start,
|
||||
tempo_map,
|
||||
);
|
||||
current_effective_duration,
|
||||
).seconds_to_f64();
|
||||
|
||||
// Calculate how much we want to extend (content seconds)
|
||||
let desired_extend = new_trim_end - old_trim_end;
|
||||
let actual_extend = desired_extend.min(max_extend);
|
||||
|
||||
// Clamp to max allowed
|
||||
let actual_extend = desired_extend.min(max_extend_secs);
|
||||
let clamped_trim_end = old_trim_end + actual_extend;
|
||||
|
||||
// Don't exceed the clip's content.
|
||||
let final_trim_end = clamped_trim_end.min(content_end);
|
||||
// Don't exceed clip duration
|
||||
let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64());
|
||||
|
||||
clamped_new = TrimData::right(Some(final_trim_end));
|
||||
}
|
||||
|
|
@ -423,7 +366,7 @@ impl Action for TrimClipInstancesAction {
|
|||
|
||||
fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::clip::ResolvedContent;
|
||||
use crate::clip::AudioClipType;
|
||||
|
||||
// Get audio controller
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
|
|
@ -444,9 +387,8 @@ impl Action for TrimClipInstancesAction {
|
|||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||
// Instance already has new values after execute()
|
||||
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
|
||||
// A vector clip's content is wall-clock, so its content times ARE seconds.
|
||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
|
||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -482,29 +424,27 @@ impl Action for TrimClipInstancesAction {
|
|||
// Calculate new internal_start and internal_end for backend
|
||||
// Note: instance already has the new trim values after execute()
|
||||
let internal_start = instance.trim_start;
|
||||
let internal_end = instance
|
||||
.trim_end
|
||||
.unwrap_or(ContentTime(clip.content_duration().native()));
|
||||
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
|
||||
// Handle trim based on clip type
|
||||
match &instance.resolve(clip) {
|
||||
ResolvedContent::Midi { midi_clip_id } => {
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
// For MIDI: trim_clip expects the pool clip ID
|
||||
controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end));
|
||||
controller.trim_clip(*track_id, *midi_clip_id, internal_start, internal_end);
|
||||
}
|
||||
ResolvedContent::Audio { .. } => {
|
||||
AudioClipType::Sampled { .. } => {
|
||||
// For sampled audio: trim_clip expects the instance ID
|
||||
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
||||
|
||||
match backend_instance_id {
|
||||
crate::action::BackendClipInstanceId::Audio(audio_id) => {
|
||||
controller.trim_clip(*track_id, *audio_id, clip.trim_range(internal_start, internal_end));
|
||||
controller.trim_clip(*track_id, *audio_id, internal_start, internal_end);
|
||||
}
|
||||
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
||||
}
|
||||
}
|
||||
ResolvedContent::Recording => {
|
||||
AudioClipType::Recording => {
|
||||
// Recording clips cannot be trimmed - skip
|
||||
}
|
||||
}
|
||||
|
|
@ -516,7 +456,7 @@ impl Action for TrimClipInstancesAction {
|
|||
|
||||
fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::clip::ResolvedContent;
|
||||
use crate::clip::AudioClipType;
|
||||
|
||||
// Get audio controller
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
|
|
@ -537,9 +477,8 @@ impl Action for TrimClipInstancesAction {
|
|||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||
// Instance already has old values after rollback()
|
||||
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
|
||||
// A vector clip's content is wall-clock, so its content times ARE seconds.
|
||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
|
||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -573,35 +512,34 @@ impl Action for TrimClipInstancesAction {
|
|||
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
||||
|
||||
// Calculate old internal_start and internal_end for backend
|
||||
let content_end = ContentTime(clip.content_duration().native());
|
||||
let internal_start = match trim_type {
|
||||
TrimType::TrimLeft => old.trim_value.unwrap_or(ContentTime::ZERO),
|
||||
TrimType::TrimLeft => old.trim_value.unwrap_or(0.0),
|
||||
TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed
|
||||
};
|
||||
let internal_end = match trim_type {
|
||||
TrimType::TrimLeft => instance.trim_end.unwrap_or(content_end), // trim_end wasn't changed
|
||||
TrimType::TrimRight => old.trim_value.unwrap_or(content_end),
|
||||
TrimType::TrimLeft => instance.trim_end.unwrap_or(clip.content_duration().native()), // trim_end wasn't changed
|
||||
TrimType::TrimRight => old.trim_value.unwrap_or(clip.content_duration().native()),
|
||||
};
|
||||
|
||||
// Handle trim based on clip type
|
||||
match &instance.resolve(clip) {
|
||||
ResolvedContent::Midi { midi_clip_id } => {
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
// For MIDI: trim_clip expects the pool clip ID
|
||||
controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end));
|
||||
controller.trim_clip(*track_id, *midi_clip_id, internal_start, internal_end);
|
||||
}
|
||||
ResolvedContent::Audio { .. } => {
|
||||
AudioClipType::Sampled { .. } => {
|
||||
// For sampled audio: trim_clip expects the instance ID
|
||||
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
||||
|
||||
match backend_instance_id {
|
||||
crate::action::BackendClipInstanceId::Audio(audio_id) => {
|
||||
controller.trim_clip(*track_id, *audio_id, clip.trim_range(internal_start, internal_end));
|
||||
controller.trim_clip(*track_id, *audio_id, internal_start, internal_end);
|
||||
}
|
||||
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
||||
}
|
||||
}
|
||||
ResolvedContent::Recording => {
|
||||
AudioClipType::Recording => {
|
||||
// Recording clips cannot be trimmed - skip
|
||||
}
|
||||
}
|
||||
|
|
@ -631,7 +569,7 @@ mod tests {
|
|||
|
||||
let mut clip_instance = ClipInstance::new(clip_id);
|
||||
clip_instance.timeline_start = Beats::ZERO;
|
||||
clip_instance.trim_start = ContentTime::ZERO;
|
||||
clip_instance.trim_start = 0.0;
|
||||
let instance_id = clip_instance.id;
|
||||
vector_layer.clip_instances.push(clip_instance);
|
||||
|
||||
|
|
@ -644,8 +582,8 @@ mod tests {
|
|||
vec![(
|
||||
instance_id,
|
||||
TrimType::TrimLeft,
|
||||
TrimData::left(ContentTime::ZERO, Beats::ZERO),
|
||||
TrimData::left(ContentTime(2.0), Beats(2.0)),
|
||||
TrimData::left(0.0, Beats::ZERO),
|
||||
TrimData::left(2.0, Beats(2.0)),
|
||||
)],
|
||||
);
|
||||
|
||||
|
|
@ -661,7 +599,7 @@ mod tests {
|
|||
.iter()
|
||||
.find(|ci| ci.id == instance_id)
|
||||
.unwrap();
|
||||
assert_eq!(instance.trim_start, ContentTime(2.0));
|
||||
assert_eq!(instance.trim_start, 2.0);
|
||||
assert_eq!(instance.timeline_start, Beats(2.0));
|
||||
}
|
||||
|
||||
|
|
@ -675,7 +613,7 @@ mod tests {
|
|||
.iter()
|
||||
.find(|ci| ci.id == instance_id)
|
||||
.unwrap();
|
||||
assert_eq!(instance.trim_start, ContentTime::ZERO);
|
||||
assert_eq!(instance.trim_start, 0.0);
|
||||
assert_eq!(instance.timeline_start, Beats::ZERO);
|
||||
}
|
||||
}
|
||||
|
|
@ -706,7 +644,7 @@ mod tests {
|
|||
instance_id,
|
||||
TrimType::TrimRight,
|
||||
TrimData::right(None),
|
||||
TrimData::right(Some(ContentTime(8.0))),
|
||||
TrimData::right(Some(8.0)),
|
||||
)],
|
||||
);
|
||||
|
||||
|
|
@ -722,7 +660,7 @@ mod tests {
|
|||
.iter()
|
||||
.find(|ci| ci.id == instance_id)
|
||||
.unwrap();
|
||||
assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
|
||||
assert_eq!(instance.trim_end, Some(8.0));
|
||||
}
|
||||
|
||||
// Rollback
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
use crate::layer::AnyLayer;
|
||||
use crate::layer_tree::LayerTree;
|
||||
use crate::object::Transform;
|
||||
use daw_backend::{Beats, ContentTime, Seconds};
|
||||
use daw_backend::{Beats, Seconds};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -130,14 +130,10 @@ impl VectorClip {
|
|||
let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration {
|
||||
ci.timeline_start + td_beats
|
||||
} else if let Some(te) = ci.trim_end {
|
||||
// `clip_duration_fn` hands back seconds, so this whole path treats nested
|
||||
// content as wall-clock. That's right for the vector/video/audio clips a vector
|
||||
// clip actually nests; a nested MIDI clip (beats content) would need resolving
|
||||
// against its clip, which this callback can't do. Pre-existing limitation.
|
||||
let secs = (te - ci.trim_start).raw().max(0.0);
|
||||
let secs = (te - ci.trim_start).max(0.0);
|
||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
|
||||
} else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
|
||||
let secs = (clip_dur_secs - ci.trim_start.raw()).max(0.0);
|
||||
let secs = (clip_dur_secs - ci.trim_start).max(0.0);
|
||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
|
||||
} else {
|
||||
continue;
|
||||
|
|
@ -205,9 +201,7 @@ impl VectorClip {
|
|||
// Convert parent clip time (seconds) to nested clip local time (seconds).
|
||||
// timeline_start is in beats; convert to seconds using document BPM.
|
||||
let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||
// Nested clips here are vector clips, whose content is wall-clock seconds.
|
||||
let nested_clip_time =
|
||||
((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||
let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
// Look up the nested clip definition
|
||||
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||
|
|
@ -474,25 +468,6 @@ pub enum AudioClipType {
|
|||
Recording,
|
||||
}
|
||||
|
||||
/// One take of a cycle recording — see [`ClipInstance::takes`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AudioTake {
|
||||
/// Display name, e.g. "Take 1". User-editable.
|
||||
pub name: String,
|
||||
/// The recorded content this take points at.
|
||||
pub content: TakeContent,
|
||||
}
|
||||
|
||||
/// What a take actually holds. An instance's takes are all the same kind — one cycle-record session
|
||||
/// captures either audio or MIDI, never a mix.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TakeContent {
|
||||
/// Sampled audio: index into the audio pool.
|
||||
Audio { audio_pool_index: usize },
|
||||
/// MIDI: backend MIDI clip ID.
|
||||
Midi { midi_clip_id: u32 },
|
||||
}
|
||||
|
||||
/// A clip's content duration, tagged by its native unit.
|
||||
///
|
||||
/// Sampled/recording audio and video measure content in wall-clock **seconds**; MIDI measures
|
||||
|
|
@ -522,17 +497,6 @@ impl ClipDuration {
|
|||
ClipDuration::Beats(b) => b.beats_to_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag a [`ContentTime`] with *this* duration's domain.
|
||||
///
|
||||
/// Handy when you already hold a clip's content duration (so you know the domain) and need to
|
||||
/// resolve one of its trim bounds, without going back to the clip.
|
||||
pub fn same_domain(self, t: ContentTime) -> ClipDuration {
|
||||
match self {
|
||||
ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(t.raw())),
|
||||
ClipDuration::Beats(_) => ClipDuration::Beats(Beats(t.raw())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio clip
|
||||
|
|
@ -568,10 +532,11 @@ impl AudioClip {
|
|||
/// The clip's content duration, tagged with its native domain (seconds for sampled/recording,
|
||||
/// beats for MIDI). This is the only sanctioned way to read the raw `duration` field.
|
||||
pub fn content_duration(&self) -> ClipDuration {
|
||||
if self.is_midi_domain() {
|
||||
ClipDuration::Beats(Beats(self.duration))
|
||||
} else {
|
||||
ClipDuration::Seconds(Seconds(self.duration))
|
||||
match self.clip_type {
|
||||
AudioClipType::Midi { .. } => ClipDuration::Beats(Beats(self.duration)),
|
||||
AudioClipType::Sampled { .. } | AudioClipType::Recording => {
|
||||
ClipDuration::Seconds(Seconds(self.duration))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -580,19 +545,15 @@ impl AudioClip {
|
|||
pub fn set_content_duration(&mut self, duration: ClipDuration) {
|
||||
debug_assert!(
|
||||
matches!(
|
||||
(self.is_midi_domain(), duration),
|
||||
(true, ClipDuration::Beats(_)) | (false, ClipDuration::Seconds(_))
|
||||
(&self.clip_type, duration),
|
||||
(AudioClipType::Midi { .. }, ClipDuration::Beats(_))
|
||||
| (AudioClipType::Sampled { .. } | AudioClipType::Recording, ClipDuration::Seconds(_))
|
||||
),
|
||||
"clip duration domain must match clip type",
|
||||
);
|
||||
self.duration = duration.native();
|
||||
}
|
||||
|
||||
/// Whether this clip's `duration` is measured in beats (MIDI) rather than seconds.
|
||||
fn is_midi_domain(&self) -> bool {
|
||||
matches!(self.clip_type, AudioClipType::Midi { .. })
|
||||
}
|
||||
|
||||
/// Create a new sampled audio clip
|
||||
///
|
||||
/// # Arguments
|
||||
|
|
@ -676,73 +637,6 @@ impl AudioClip {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The clip's own content, ignoring takes.
|
||||
///
|
||||
/// Callers that are handing content to the backend want [`ClipInstance::resolve`] instead — an
|
||||
/// instance with takes overrides the clip's content with whichever take is active.
|
||||
pub fn resolve(&self) -> ResolvedContent {
|
||||
match &self.clip_type {
|
||||
AudioClipType::Sampled { audio_pool_index } => ResolvedContent::Audio {
|
||||
audio_pool_index: *audio_pool_index,
|
||||
},
|
||||
AudioClipType::Midi { midi_clip_id } => ResolvedContent::Midi {
|
||||
midi_clip_id: *midi_clip_id,
|
||||
},
|
||||
AudioClipType::Recording => ResolvedContent::Recording,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a content time against this clip's domain.
|
||||
///
|
||||
/// This is the ONLY sanctioned way to turn a [`ContentTime`] into a real duration — the type has
|
||||
/// no `.to_seconds()` of its own precisely so that the clip, which is the one thing that knows
|
||||
/// whether its content is measured in seconds or beats, has to be consulted.
|
||||
pub fn resolve_content_time(&self, t: ContentTime) -> ClipDuration {
|
||||
if self.is_midi_domain() {
|
||||
ClipDuration::Beats(Beats(t.raw()))
|
||||
} else {
|
||||
ClipDuration::Seconds(Seconds(t.raw()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag a pair of trim bounds with this clip's content domain, ready for the backend.
|
||||
///
|
||||
/// Building the [`TrimRange`] from the clip means a caller can't reach for the wrong variant:
|
||||
/// the clip is the one thing that knows the domain.
|
||||
pub fn trim_range(&self, start: ContentTime, end: ContentTime) -> daw_backend::command::TrimRange {
|
||||
if self.is_midi_domain() {
|
||||
daw_backend::command::TrimRange::Beats {
|
||||
start: Beats(start.raw()),
|
||||
end: Beats(end.raw()),
|
||||
}
|
||||
} else {
|
||||
daw_backend::command::TrimRange::Seconds {
|
||||
start: Seconds(start.raw()),
|
||||
end: Seconds(end.raw()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this clip's own content is the given audio pool index.
|
||||
pub fn owns_audio_pool_index(&self, pool_index: usize) -> bool {
|
||||
self.audio_pool_index() == Some(pool_index)
|
||||
}
|
||||
|
||||
/// Whether this clip's own content is the given backend MIDI clip ID.
|
||||
pub fn owns_midi_clip_id(&self, id: u32) -> bool {
|
||||
self.midi_clip_id() == Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a clip instance actually plays, once takes are resolved to the active one.
|
||||
/// Produced by [`ClipInstance::resolve`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ResolvedContent {
|
||||
Audio { audio_pool_index: usize },
|
||||
Midi { midi_clip_id: u32 },
|
||||
/// A recording in progress (or an empty take folder) — no backend content yet.
|
||||
Recording,
|
||||
}
|
||||
|
||||
/// Unified clip enum for polymorphic handling
|
||||
|
|
@ -821,17 +715,16 @@ pub struct ClipInstance {
|
|||
/// Default: None (use trimmed clip duration, no looping)
|
||||
pub timeline_duration: Option<Beats>,
|
||||
|
||||
/// Trim start: offset into the clip's internal content.
|
||||
///
|
||||
/// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled
|
||||
/// audio/video/vector but BEATS for MIDI. Resolve it against the clip
|
||||
/// ([`Document::resolve_content_time`]) before combining it with anything on the timeline.
|
||||
/// Trim start: offset into the clip's internal content, in **seconds**.
|
||||
/// - For audio: byte-offset into the audio file
|
||||
/// - For video: seek position in the video file
|
||||
/// - For vector: time offset into the animation
|
||||
/// Default: 0.0
|
||||
pub trim_start: ContentTime,
|
||||
pub trim_start: f64,
|
||||
|
||||
/// Trim end: offset into the clip's internal content. See [`Self::trim_start`].
|
||||
/// Trim end: offset into the clip's internal content, in **seconds**.
|
||||
/// Default: None (use full clip duration)
|
||||
pub trim_end: Option<ContentTime>,
|
||||
pub trim_end: Option<f64>,
|
||||
|
||||
/// Playback speed multiplier
|
||||
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
|
||||
|
|
@ -848,35 +741,6 @@ pub struct ClipInstance {
|
|||
/// Default: None (no pre-loop)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub loop_before: Option<Beats>,
|
||||
|
||||
/// Alternate takes from cycle recording. Empty = an ordinary instance with no takes.
|
||||
///
|
||||
/// The takes live on the INSTANCE, not the clip, so managing them is per-instance: deleting or
|
||||
/// renaming a take on one instance leaves every other instance alone. Splitting clones the list
|
||||
/// along with the rest of the instance, so the two halves get independent take lists — and,
|
||||
/// since they can each select a different take, comping still falls out for free.
|
||||
///
|
||||
/// Every take spans the full cycle region (partial passes are padded with silence at capture
|
||||
/// time), so they're all the same length as the clip's own content. That uniformity is what lets
|
||||
/// a take switch leave the instance's geometry untouched.
|
||||
///
|
||||
/// When non-empty, these OVERRIDE the clip's own content — see [`Self::resolve`].
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub takes: Vec<AudioTake>,
|
||||
|
||||
/// Which of [`Self::takes`] plays. `None` (or a stale index) means take 0.
|
||||
/// Default: None
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_take: Option<usize>,
|
||||
|
||||
/// The cycle region's length in beats when these takes were recorded.
|
||||
///
|
||||
/// Audio takes are cut geometrically (by sample count), so they're only meaningful against the
|
||||
/// tempo they were recorded at. Keeping the recorded length lets a future time-stretch/conform
|
||||
/// feature reconcile them if the tempo moves underneath, and lets a new recording tell whether
|
||||
/// it belongs in this take list or a fresh one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recorded_loop_beats: Option<Beats>,
|
||||
}
|
||||
|
||||
/// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID.
|
||||
|
|
@ -930,14 +794,11 @@ impl ClipInstance {
|
|||
name: None,
|
||||
timeline_start: Beats::ZERO,
|
||||
timeline_duration: None,
|
||||
trim_start: ContentTime::ZERO,
|
||||
trim_start: 0.0,
|
||||
trim_end: None,
|
||||
playback_speed: 1.0,
|
||||
gain: 1.0,
|
||||
loop_before: None,
|
||||
takes: Vec::new(),
|
||||
active_take: None,
|
||||
recorded_loop_beats: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -951,14 +812,11 @@ impl ClipInstance {
|
|||
name: None,
|
||||
timeline_start: Beats::ZERO,
|
||||
timeline_duration: None,
|
||||
trim_start: ContentTime::ZERO,
|
||||
trim_start: 0.0,
|
||||
trim_end: None,
|
||||
playback_speed: 1.0,
|
||||
gain: 1.0,
|
||||
loop_before: None,
|
||||
takes: Vec::new(),
|
||||
active_take: None,
|
||||
recorded_loop_beats: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -994,7 +852,7 @@ impl ClipInstance {
|
|||
}
|
||||
|
||||
/// Set trimming (start and end time within the clip's internal content)
|
||||
pub fn with_trimming(mut self, trim_start: ContentTime, trim_end: Option<ContentTime>) -> Self {
|
||||
pub fn with_trimming(mut self, trim_start: f64, trim_end: Option<f64>) -> Self {
|
||||
self.trim_start = trim_start;
|
||||
self.trim_end = trim_end;
|
||||
self
|
||||
|
|
@ -1018,89 +876,24 @@ impl ClipInstance {
|
|||
self
|
||||
}
|
||||
|
||||
/// The take this instance plays, if it has any.
|
||||
///
|
||||
/// A `None`/stale `active_take` falls back to take 0 — an index can go stale (an undo that
|
||||
/// shrank the list, an old `.beam`), and silently playing the first take beats playing nothing.
|
||||
pub fn active_take(&self) -> Option<&AudioTake> {
|
||||
self.takes
|
||||
.get(self.active_take.unwrap_or(0))
|
||||
.or_else(|| self.takes.first())
|
||||
}
|
||||
|
||||
/// The index [`Self::active_take`] actually resolves to, clamped into range.
|
||||
pub fn active_take_index(&self) -> usize {
|
||||
let i = self.active_take.unwrap_or(0);
|
||||
if i < self.takes.len() { i } else { 0 }
|
||||
}
|
||||
|
||||
/// What this instance plays: its active take if it has takes, otherwise the clip's own content.
|
||||
///
|
||||
/// This is the sanctioned way to ask "what content do I hand the backend for this instance?".
|
||||
/// Takes are never a distinct *case* at the call site — an instance with takes is just an audio
|
||||
/// or MIDI instance whose identity depends on which take is live.
|
||||
pub fn resolve(&self, clip: &AudioClip) -> ResolvedContent {
|
||||
match self.active_take().map(|t| &t.content) {
|
||||
Some(TakeContent::Audio { audio_pool_index }) => ResolvedContent::Audio {
|
||||
audio_pool_index: *audio_pool_index,
|
||||
},
|
||||
Some(TakeContent::Midi { midi_clip_id }) => ResolvedContent::Midi {
|
||||
midi_clip_id: *midi_clip_id,
|
||||
},
|
||||
None => clip.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio pool index this instance plays. See [`Self::resolve`].
|
||||
pub fn resolved_audio_pool_index(&self, clip: &AudioClip) -> Option<usize> {
|
||||
match self.resolve(clip) {
|
||||
ResolvedContent::Audio { audio_pool_index } => Some(audio_pool_index),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend MIDI clip ID this instance plays. See [`Self::resolve`].
|
||||
pub fn resolved_midi_clip_id(&self, clip: &AudioClip) -> Option<u32> {
|
||||
match self.resolve(clip) {
|
||||
ResolvedContent::Midi { midi_clip_id } => Some(midi_clip_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Content window (`trim_end - trim_start`) in the clip's own content domain.
|
||||
/// Content window size in seconds: `trim_end - trim_start`.
|
||||
/// Used for internal looping calculations.
|
||||
pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration {
|
||||
let end = self.trim_end.map_or(clip_content.native(), |t| t.raw());
|
||||
let window = (end - self.trim_start.raw()).max(0.0);
|
||||
match clip_content {
|
||||
ClipDuration::Beats(_) => ClipDuration::Beats(Beats(window)),
|
||||
ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(window)),
|
||||
}
|
||||
pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds {
|
||||
let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64());
|
||||
Seconds((end - self.trim_start).max(0.0))
|
||||
}
|
||||
|
||||
/// How long this instance appears on the timeline, in **beats**.
|
||||
///
|
||||
/// If `timeline_duration` is set, returns that (enabling content looping). Otherwise the clip
|
||||
/// occupies its content window — converted to beats *in the clip's own domain*:
|
||||
///
|
||||
/// - MIDI content is already beats and is tempo-invariant, so it carries over directly.
|
||||
/// - Wall-clock content (audio/video/vector) is a seconds span, so it converts at the clip's
|
||||
/// position on the timeline.
|
||||
///
|
||||
/// Taking a `ClipDuration` rather than a bare `Seconds` is what keeps those apart: this used to
|
||||
/// take seconds and subtract `trim_start` from it, which for a TRIMMED MIDI clip subtracted a
|
||||
/// beats offset from a seconds duration and got the clip's length wrong.
|
||||
pub fn effective_duration_beats(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||
/// If `timeline_duration` is set, returns that (enabling content looping).
|
||||
/// Otherwise converts the content window from seconds to beats using the tempo map.
|
||||
pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||
if let Some(td) = self.timeline_duration {
|
||||
return td;
|
||||
}
|
||||
match self.content_window(clip_content) {
|
||||
ClipDuration::Beats(b) => b,
|
||||
ClipDuration::Seconds(s) => {
|
||||
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
||||
tempo_map.seconds_to_beats(start_secs + s) - self.timeline_start
|
||||
}
|
||||
}
|
||||
let window = self.content_window_secs(clip_duration_secs);
|
||||
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
||||
tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start
|
||||
}
|
||||
|
||||
/// Left edge of the clip's visual extent on the timeline, in **beats**.
|
||||
|
|
@ -1109,32 +902,27 @@ impl ClipInstance {
|
|||
}
|
||||
|
||||
/// Total visual duration (loop_before + effective_duration), in **beats**.
|
||||
pub fn total_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||
self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_content, tempo_map)
|
||||
pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||
self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map)
|
||||
}
|
||||
|
||||
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
|
||||
///
|
||||
/// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats
|
||||
/// trims are converted rather than read as seconds. Callers are the wall-clock consumers (video
|
||||
/// seek, vector/raster rendering), which want seconds regardless of how the clip stores content.
|
||||
///
|
||||
/// Returns `None` if the clip instance is not active at `time`.
|
||||
pub fn remap_time_secs(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||
/// Returns `None` if the clip instance is not active at `time_secs`.
|
||||
pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
||||
let dur_beats = self.effective_duration_beats(clip_content, tempo_map);
|
||||
let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map);
|
||||
let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats);
|
||||
|
||||
if time < start_secs || time >= end_secs {
|
||||
return None;
|
||||
}
|
||||
|
||||
let trim_start_secs = clip_content.same_domain(self.trim_start).to_seconds(tempo_map);
|
||||
let content_time = (time - start_secs) * self.playback_speed;
|
||||
let content_window = self.content_window(clip_content).to_seconds(tempo_map);
|
||||
let content_window = self.content_window_secs(clip_duration_secs);
|
||||
|
||||
if content_window == Seconds::ZERO {
|
||||
return Some(trim_start_secs);
|
||||
return Some(Seconds(self.trim_start));
|
||||
}
|
||||
|
||||
let looped = if content_time > content_window {
|
||||
|
|
@ -1143,19 +931,19 @@ impl ClipInstance {
|
|||
content_time
|
||||
};
|
||||
|
||||
Some(trim_start_secs + looped)
|
||||
Some(Seconds(self.trim_start) + looped)
|
||||
}
|
||||
|
||||
/// Alias for `remap_time_secs`.
|
||||
#[inline]
|
||||
pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||
self.remap_time_secs(time, clip_content, tempo_map)
|
||||
pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||
self.remap_time_secs(time, clip_duration_secs, tempo_map)
|
||||
}
|
||||
|
||||
/// Alias for `effective_duration_beats`.
|
||||
#[inline]
|
||||
pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||
self.effective_duration_beats(clip_content, tempo_map)
|
||||
pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||
self.effective_duration_beats(clip_duration_secs, tempo_map)
|
||||
}
|
||||
|
||||
/// Convert to affine transform
|
||||
|
|
@ -1232,7 +1020,7 @@ mod tests {
|
|||
assert_eq!(instance.clip_id, clip_id);
|
||||
assert_eq!(instance.opacity, 1.0);
|
||||
assert_eq!(instance.timeline_start, Beats::ZERO);
|
||||
assert_eq!(instance.trim_start, ContentTime::ZERO);
|
||||
assert_eq!(instance.trim_start, 0.0);
|
||||
assert_eq!(instance.trim_end, None);
|
||||
assert_eq!(instance.playback_speed, 1.0);
|
||||
assert_eq!(instance.gain, 1.0);
|
||||
|
|
@ -1242,52 +1030,27 @@ mod tests {
|
|||
fn test_clip_instance_trimming() {
|
||||
let clip_id = Uuid::new_v4();
|
||||
let instance = ClipInstance::new(clip_id)
|
||||
.with_trimming(ContentTime(2.0), Some(ContentTime(8.0)));
|
||||
.with_trimming(2.0, Some(8.0));
|
||||
|
||||
assert_eq!(instance.trim_start, ContentTime(2.0));
|
||||
assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
|
||||
assert_eq!(instance.trim_start, 2.0);
|
||||
assert_eq!(instance.trim_end, Some(8.0));
|
||||
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the
|
||||
// beats-domain effective duration equals the seconds content window.
|
||||
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
||||
let content = ClipDuration::Seconds(Seconds(10.0));
|
||||
assert_eq!(instance.effective_duration(content, &tempo_map), Beats(6.0));
|
||||
assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clip_instance_no_end_trim() {
|
||||
let clip_id = Uuid::new_v4();
|
||||
let instance = ClipInstance::new(clip_id)
|
||||
.with_trimming(ContentTime(2.0), None);
|
||||
.with_trimming(2.0, None);
|
||||
|
||||
assert_eq!(instance.trim_start, ContentTime(2.0));
|
||||
assert_eq!(instance.trim_start, 2.0);
|
||||
assert_eq!(instance.trim_end, None);
|
||||
// At 60 BPM the tempo map is identity (1 beat == 1 second).
|
||||
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
||||
let content = ClipDuration::Seconds(Seconds(10.0));
|
||||
assert_eq!(instance.effective_duration(content, &tempo_map), Beats(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimmed_midi_clip_keeps_its_beats_length_across_tempo() {
|
||||
// Regression: `effective_duration_beats` used to take a SECONDS clip duration and subtract
|
||||
// `trim_start` from it. For a TRIMMED MIDI clip that subtracted a beats offset from a
|
||||
// seconds duration, so the clip's timeline length came out wrong at any tempo but 60 BPM.
|
||||
//
|
||||
// MIDI content is beats and tempo-invariant: a clip trimmed to beats 2..6 is 4 beats long
|
||||
// whatever the tempo says.
|
||||
let clip_id = Uuid::new_v4();
|
||||
let instance = ClipInstance::new(clip_id)
|
||||
.with_trimming(ContentTime(2.0), Some(ContentTime(6.0)));
|
||||
let content = ClipDuration::Beats(Beats(8.0));
|
||||
|
||||
for bpm in [60.0, 120.0, 90.0] {
|
||||
let tempo_map = crate::tempo_map::TempoMap::constant(bpm);
|
||||
assert_eq!(
|
||||
instance.effective_duration(content, &tempo_map),
|
||||
Beats(4.0),
|
||||
"a MIDI clip trimmed to beats 2..6 is 4 beats long at {bpm} BPM",
|
||||
);
|
||||
}
|
||||
assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1307,109 +1070,4 @@ mod tests {
|
|||
assert_eq!(instance.playback_speed, 2.0);
|
||||
assert_eq!(instance.gain, 0.8);
|
||||
}
|
||||
|
||||
/// A clip whose own content is pool 10, plus an instance carrying takes over the given pools.
|
||||
fn with_takes(pool_indices: &[usize]) -> (AudioClip, ClipInstance) {
|
||||
let clip = AudioClip::new_sampled("Cycle rec", pool_indices[0], 2.0);
|
||||
let mut instance = ClipInstance::new(clip.id);
|
||||
instance.takes = pool_indices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &audio_pool_index)| AudioTake {
|
||||
name: format!("Take {}", i + 1),
|
||||
content: TakeContent::Audio { audio_pool_index },
|
||||
})
|
||||
.collect();
|
||||
instance.recorded_loop_beats = Some(Beats(8.0));
|
||||
(clip, instance)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_take_selects_the_pool_file() {
|
||||
let (clip, mut instance) = with_takes(&[10, 11, 12]);
|
||||
instance.active_take = Some(0);
|
||||
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10));
|
||||
instance.active_take = Some(2);
|
||||
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(12));
|
||||
// None means take 0.
|
||||
instance.active_take = None;
|
||||
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_take_falls_back_to_the_first() {
|
||||
// An index can go stale (an undo that shrank the list, an old .beam). Playing the first take
|
||||
// beats playing nothing.
|
||||
let (clip, mut instance) = with_takes(&[10, 11]);
|
||||
instance.active_take = Some(99);
|
||||
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10));
|
||||
assert_eq!(instance.active_take_index(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_without_takes_plays_the_clips_own_content() {
|
||||
let clip = AudioClip::new_sampled("Plain", 42, 2.0);
|
||||
let instance = ClipInstance::new(clip.id);
|
||||
assert!(instance.takes.is_empty());
|
||||
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midi_takes_resolve_to_midi_content() {
|
||||
let clip = AudioClip::new_midi("Cycle rec", 7, Beats(4.0));
|
||||
let mut instance = ClipInstance::new(clip.id);
|
||||
instance.takes = vec![AudioTake {
|
||||
name: "Take 1".into(),
|
||||
content: TakeContent::Midi { midi_clip_id: 7 },
|
||||
}];
|
||||
assert_eq!(clip.content_duration(), ClipDuration::Beats(Beats(4.0)));
|
||||
assert_eq!(instance.resolved_midi_clip_id(&clip), Some(7));
|
||||
assert_eq!(instance.resolved_audio_pool_index(&clip), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_gives_each_half_an_independent_take_list() {
|
||||
// Takes live on the INSTANCE, so a split (which clones the instance) hands each half its own
|
||||
// list. Two consequences, both wanted: the halves can select different takes (comping), and
|
||||
// deleting a take from one leaves the other alone.
|
||||
let (clip, left_src) = with_takes(&[10, 11, 12]);
|
||||
let mut left = left_src.clone();
|
||||
let mut right = left_src.clone();
|
||||
right.id = Uuid::new_v4();
|
||||
|
||||
left.active_take = Some(0);
|
||||
right.active_take = Some(2);
|
||||
assert_eq!(left.resolved_audio_pool_index(&clip), Some(10));
|
||||
assert_eq!(right.resolved_audio_pool_index(&clip), Some(12));
|
||||
|
||||
// Delete take 2 (pool 11) from the left half only.
|
||||
left.takes.remove(1);
|
||||
assert_eq!(left.takes.len(), 2);
|
||||
assert_eq!(right.takes.len(), 3, "the other half keeps its own takes");
|
||||
assert_eq!(
|
||||
right.resolved_audio_pool_index(&clip),
|
||||
Some(12),
|
||||
"and its selection still points where it did",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_instance_without_active_take_deserializes() {
|
||||
// Back-compat: .beam files written before take folders have no `active_take` field.
|
||||
let json = r#"{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"clip_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"transform": {"x": 0.0, "y": 0.0, "rotation": 0.0, "scale_x": 1.0, "scale_y": 1.0, "skew_x": 0.0, "skew_y": 0.0},
|
||||
"opacity": 1.0,
|
||||
"name": null,
|
||||
"timeline_start": 0.0,
|
||||
"timeline_duration": null,
|
||||
"trim_start": 0.0,
|
||||
"trim_end": null,
|
||||
"playback_speed": 1.0,
|
||||
"gain": 1.0
|
||||
}"#;
|
||||
let instance: ClipInstance = serde_json::from_str(json).expect("old instances must load");
|
||||
assert_eq!(instance.active_take, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,17 +201,6 @@ pub struct Document {
|
|||
#[serde(default)]
|
||||
pub time_signature: TimeSignature,
|
||||
|
||||
/// Transport cycle (loop) region, as `(start, end)` in **beats**.
|
||||
///
|
||||
/// Authored in beats so it stays put musically when the tempo changes. `None` = no region set.
|
||||
/// Saved with the project; `#[serde(default)]` keeps older `.beam` files loading.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cycle_region: Option<(Beats, Beats)>,
|
||||
|
||||
/// Whether the transport loops over `cycle_region`.
|
||||
#[serde(default)]
|
||||
pub cycle_enabled: bool,
|
||||
|
||||
/// 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.
|
||||
|
|
@ -308,8 +297,6 @@ impl Default for Document {
|
|||
height: 1080.0,
|
||||
framerate: 60.0,
|
||||
time_signature: TimeSignature::default(),
|
||||
cycle_region: None,
|
||||
cycle_enabled: false,
|
||||
master_layer: {
|
||||
let mut ml = GroupLayer::new_master(120.0);
|
||||
ml.layer.id = uuid::Uuid::new_v4();
|
||||
|
|
@ -464,11 +451,8 @@ impl Document {
|
|||
let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration {
|
||||
instance.timeline_start + timeline_duration
|
||||
} else {
|
||||
// `clip_duration` arrives as seconds (the recursive helper's signature), so this
|
||||
// path is the wall-clock one; MIDI content would need resolving against its clip.
|
||||
let trim_end = instance.trim_end.map_or(clip_duration, |t| t.raw());
|
||||
let trimmed_secs =
|
||||
((trim_end - instance.trim_start.raw()) / instance.playback_speed).max(0.0);
|
||||
let trim_end = instance.trim_end.unwrap_or(clip_duration);
|
||||
let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0);
|
||||
let start_secs = tempo_map.beats_to_seconds(instance.timeline_start);
|
||||
tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs))
|
||||
};
|
||||
|
|
@ -783,18 +767,16 @@ impl Document {
|
|||
}
|
||||
|
||||
/// Find the document audio clip (UUID + ref) that owns the given backend pool index.
|
||||
/// A take folder owns one pool file per take, so any of them maps back to the folder.
|
||||
pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> {
|
||||
self.audio_clips.iter()
|
||||
.find(|(_, c)| c.owns_audio_pool_index(pool_index))
|
||||
.find(|(_, c)| c.audio_pool_index() == Some(pool_index))
|
||||
.map(|(&id, c)| (id, c))
|
||||
}
|
||||
|
||||
/// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID.
|
||||
/// As above, a take folder owns one MIDI clip per take.
|
||||
pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> {
|
||||
self.audio_clips.iter()
|
||||
.find(|(_, c)| c.owns_midi_clip_id(midi_clip_id))
|
||||
.find(|(_, c)| c.midi_clip_id() == Some(midi_clip_id))
|
||||
.map(|(&id, c)| (id, c))
|
||||
}
|
||||
|
||||
|
|
@ -916,81 +898,6 @@ impl Document {
|
|||
/// Searches through all clip libraries to find the clip and return its duration.
|
||||
/// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects
|
||||
/// have infinite internal duration.
|
||||
/// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**.
|
||||
///
|
||||
/// Content time is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for sampled
|
||||
/// audio, video and vector, but BEATS for MIDI. Anything doing arithmetic against a trim value —
|
||||
/// mapping a timeline position into the clip's content, say — has to work in that same domain,
|
||||
/// and [`Self::get_clip_duration`] can't tell it which: that one always converts to seconds.
|
||||
///
|
||||
/// Returns `None` for unknown clips.
|
||||
pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option<crate::clip::ClipDuration> {
|
||||
if let Some(clip) = self.audio_clips.get(clip_id) {
|
||||
return Some(clip.content_duration());
|
||||
}
|
||||
// Everything else measures its content in wall-clock seconds.
|
||||
self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds)
|
||||
}
|
||||
|
||||
/// Find an existing take folder on `layer_id` that a new cycle recording should be appended to.
|
||||
///
|
||||
/// Cycle-recording over a region that already holds a take folder should ADD to that folder
|
||||
/// rather than drop a second clip on top of it — otherwise the new takes are stranded in a
|
||||
/// separate, overlapping clip and can't be auditioned against the ones already there.
|
||||
///
|
||||
/// "The same region" means the instance starts at `loop_start` and its folder was recorded
|
||||
/// against the same loop length. Matching the length too means resizing the cycle region starts
|
||||
/// a fresh folder rather than appending takes of a different length to an existing one, which
|
||||
/// would break the uniform-take invariant comping depends on.
|
||||
///
|
||||
/// `exclude` is the in-progress recording's own instance, which is on the layer but isn't a
|
||||
/// candidate. Returns the instance id.
|
||||
pub fn take_folder_at(
|
||||
&self,
|
||||
layer_id: &Uuid,
|
||||
loop_start: Beats,
|
||||
loop_len: Beats,
|
||||
exclude: &Uuid,
|
||||
) -> Option<Uuid> {
|
||||
let Some(AnyLayer::Audio(audio_layer)) = self.get_layer(layer_id) else {
|
||||
return None;
|
||||
};
|
||||
const EPS: f64 = 1e-6;
|
||||
audio_layer.clip_instances.iter().find_map(|ci| {
|
||||
if ci.id == *exclude
|
||||
|| ci.takes.is_empty()
|
||||
|| (ci.timeline_start - loop_start).beats_to_f64().abs() > EPS
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let recorded = ci.recorded_loop_beats?;
|
||||
((recorded - loop_len).beats_to_f64().abs() < EPS).then_some(ci.id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a [`ContentTime`] (a trim bound) against the clip it belongs to.
|
||||
///
|
||||
/// The clip is the only thing that knows whether its content is measured in seconds or beats, so
|
||||
/// this is the sanctioned exit from `ContentTime`. Works for every clip kind, not just audio.
|
||||
/// Returns `None` for unknown clips.
|
||||
pub fn resolve_content_time(
|
||||
&self,
|
||||
clip_id: &Uuid,
|
||||
t: daw_backend::ContentTime,
|
||||
) -> Option<crate::clip::ClipDuration> {
|
||||
if let Some(clip) = self.audio_clips.get(clip_id) {
|
||||
return Some(clip.resolve_content_time(t));
|
||||
}
|
||||
if self.vector_clips.contains_key(clip_id)
|
||||
|| self.video_clips.contains_key(clip_id)
|
||||
|| self.effect_definitions.contains_key(clip_id)
|
||||
{
|
||||
// Wall-clock content.
|
||||
return Some(crate::clip::ClipDuration::Seconds(Seconds(t.raw())));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
|
||||
if let Some(clip) = self.vector_clips.get(clip_id) {
|
||||
if clip.is_group {
|
||||
|
|
@ -1044,9 +951,9 @@ impl Document {
|
|||
};
|
||||
|
||||
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
|
||||
// The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
|
||||
let clip_content = self.clip_trim_duration(&instance.clip_id)?;
|
||||
Some(instance.timeline_start + instance.effective_duration_beats(clip_content, self.tempo_map()))
|
||||
let clip_duration = self.get_clip_duration(&instance.clip_id)?;
|
||||
// End position on the timeline, in beats (convert the seconds content window via tempo map).
|
||||
Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map()))
|
||||
}
|
||||
|
||||
/// Check if a time range overlaps with any existing clip on the layer
|
||||
|
|
@ -1086,14 +993,13 @@ impl Document {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Calculate instance extent (accounting for loop_before). Content duration in the clip's
|
||||
// own domain, so the trims resolve correctly for MIDI.
|
||||
let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else {
|
||||
// Calculate instance extent (accounting for loop_before)
|
||||
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let instance_start = instance.effective_start();
|
||||
let instance_end = instance.timeline_start + instance.effective_duration(clip_content, self.tempo_map());
|
||||
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map());
|
||||
|
||||
// Check overlap: start_a < end_b AND start_b < end_a
|
||||
if start_time < instance_end && instance_start < end_time {
|
||||
|
|
@ -1150,7 +1056,7 @@ impl Document {
|
|||
continue;
|
||||
}
|
||||
|
||||
if let Some(clip_dur) = self.clip_trim_duration(&instance.clip_id) {
|
||||
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
|
||||
let inst_start = instance.effective_start();
|
||||
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
|
||||
occupied_ranges.push((inst_start, inst_end, instance.id));
|
||||
|
|
@ -1246,7 +1152,7 @@ impl Document {
|
|||
if group_ids.contains(&inst.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(dur) = self.clip_trim_duration(&inst.clip_id) {
|
||||
if let Some(dur) = self.get_clip_duration(&inst.clip_id) {
|
||||
let start = inst.effective_start();
|
||||
let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
|
||||
non_group.push((start, end));
|
||||
|
|
@ -1320,8 +1226,9 @@ impl Document {
|
|||
}
|
||||
|
||||
// Calculate other clip's extent (accounting for loop_before)
|
||||
if let Some(clip_duration) = self.clip_trim_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, self.tempo_map());
|
||||
// (clip_duration is Seconds via get_clip_duration; effective_duration converts.)
|
||||
|
||||
// If this clip is to the left and closer than current nearest
|
||||
if other_end <= current_timeline_start && other_end > nearest_end {
|
||||
|
|
@ -1419,7 +1326,7 @@ impl Document {
|
|||
continue;
|
||||
}
|
||||
|
||||
if let Some(clip_duration) = self.clip_trim_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, self.tempo_map());
|
||||
|
||||
if other_end <= current_effective_start && other_end > nearest_end {
|
||||
|
|
|
|||
|
|
@ -152,12 +152,7 @@ impl EffectLayer {
|
|||
self.clip_instances
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
// Effects have an "infinite" wall-clock content length.
|
||||
let end = e.timeline_start
|
||||
+ e.effective_duration(
|
||||
crate::clip::ClipDuration::Seconds(daw_backend::Seconds(EFFECT_DURATION)),
|
||||
tempo_map,
|
||||
);
|
||||
let end = e.timeline_start + e.effective_duration(daw_backend::Seconds(EFFECT_DURATION), tempo_map);
|
||||
time_beats >= e.timeline_start && time_beats < end
|
||||
})
|
||||
.collect()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! Provides functions for testing if points or rectangles intersect with
|
||||
//! vector graph elements and clip instances, taking into account transform hierarchies.
|
||||
|
||||
use crate::clip::{ClipDuration, ClipInstance};
|
||||
use crate::clip::ClipInstance;
|
||||
use crate::vector_graph::{VertexId, EdgeId, FillId};
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::shape::Shape;
|
||||
|
|
@ -260,10 +260,7 @@ pub fn hit_test_clip_instances(
|
|||
for clip_instance in clip_instances.iter().rev() {
|
||||
// Check time bounds: skip clip instances not active at this time
|
||||
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||
// Hit-testing runs on vector/raster content, which is wall-clock seconds.
|
||||
let clip_duration = ClipDuration::Seconds(
|
||||
document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO),
|
||||
);
|
||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO);
|
||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
||||
let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
|
||||
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
||||
|
|
@ -272,8 +269,7 @@ pub fn hit_test_clip_instances(
|
|||
|
||||
// clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds)
|
||||
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||
let clip_time =
|
||||
((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||
vector_clip.calculate_content_bounds(document, clip_time)
|
||||
|
|
@ -308,10 +304,7 @@ pub fn hit_test_clip_instances_in_rect(
|
|||
for clip_instance in clip_instances {
|
||||
// Check time bounds: skip clip instances not active at this time
|
||||
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||
// Hit-testing runs on vector/raster content, which is wall-clock seconds.
|
||||
let clip_duration = ClipDuration::Seconds(
|
||||
document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO),
|
||||
);
|
||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO);
|
||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
||||
let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
|
||||
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
||||
|
|
@ -319,8 +312,7 @@ pub fn hit_test_clip_instances_in_rect(
|
|||
}
|
||||
|
||||
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||
let clip_time =
|
||||
((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||
vector_clip.calculate_content_bounds(document, clip_time)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
//! The compositing mode enables proper per-layer opacity, blend modes, and effects.
|
||||
|
||||
use crate::animation::TransformProperty;
|
||||
use crate::clip::{ClipDuration, ClipInstance, ImageAsset};
|
||||
use crate::clip::{ClipInstance, ImageAsset};
|
||||
use crate::document::Document;
|
||||
use daw_backend::Seconds;
|
||||
use crate::gpu::BlendMode;
|
||||
|
|
@ -568,7 +568,7 @@ pub fn render_layer_isolated(
|
|||
let tempo_map = document.tempo_map();
|
||||
for clip_instance in &video_layer.clip_instances {
|
||||
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
||||
let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else { continue };
|
||||
let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { continue };
|
||||
let clip_time = clip_time.seconds_to_f64();
|
||||
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue };
|
||||
|
||||
|
|
@ -1019,10 +1019,7 @@ fn render_clip_instance(
|
|||
}
|
||||
0.0
|
||||
} else {
|
||||
// A vector clip's content is wall-clock seconds.
|
||||
let clip_dur = ClipDuration::Seconds(
|
||||
document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)),
|
||||
);
|
||||
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration));
|
||||
let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else {
|
||||
return; // Clip instance not active at this time
|
||||
};
|
||||
|
|
@ -1177,7 +1174,7 @@ fn render_video_layer(
|
|||
|
||||
// Remap timeline time to clip's internal time
|
||||
let tempo_map = document.tempo_map();
|
||||
let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else {
|
||||
let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else {
|
||||
continue; // Clip instance not active at this time
|
||||
};
|
||||
let clip_time = clip_time.seconds_to_f64();
|
||||
|
|
@ -1913,10 +1910,7 @@ fn render_clip_instance_cpu(
|
|||
if time < start_secs || time >= end { return; }
|
||||
0.0
|
||||
} else {
|
||||
// A vector clip's content is wall-clock seconds.
|
||||
let clip_dur = ClipDuration::Seconds(
|
||||
document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)),
|
||||
);
|
||||
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration));
|
||||
let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return };
|
||||
t.seconds_to_f64()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,18 +35,6 @@ pub struct AppConfig {
|
|||
#[serde(default = "defaults::audio_buffer_size")]
|
||||
pub audio_buffer_size: u32,
|
||||
|
||||
/// How a cycle MIDI recording treats its passes.
|
||||
///
|
||||
/// `false` (default) = **merge**: every pass overdubs into one clip, and earlier passes play back
|
||||
/// as you record so you can layer against them. `true` = **separate takes**: each pass becomes
|
||||
/// its own take in a take folder, exactly as audio always does, and earlier passes stay silent
|
||||
/// (they're alternatives, not layers).
|
||||
///
|
||||
/// Only applies when the transport actually wraps; a recording that stops inside the first pass
|
||||
/// is an ordinary single recording either way.
|
||||
#[serde(default = "defaults::cycle_midi_separate_takes")]
|
||||
pub cycle_midi_separate_takes: bool,
|
||||
|
||||
/// Reopen last session on startup
|
||||
#[serde(default = "defaults::reopen_last_session")]
|
||||
pub reopen_last_session: bool,
|
||||
|
|
@ -102,7 +90,6 @@ impl Default for AppConfig {
|
|||
file_height: defaults::file_height(),
|
||||
scroll_speed: defaults::scroll_speed(),
|
||||
audio_buffer_size: defaults::audio_buffer_size(),
|
||||
cycle_midi_separate_takes: defaults::cycle_midi_separate_takes(),
|
||||
reopen_last_session: defaults::reopen_last_session(),
|
||||
restore_layout_from_file: defaults::restore_layout_from_file(),
|
||||
debug: defaults::debug(),
|
||||
|
|
@ -309,7 +296,6 @@ mod defaults {
|
|||
pub fn file_height() -> u32 { 600 }
|
||||
pub fn scroll_speed() -> f64 { 1.0 }
|
||||
pub fn audio_buffer_size() -> u32 { 256 }
|
||||
pub fn cycle_midi_separate_takes() -> bool { false }
|
||||
pub fn reopen_last_session() -> bool { false }
|
||||
pub fn restore_layout_from_file() -> bool { true }
|
||||
pub fn debug() -> bool { false }
|
||||
|
|
|
|||
|
|
@ -1064,12 +1064,7 @@ fn composite_document_to_hdr(
|
|||
}
|
||||
let tempo_map = document.tempo_map();
|
||||
let effect_end_beats = effect_instance.timeline_start
|
||||
+ effect_instance.effective_duration(
|
||||
lightningbeam_core::clip::ClipDuration::Seconds(daw_backend::Seconds(
|
||||
lightningbeam_core::effect::EFFECT_DURATION,
|
||||
)),
|
||||
tempo_map,
|
||||
);
|
||||
+ effect_instance.effective_duration(daw_backend::Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map);
|
||||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||
effect_def,
|
||||
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
|
||||
|
|
|
|||
|
|
@ -1105,23 +1105,6 @@ impl EditingContext {
|
|||
}
|
||||
}
|
||||
|
||||
/// A finished cycle recording whose takes belong to a take folder that already exists at that
|
||||
/// region, queued for [`EditorApp::append_cycle_takes`].
|
||||
struct PendingTakeAppend {
|
||||
layer_id: Uuid,
|
||||
/// The throwaway clip + instance the recording itself was captured into.
|
||||
recording_instance_id: Uuid,
|
||||
recording_clip_id: Uuid,
|
||||
/// The recording's backend clip, which has to be torn down or it keeps playing alongside the
|
||||
/// take folder we're appending to. Carried explicitly rather than looked up in
|
||||
/// `clip_instance_to_backend_map`, because on the audio path the recording instance isn't in
|
||||
/// that map yet — it's only added after promotion, which the append path skips.
|
||||
recording_backend_id: Option<lightningbeam_core::action::BackendClipInstanceId>,
|
||||
loop_start: Beats,
|
||||
loop_len: Beats,
|
||||
takes: Vec<lightningbeam_core::clip::AudioTake>,
|
||||
}
|
||||
|
||||
struct EditorApp {
|
||||
layouts: Vec<LayoutDefinition>,
|
||||
current_layout_index: usize,
|
||||
|
|
@ -1230,10 +1213,6 @@ struct EditorApp {
|
|||
metronome_enabled: bool, // Whether metronome clicks during recording
|
||||
count_in_enabled: bool, // Whether count-in fires before recording
|
||||
recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during recording
|
||||
/// Cycle takes waiting to be folded into an existing take folder. Queued from the audio-event
|
||||
/// loop (which holds a borrow on the event queue, so it can't call a `&mut self` method) and
|
||||
/// drained just after it.
|
||||
pending_take_appends: Vec<PendingTakeAppend>,
|
||||
recording_start_time: f64, // Playback time when recording started
|
||||
recording_layer_ids: Vec<Uuid>, // Layers being recorded to (for creating clips)
|
||||
// Asset drag-and-drop state
|
||||
|
|
@ -1602,7 +1581,6 @@ impl EditorApp {
|
|||
metronome_enabled: false, // Metronome off by default
|
||||
count_in_enabled: false, // Count-in off by default
|
||||
recording_clips: HashMap::new(), // No active recording clips
|
||||
pending_take_appends: Vec::new(),
|
||||
recording_start_time: 0.0, // Will be set when recording starts
|
||||
recording_layer_ids: Vec::new(), // Will be populated when recording starts
|
||||
dragging_asset: None, // No asset being dragged initially
|
||||
|
|
@ -2058,113 +2036,9 @@ impl EditorApp {
|
|||
/// 2. For MIDI: Loads the default instrument
|
||||
/// 3. Stores the bidirectional mapping
|
||||
/// 4. Syncs any existing clips on the layer
|
||||
/// Fold freshly-recorded cycle takes into an existing take folder covering the same region, if
|
||||
/// there is one. Returns true if it did.
|
||||
///
|
||||
/// Recording more takes over a region you've already recorded should extend that folder, not
|
||||
/// stack a second clip on top of it — otherwise the new takes are stranded in an overlapping
|
||||
/// clip and you can't audition them against the ones already there.
|
||||
///
|
||||
/// The in-progress recording's own clip and instance are throwaway scaffolding in this case (the
|
||||
/// takes themselves already live in the backend pools), so they're discarded. They were never
|
||||
/// committed as an action, so there's nothing on the undo stack to unwind — the single
|
||||
/// `AppendTakesAction` is the whole undoable step.
|
||||
fn append_cycle_takes(
|
||||
&mut self,
|
||||
layer_id: uuid::Uuid,
|
||||
recording_instance_id: uuid::Uuid,
|
||||
recording_clip_id: uuid::Uuid,
|
||||
recording_backend_id: Option<lightningbeam_core::action::BackendClipInstanceId>,
|
||||
loop_start: Beats,
|
||||
loop_len: Beats,
|
||||
takes: Vec<lightningbeam_core::clip::AudioTake>,
|
||||
) -> bool {
|
||||
let Some(target_instance_id) = self.action_executor.document().take_folder_at(
|
||||
&layer_id,
|
||||
loop_start,
|
||||
loop_len,
|
||||
&recording_instance_id,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Tear down the recording's own backend clip. Without this it keeps playing on top of the
|
||||
// take folder we're appending to — two takes sounding at once. The target instance's clip is
|
||||
// separately repointed at the new active take by the action's backend sync below.
|
||||
let backend_id = recording_backend_id
|
||||
.or_else(|| self.clip_instance_to_backend_map.remove(&recording_instance_id));
|
||||
self.clip_instance_to_backend_map.remove(&recording_instance_id);
|
||||
let track_id = self.layer_to_track_map.get(&layer_id).copied();
|
||||
if let (Some(backend_id), Some(track_id), Some(controller_arc)) =
|
||||
(backend_id, track_id, self.audio_controller.as_ref())
|
||||
{
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
match backend_id {
|
||||
lightningbeam_core::action::BackendClipInstanceId::Audio(id) => {
|
||||
controller.remove_audio_clip(track_id, id)
|
||||
}
|
||||
lightningbeam_core::action::BackendClipInstanceId::Midi(id) => {
|
||||
controller.remove_midi_clip(track_id, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discard the scaffolding clip + instance.
|
||||
{
|
||||
let doc = self.action_executor.document_mut();
|
||||
if let Some(AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) {
|
||||
al.clip_instances.retain(|ci| ci.id != recording_instance_id);
|
||||
}
|
||||
doc.audio_clips.remove(&recording_clip_id);
|
||||
}
|
||||
|
||||
let action = lightningbeam_core::actions::AppendTakesAction::new(
|
||||
layer_id,
|
||||
target_instance_id,
|
||||
takes,
|
||||
);
|
||||
|
||||
if let Some(controller_arc) = self.audio_controller.clone() {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
let mut backend_context = lightningbeam_core::action::BackendContext {
|
||||
audio_controller: Some(&mut *controller),
|
||||
layer_to_track_map: &self.layer_to_track_map,
|
||||
clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map,
|
||||
};
|
||||
if let Err(e) = self
|
||||
.action_executor
|
||||
.execute_with_backend(Box::new(action), &mut backend_context)
|
||||
{
|
||||
eprintln!("Failed to append cycle takes: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
self.autosave.pending_event = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn sync_audio_layers_to_backend(&mut self) {
|
||||
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
|
||||
|
||||
// Push the document's cycle region to the engine. Needed on load/new: the region is
|
||||
// document state, but the engine starts blank (and is cleared on Reset), so without this a
|
||||
// loaded project would show its cycle strip while the transport never actually looped.
|
||||
// Changes made later go through SetCycleRegionAction's execute_backend.
|
||||
{
|
||||
let (region, enabled) = {
|
||||
let doc = self.action_executor.document();
|
||||
(doc.cycle_region, doc.cycle_enabled)
|
||||
};
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
controller.set_loop_region(region);
|
||||
controller.set_loop_enabled(enabled);
|
||||
// Cycle MIDI mode is a *preference*, not document state, so it rides along here
|
||||
// rather than through an action.
|
||||
controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes);
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
@ -2497,7 +2371,7 @@ impl EditorApp {
|
|||
) -> Vec<uuid::Uuid> {
|
||||
let mut result = Vec::new();
|
||||
for instance in clip_instances {
|
||||
if let Some(clip_duration) = document.clip_trim_duration(&instance.clip_id) {
|
||||
if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) {
|
||||
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
|
||||
let timeline_end = instance.timeline_start + effective_duration;
|
||||
|
||||
|
|
@ -3386,9 +3260,7 @@ impl EditorApp {
|
|||
let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| {
|
||||
let mut duplicate = original.clone();
|
||||
duplicate.id = uuid::Uuid::new_v4();
|
||||
let clip_duration = document
|
||||
.clip_trim_duration(&original.clip_id)
|
||||
.unwrap_or(ClipDuration::Seconds(Seconds(1.0)));
|
||||
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(Seconds(1.0));
|
||||
let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
|
||||
duplicate.timeline_start = original.timeline_start + effective_duration;
|
||||
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
|
||||
|
|
@ -5589,7 +5461,7 @@ impl EditorApp {
|
|||
// matches the video clip exactly).
|
||||
let (_dur, sample_rate, channels) = controller
|
||||
.get_pool_file_info(pool_index)
|
||||
.unwrap_or((Seconds(video_duration), 0, 0));
|
||||
.unwrap_or((video_duration, 0, 0));
|
||||
drop(controller);
|
||||
|
||||
let audio_clip_name = format!("{} (Audio)", video_name);
|
||||
|
|
@ -6496,9 +6368,7 @@ impl eframe::App for EditorApp {
|
|||
use daw_backend::AudioEvent;
|
||||
match event {
|
||||
AudioEvent::PlaybackPosition(time) => {
|
||||
// `playback_time` is the UI's seconds playhead (see the timeline's
|
||||
// seconds/beats model); unwrap at this boundary, not before it.
|
||||
self.playback_time = time.seconds_to_f64();
|
||||
self.playback_time = time;
|
||||
}
|
||||
AudioEvent::PlaybackStopped => {
|
||||
self.is_playing = false;
|
||||
|
|
@ -6604,25 +6474,6 @@ impl eframe::App for EditorApp {
|
|||
})
|
||||
};
|
||||
|
||||
// While cycling, the recording keeps running but the clip only ever
|
||||
// occupies ONE region — each further pass is a new take layered on
|
||||
// the same span, not more length. Cap the preview there so the bar
|
||||
// doesn't grow off past the loop end while the playhead wraps.
|
||||
let cycle_cap = {
|
||||
let doc = self.action_executor.document();
|
||||
match (doc.cycle_enabled, doc.cycle_region) {
|
||||
(true, Some((ls, le))) if le > ls => {
|
||||
let tm = doc.tempo_map();
|
||||
Some(tm.beats_to_seconds(le) - tm.beats_to_seconds(ls))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let duration = match cycle_cap {
|
||||
Some(cap) if duration > cap => cap,
|
||||
_ => duration,
|
||||
};
|
||||
|
||||
// Then update the clip duration (mutable borrow)
|
||||
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) {
|
||||
|
|
@ -6634,158 +6485,6 @@ impl eframe::App for EditorApp {
|
|||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
AudioEvent::CycleRecordingStopped { clip_id: backend_clip_id, takes, loop_start, loop_len_beats, loop_len_seconds } => {
|
||||
eprintln!("[STOP] CycleRecordingStopped: {} takes", takes.len());
|
||||
|
||||
// Clean up the live-recording waveform cache (keyed usize::MAX).
|
||||
self.raw_audio_cache.remove(&usize::MAX);
|
||||
self.waveform_gpu_dirty.remove(&usize::MAX);
|
||||
|
||||
// Pull every take's samples in for waveform rendering — the user can
|
||||
// switch to any of them, not just the active one.
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
for &(pool_index, _) in &takes {
|
||||
match controller.get_pool_audio_samples(pool_index) {
|
||||
Ok((samples, sr, ch)) => {
|
||||
self.raw_audio_cache.insert(pool_index, (Arc::new(samples), sr, ch));
|
||||
self.waveform_gpu_dirty.insert(pool_index);
|
||||
self.audio_pools_with_new_waveforms.insert(pool_index);
|
||||
}
|
||||
Err(e) => eprintln!("Failed to fetch take audio: {}", e),
|
||||
}
|
||||
self.audio_duration_cache.insert(pool_index, loop_len_seconds.seconds_to_f64());
|
||||
}
|
||||
}
|
||||
|
||||
let recording_layer = self.recording_clips.iter()
|
||||
.find(|(_, &cid)| cid == backend_clip_id)
|
||||
.map(|(&lid, _)| lid);
|
||||
|
||||
if let (Some(layer_id), false) = (recording_layer, takes.is_empty()) {
|
||||
let (clip_id, instance_id) = {
|
||||
let document = self.action_executor.document();
|
||||
document.get_layer(&layer_id)
|
||||
.and_then(|layer| {
|
||||
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer {
|
||||
audio_layer.clip_instances.last().map(|i| (i.clip_id, i.id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil()))
|
||||
};
|
||||
|
||||
if !clip_id.is_nil() {
|
||||
self.autosave.pending_event = true;
|
||||
let last_take = takes.len() - 1;
|
||||
|
||||
let new_takes: Vec<lightningbeam_core::clip::AudioTake> = takes
|
||||
.iter()
|
||||
.map(|&(pool_index, _)| lightningbeam_core::clip::AudioTake {
|
||||
// Renumbered by the folder that ends up owning them.
|
||||
name: String::new(),
|
||||
content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index },
|
||||
})
|
||||
.collect();
|
||||
|
||||
// If this region already holds a take folder, extend it rather
|
||||
// than dropping a second clip on top. Queued and run just after
|
||||
// this loop, which holds a borrow on the event queue.
|
||||
if self.action_executor.document()
|
||||
.take_folder_at(&layer_id, loop_start, loop_len_beats, &instance_id)
|
||||
.is_some()
|
||||
{
|
||||
self.pending_take_appends.push(PendingTakeAppend {
|
||||
layer_id,
|
||||
recording_instance_id: instance_id,
|
||||
recording_clip_id: clip_id,
|
||||
// The engine's recording clip. It isn't in
|
||||
// clip_instance_to_backend_map yet (that only happens on
|
||||
// promotion, which we're skipping), so hand it over
|
||||
// directly or it'll keep sounding alongside the folder.
|
||||
recording_backend_id: Some(
|
||||
lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id),
|
||||
),
|
||||
loop_start,
|
||||
loop_len: loop_len_beats,
|
||||
takes: new_takes,
|
||||
});
|
||||
self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id);
|
||||
ctx.request_repaint();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finalize the recording clip, and hang the takes off the INSTANCE.
|
||||
{
|
||||
let doc = self.action_executor.document_mut();
|
||||
if let Some(clip) = doc.audio_clips.get_mut(&clip_id) {
|
||||
// The clip's own content is take 1; the instance's take
|
||||
// list overrides it with whichever take is active. Every
|
||||
// take is exactly one cycle region long, so the clip's
|
||||
// duration is the region.
|
||||
clip.finalize_recording(
|
||||
takes[0].0,
|
||||
loop_len_seconds.seconds_to_f64(),
|
||||
);
|
||||
clip.name = format!("Cycle recording ({} takes)", takes.len());
|
||||
}
|
||||
|
||||
// Anchor the instance to the region and select the most
|
||||
// recent take, GarageBand-style.
|
||||
//
|
||||
// timeline_duration stays None on purpose: pinning it would
|
||||
// make a later tempo change loop/repeat the take's content
|
||||
// to fill the span instead of letting it drift naturally.
|
||||
if let Some(lightningbeam_core::layer::AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) {
|
||||
if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) {
|
||||
inst.timeline_start = loop_start;
|
||||
inst.timeline_duration = None;
|
||||
// Audio take content is seconds, and each take spans
|
||||
// exactly one cycle region.
|
||||
inst.trim_start = daw_backend::ContentTime::ZERO;
|
||||
inst.trim_end =
|
||||
Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64()));
|
||||
inst.takes = takes.iter().enumerate().map(|(i, &(pool_index, _))| {
|
||||
lightningbeam_core::clip::AudioTake {
|
||||
name: format!("Take {}", i + 1),
|
||||
content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index },
|
||||
}
|
||||
}).collect();
|
||||
inst.active_take = Some(last_take);
|
||||
inst.recorded_loop_beats = Some(loop_len_beats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The backend already has a clip for the active take (the engine
|
||||
// pointed it at the last take on stop), so map to it rather than
|
||||
// adding a duplicate.
|
||||
let backend_id = lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id);
|
||||
self.clip_instance_to_backend_map.insert(instance_id, backend_id);
|
||||
|
||||
// Commit the whole cycle-record session as ONE undoable action.
|
||||
let clip_instance = self.layer_to_track_map.get(&layer_id).copied().and_then(|track_id| {
|
||||
self.action_executor.document()
|
||||
.get_layer(&layer_id)
|
||||
.and_then(|l| if let AnyLayer::Audio(al) = l {
|
||||
al.clip_instances.iter().find(|ci| ci.id == instance_id).cloned()
|
||||
} else { None })
|
||||
.map(|ci| (track_id, ci))
|
||||
});
|
||||
if let Some((track_id, clip_instance)) = clip_instance {
|
||||
let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied(
|
||||
layer_id, clip_instance, track_id, backend_id,
|
||||
);
|
||||
self.action_executor.push_applied(Box::new(action));
|
||||
} else {
|
||||
self.media_modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id);
|
||||
}
|
||||
AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => {
|
||||
eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index);
|
||||
|
||||
|
|
@ -6811,7 +6510,6 @@ impl eframe::App for EditorApp {
|
|||
let mut controller = controller_arc.lock().unwrap();
|
||||
match controller.get_pool_file_info(pool_index) {
|
||||
Ok((dur, _, _)) => {
|
||||
let dur = dur.seconds_to_f64();
|
||||
eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur);
|
||||
self.audio_duration_cache.insert(pool_index, dur);
|
||||
dur
|
||||
|
|
@ -6845,7 +6543,7 @@ impl eframe::App for EditorApp {
|
|||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, daw_backend::ContentTime::ZERO))
|
||||
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, 0.0))
|
||||
};
|
||||
|
||||
if !clip_id.is_nil() {
|
||||
|
|
@ -6969,11 +6667,7 @@ impl eframe::App for EditorApp {
|
|||
);
|
||||
}
|
||||
}
|
||||
// Update the clip's duration so the timeline bar grows. Once the
|
||||
// transport has wrapped, the backend reports the whole cycle
|
||||
// region here and keeps reporting it, so the bar grows through
|
||||
// the first pass and then holds — every further pass merges into
|
||||
// the same clip rather than extending it.
|
||||
// Update the clip's duration so the timeline bar grows
|
||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
||||
clip.set_content_duration(ClipDuration::Beats(duration));
|
||||
}
|
||||
|
|
@ -6993,144 +6687,6 @@ impl eframe::App for EditorApp {
|
|||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
AudioEvent::MidiCycleRecordingStopped { track_id, clip_ids, loop_start, loop_len_beats } => {
|
||||
println!("🎹 MIDI cycle recording stopped: {} takes", clip_ids.len());
|
||||
|
||||
// Pull every take's events into the cache — the user can switch to any of
|
||||
// them, not just the active one.
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
for &take_clip_id in &clip_ids {
|
||||
if let Ok(data) = controller.query_midi_clip(track_id, take_clip_id) {
|
||||
self.midi_event_cache.insert(take_clip_id, data.events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let layer_id = self.track_to_layer_map.get(&track_id).copied();
|
||||
if let (Some(layer_id), Some(&first_clip_id)) = (layer_id, clip_ids.first()) {
|
||||
// The doc clip is the one the recording started on — which the backend
|
||||
// reused as take 1.
|
||||
let doc_clip_id = self.action_executor.document()
|
||||
.audio_clip_by_midi_clip_id(first_clip_id)
|
||||
.map(|(id, _)| id);
|
||||
|
||||
if let Some(doc_clip_id) = doc_clip_id {
|
||||
self.autosave.pending_event = true;
|
||||
let last_take = clip_ids.len() - 1;
|
||||
|
||||
let recording_instance_id = self.action_executor.document()
|
||||
.get_layer(&layer_id)
|
||||
.and_then(|l| if let AnyLayer::Audio(al) = l {
|
||||
al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).map(|ci| ci.id)
|
||||
} else { None });
|
||||
|
||||
let new_takes: Vec<lightningbeam_core::clip::AudioTake> = clip_ids
|
||||
.iter()
|
||||
.map(|&mid| lightningbeam_core::clip::AudioTake {
|
||||
// Renumbered by the folder that ends up owning them.
|
||||
name: String::new(),
|
||||
content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid },
|
||||
})
|
||||
.collect();
|
||||
|
||||
// If this region already holds a take folder, extend it rather
|
||||
// than dropping a second clip on top. Queued and run just after
|
||||
// this loop, which holds a borrow on the event queue.
|
||||
let existing_folder = recording_instance_id.filter(|rec_inst| {
|
||||
self.action_executor.document()
|
||||
.take_folder_at(&layer_id, loop_start, loop_len_beats, rec_inst)
|
||||
.is_some()
|
||||
});
|
||||
if let Some(rec_inst) = existing_folder {
|
||||
self.pending_take_appends.push(PendingTakeAppend {
|
||||
layer_id,
|
||||
recording_instance_id: rec_inst,
|
||||
recording_clip_id: doc_clip_id,
|
||||
// The MIDI path maps its recording instance during
|
||||
// MidiRecordingProgress, so the map has it.
|
||||
recording_backend_id: None,
|
||||
loop_start,
|
||||
loop_len: loop_len_beats,
|
||||
takes: new_takes,
|
||||
});
|
||||
self.recording_layer_ids.retain(|id| *id != layer_id);
|
||||
self.recording_clips.remove(&layer_id);
|
||||
if self.recording_layer_ids.is_empty() {
|
||||
self.is_recording = false;
|
||||
self.recording_clips.clear();
|
||||
}
|
||||
ctx.request_repaint();
|
||||
continue;
|
||||
}
|
||||
|
||||
{
|
||||
let doc = self.action_executor.document_mut();
|
||||
if let Some(clip) = doc.audio_clips.get_mut(&doc_clip_id) {
|
||||
// The clip's own content stays take 1 (the clip the
|
||||
// recording started on); the instance's take list
|
||||
// overrides it with whichever take is active. MIDI takes
|
||||
// are beats-domain and each spans one cycle region.
|
||||
clip.set_content_duration(ClipDuration::Beats(loop_len_beats));
|
||||
clip.name = format!("Cycle recording ({} takes)", clip_ids.len());
|
||||
}
|
||||
|
||||
// Anchor to the region and select the most recent take.
|
||||
// timeline_duration stays None on purpose — pinning it would
|
||||
// make a later tempo change loop the take's content to fill
|
||||
// the span instead of letting it drift naturally.
|
||||
if let Some(AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) {
|
||||
if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.clip_id == doc_clip_id) {
|
||||
inst.timeline_start = loop_start;
|
||||
inst.timeline_duration = None;
|
||||
inst.trim_start = daw_backend::ContentTime::ZERO;
|
||||
inst.trim_end = Some(daw_backend::ContentTime(loop_len_beats.beats_to_f64()));
|
||||
inst.takes = clip_ids.iter().enumerate().map(|(i, &mid)| {
|
||||
lightningbeam_core::clip::AudioTake {
|
||||
name: format!("Take {}", i + 1),
|
||||
content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid },
|
||||
}
|
||||
}).collect();
|
||||
inst.active_take = Some(last_take);
|
||||
inst.recorded_loop_beats = Some(loop_len_beats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the whole cycle-record session as ONE undoable action.
|
||||
// The backend instance was mapped during MidiRecordingProgress and
|
||||
// the engine already repointed it at the active take.
|
||||
let instance = self.action_executor.document()
|
||||
.get_layer(&layer_id)
|
||||
.and_then(|l| if let AnyLayer::Audio(al) = l {
|
||||
al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).cloned()
|
||||
} else { None });
|
||||
if let Some(instance) = instance {
|
||||
match (self.layer_to_track_map.get(&layer_id).copied(),
|
||||
self.clip_instance_to_backend_map.get(&instance.id).copied()) {
|
||||
(Some(tid), Some(backend_id)) => {
|
||||
let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied(
|
||||
layer_id, instance, tid, backend_id,
|
||||
);
|
||||
self.action_executor.push_applied(Box::new(action));
|
||||
}
|
||||
_ => self.media_modified = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear recording state, same as the single-clip MIDI path.
|
||||
if let Some(&layer_id) = self.track_to_layer_map.get(&track_id) {
|
||||
self.recording_layer_ids.retain(|id| *id != layer_id);
|
||||
self.recording_clips.remove(&layer_id);
|
||||
}
|
||||
if self.recording_layer_ids.is_empty() {
|
||||
self.is_recording = false;
|
||||
self.recording_clips.clear();
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => {
|
||||
println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes",
|
||||
track_id, clip_id, note_count);
|
||||
|
|
@ -7149,7 +6705,7 @@ impl eframe::App for EditorApp {
|
|||
.map(|(id, _)| id);
|
||||
if let Some(doc_clip_id) = doc_clip_id {
|
||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
||||
clip.set_content_duration(ClipDuration::Beats(midi_clip_data.duration));
|
||||
clip.set_content_duration(ClipDuration::Beats(Beats(midi_clip_data.duration)));
|
||||
clip.name = format!("MIDI Recording {}", clip_id);
|
||||
}
|
||||
}
|
||||
|
|
@ -7277,20 +6833,6 @@ impl eframe::App for EditorApp {
|
|||
|
||||
}
|
||||
|
||||
// Cycle takes that landed on an existing take folder. Deferred out of the event loop above,
|
||||
// which holds a borrow on the event queue and so can't call a `&mut self` method.
|
||||
for req in std::mem::take(&mut self.pending_take_appends) {
|
||||
self.append_cycle_takes(
|
||||
req.layer_id,
|
||||
req.recording_instance_id,
|
||||
req.recording_clip_id,
|
||||
req.recording_backend_id,
|
||||
req.loop_start,
|
||||
req.loop_len,
|
||||
req.takes,
|
||||
);
|
||||
}
|
||||
|
||||
// Update input monitoring based on active layer (only send command when changed)
|
||||
{
|
||||
let should_monitor = self.audio_controller.is_some() && self.active_layer_id.map_or(false, |layer_id| {
|
||||
|
|
@ -7485,11 +7027,6 @@ impl eframe::App for EditorApp {
|
|||
if result.buffer_size_changed {
|
||||
println!("⚠️ Audio buffer size will be applied on next app restart");
|
||||
}
|
||||
// Cycle MIDI mode takes effect immediately — no restart needed, unlike the buffer size.
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes);
|
||||
}
|
||||
// Apply new keybindings if changed
|
||||
if let Some(new_keymap) = result.new_keymap {
|
||||
self.keymap = new_keymap;
|
||||
|
|
|
|||
|
|
@ -42,8 +42,6 @@ pub const GRIP_HORIZONTAL: &str = "\u{e0ea}";
|
|||
pub const CHEVRONS_UP: &str = "\u{e074}";
|
||||
pub const PLAY: &str = "\u{e13c}";
|
||||
pub const PAUSE: &str = "\u{e12e}";
|
||||
pub const REPEAT: &str = "\u{e146}"; // cycle / loop region toggle
|
||||
pub const TRASH: &str = "\u{e18d}"; // delete a take
|
||||
pub const SETTINGS: &str = "\u{e154}";
|
||||
pub const SEARCH: &str = "\u{e151}";
|
||||
pub const PLUS: &str = "\u{e13d}";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! - Image Assets (static images)
|
||||
|
||||
use eframe::egui;
|
||||
use lightningbeam_core::clip::{ResolvedContent, VectorClip};
|
||||
use lightningbeam_core::clip::{AudioClipType, VectorClip};
|
||||
use lightningbeam_core::document::Document;
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
|
@ -918,11 +918,11 @@ impl AssetLibraryPane {
|
|||
continue;
|
||||
}
|
||||
|
||||
let (extra_info, drag_clip_type) = match &clip.resolve() {
|
||||
ResolvedContent::Audio { .. } => ("Sampled".to_string(), DragClipType::AudioSampled),
|
||||
ResolvedContent::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi),
|
||||
ResolvedContent::Recording => {
|
||||
// Skip recording-in-progress clips (and empty take folders) from asset library
|
||||
let (extra_info, drag_clip_type) = match &clip.clip_type {
|
||||
AudioClipType::Sampled { .. } => ("Sampled".to_string(), DragClipType::AudioSampled),
|
||||
AudioClipType::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi),
|
||||
AudioClipType::Recording => {
|
||||
// Skip recording-in-progress clips from asset library
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
|
@ -1118,15 +1118,15 @@ impl AssetLibraryPane {
|
|||
|
||||
for (id, clip) in &document.audio_clips {
|
||||
if !linked_audio_ids.contains(id) && clip.folder_id == current_folder {
|
||||
let (extra_info, drag_clip_type) = match &clip.resolve() {
|
||||
ResolvedContent::Audio { .. } => {
|
||||
let (extra_info, drag_clip_type) = match &clip.clip_type {
|
||||
AudioClipType::Sampled { .. } => {
|
||||
("Sampled".to_string(), DragClipType::AudioSampled)
|
||||
}
|
||||
ResolvedContent::Midi { .. } => {
|
||||
AudioClipType::Midi { .. } => {
|
||||
("MIDI".to_string(), DragClipType::AudioMidi)
|
||||
}
|
||||
ResolvedContent::Recording => {
|
||||
// Skip recording-in-progress clips (and empty take folders)
|
||||
AudioClipType::Recording => {
|
||||
// Skip recording-in-progress clips
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
|
@ -1765,7 +1765,7 @@ impl AssetLibraryPane {
|
|||
let prefetched_waveform: Option<Vec<(f32, f32)>> =
|
||||
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
|
||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||
if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() {
|
||||
if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type {
|
||||
shared.raw_audio_cache.get(audio_pool_index)
|
||||
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize))
|
||||
} else {
|
||||
|
|
@ -1790,8 +1790,8 @@ impl AssetLibraryPane {
|
|||
AssetCategory::Audio => {
|
||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
||||
match &clip.resolve() {
|
||||
ResolvedContent::Audio { .. } => {
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Sampled { .. } => {
|
||||
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
||||
if let Some(ref peaks) = prefetched_waveform {
|
||||
Some(generate_waveform_thumbnail(peaks, bg_color, wave_color))
|
||||
|
|
@ -1799,7 +1799,7 @@ impl AssetLibraryPane {
|
|||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
ResolvedContent::Midi { midi_clip_id } => {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
||||
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) {
|
||||
Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color))
|
||||
|
|
@ -1807,7 +1807,7 @@ impl AssetLibraryPane {
|
|||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
ResolvedContent::Recording => {
|
||||
AudioClipType::Recording => {
|
||||
// Recording in progress - show placeholder
|
||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
|
|
@ -2344,8 +2344,8 @@ impl AssetLibraryPane {
|
|||
AssetCategory::Audio => {
|
||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
||||
match &clip.resolve() {
|
||||
ResolvedContent::Audio { audio_pool_index } => {
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Sampled { audio_pool_index } => {
|
||||
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
||||
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
|
||||
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
|
||||
|
|
@ -2355,7 +2355,7 @@ impl AssetLibraryPane {
|
|||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
ResolvedContent::Midi { midi_clip_id } => {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
||||
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) {
|
||||
Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color))
|
||||
|
|
@ -2363,7 +2363,7 @@ impl AssetLibraryPane {
|
|||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
ResolvedContent::Recording => {
|
||||
AudioClipType::Recording => {
|
||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
|
|
@ -2481,8 +2481,8 @@ impl AssetLibraryPane {
|
|||
AssetCategory::Audio => {
|
||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
||||
match &clip.resolve() {
|
||||
ResolvedContent::Audio { audio_pool_index } => {
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Sampled { audio_pool_index } => {
|
||||
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
||||
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
|
||||
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
|
||||
|
|
@ -2492,7 +2492,7 @@ impl AssetLibraryPane {
|
|||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
ResolvedContent::Midi { midi_clip_id } => {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
||||
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) {
|
||||
Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color))
|
||||
|
|
@ -2500,7 +2500,7 @@ impl AssetLibraryPane {
|
|||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
ResolvedContent::Recording => {
|
||||
AudioClipType::Recording => {
|
||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
|
|
@ -2802,7 +2802,7 @@ impl AssetLibraryPane {
|
|||
let prefetched_waveform: Option<Vec<(f32, f32)>> =
|
||||
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
|
||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||
if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() {
|
||||
if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type {
|
||||
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
|
||||
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
|
||||
if waveform.is_some() {
|
||||
|
|
@ -2842,8 +2842,8 @@ impl AssetLibraryPane {
|
|||
// Check if it's sampled or MIDI
|
||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
||||
match &clip.resolve() {
|
||||
ResolvedContent::Audio { .. } => {
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Sampled { .. } => {
|
||||
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
||||
if let Some(ref peaks) = prefetched_waveform {
|
||||
println!("✅ Generating waveform thumbnail with {} peaks for asset {}", peaks.len(), asset_id);
|
||||
|
|
@ -2853,7 +2853,7 @@ impl AssetLibraryPane {
|
|||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
ResolvedContent::Midi { midi_clip_id } => {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
||||
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
||||
|
||||
|
|
@ -2863,7 +2863,7 @@ impl AssetLibraryPane {
|
|||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
ResolvedContent::Recording => {
|
||||
AudioClipType::Recording => {
|
||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||
}
|
||||
}
|
||||
|
|
@ -3187,7 +3187,7 @@ impl PaneRenderer for AssetLibraryPane {
|
|||
println!("🎨 [ASSET_LIB] Checking for thumbnails to invalidate (pools: {:?})", shared.audio_pools_with_new_waveforms);
|
||||
let mut invalidated_any = false;
|
||||
for (asset_id, clip) in &document_arc.audio_clips {
|
||||
if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() {
|
||||
if let lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } = &clip.clip_type {
|
||||
if shared.audio_pools_with_new_waveforms.contains(audio_pool_index) {
|
||||
println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index);
|
||||
self.thumbnail_cache.invalidate(asset_id);
|
||||
|
|
|
|||
|
|
@ -1612,12 +1612,8 @@ impl InfopanelPane {
|
|||
ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64()));
|
||||
});
|
||||
|
||||
let clip_dur = document.clip_trim_duration(&ci.clip_id)
|
||||
.unwrap_or_else(|| lightningbeam_core::clip::ClipDuration::Seconds(
|
||||
daw_backend::Seconds(
|
||||
(ci.trim_end.unwrap_or(daw_backend::ContentTime(1.0)) - ci.trim_start).raw(),
|
||||
),
|
||||
));
|
||||
let clip_dur = document.get_clip_duration(&ci.clip_id)
|
||||
.unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start));
|
||||
let total_dur = ci.total_duration(clip_dur, document.tempo_map());
|
||||
let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur)
|
||||
- document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64();
|
||||
|
|
@ -1626,16 +1622,10 @@ impl InfopanelPane {
|
|||
ui.label(format!("{:.2}s", total_dur_secs));
|
||||
});
|
||||
|
||||
if ci.trim_start > daw_backend::ContentTime::ZERO {
|
||||
if ci.trim_start > 0.0 {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Trim Start:");
|
||||
// Content time in the clip's own domain: seconds for sampled
|
||||
// audio/video/vector, beats for MIDI. Label it accordingly.
|
||||
let unit = match clip_dur {
|
||||
lightningbeam_core::clip::ClipDuration::Beats(_) => "beats",
|
||||
lightningbeam_core::clip::ClipDuration::Seconds(_) => "s",
|
||||
};
|
||||
ui.label(format!("{:.2}{}", ci.trim_start.raw(), unit));
|
||||
ui.label(format!("{:.2}s", ci.trim_start));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -465,13 +465,9 @@ impl PianoRollPane {
|
|||
if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) {
|
||||
for instance in &audio_layer.clip_instances {
|
||||
if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
|
||||
// Resolve through the instance's active take, so a MIDI take folder edits
|
||||
// whichever take it's actually playing.
|
||||
if let Some(midi_clip_id) = instance.resolved_midi_clip_id(clip) {
|
||||
let duration = instance.effective_duration(clip.content_duration(), document.tempo_map());
|
||||
// A MIDI clip's content time IS beats, which is what the piano roll's
|
||||
// x-axis uses.
|
||||
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), instance.id));
|
||||
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type {
|
||||
let duration = instance.effective_duration(clip.content_duration().to_seconds(document.tempo_map()), document.tempo_map());
|
||||
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2467,8 +2463,7 @@ impl PianoRollPane {
|
|||
});
|
||||
// Get sample rate from raw_audio_cache
|
||||
if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) {
|
||||
// A sampled clip's content time is seconds.
|
||||
clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), *sr));
|
||||
clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), *sr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use eframe::egui;
|
||||
use daw_backend::Seconds;
|
||||
use lightningbeam_core::action::Action;
|
||||
use lightningbeam_core::clip::{ClipDuration, ClipInstance};
|
||||
use lightningbeam_core::clip::ClipInstance;
|
||||
use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter};
|
||||
use lightningbeam_core::layer::{AnyLayer, AudioLayer};
|
||||
use lightningbeam_core::renderer::RenderedLayerType;
|
||||
|
|
@ -1854,10 +1854,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
// For now, create a simple effect instance with default parameters
|
||||
let tempo_map = self.ctx.document.tempo_map();
|
||||
let effect_end_beats = effect_instance.timeline_start
|
||||
+ effect_instance.effective_duration(
|
||||
ClipDuration::Seconds(Seconds(lightningbeam_core::effect::EFFECT_DURATION)),
|
||||
tempo_map,
|
||||
);
|
||||
+ effect_instance.effective_duration(Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map);
|
||||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||
effect_def,
|
||||
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
|
||||
|
|
@ -2212,7 +2209,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
|
||||
// Calculate clip bounds for preview
|
||||
let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64();
|
||||
let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start.raw();
|
||||
let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start;
|
||||
let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) {
|
||||
vector_clip.calculate_content_bounds(&self.ctx.document, clip_time)
|
||||
} else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) {
|
||||
|
|
@ -2302,9 +2299,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
for &clip_id in self.ctx.selection.clip_instances() {
|
||||
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
||||
// Skip clip instances not active at current time (compare in seconds).
|
||||
let clip_dur = ClipDuration::Seconds(
|
||||
self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO),
|
||||
);
|
||||
let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO);
|
||||
let tempo_map = self.ctx.document.tempo_map();
|
||||
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||
let instance_end = tempo_map.beats_to_seconds(
|
||||
|
|
@ -2315,7 +2310,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
}
|
||||
|
||||
// Calculate clip-local time
|
||||
let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||
let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
// Get dynamic clip bounds from content at current time
|
||||
let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) {
|
||||
|
|
@ -2685,9 +2680,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
|
||||
// Find clip instance visible at playback time
|
||||
let visible_clip = video_layer.clip_instances.iter().find(|inst| {
|
||||
let clip_duration = ClipDuration::Seconds(
|
||||
self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO),
|
||||
);
|
||||
let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO);
|
||||
let tempo_map = self.ctx.document.tempo_map();
|
||||
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64();
|
||||
let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();
|
||||
|
|
@ -10149,7 +10142,7 @@ impl StagePane {
|
|||
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
||||
// Calculate clip-local time
|
||||
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
// Get dynamic clip bounds from content at current time
|
||||
use vello::kurbo::Rect as KurboRect;
|
||||
|
|
@ -10350,7 +10343,7 @@ impl StagePane {
|
|||
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) {
|
||||
// Calculate clip-local time
|
||||
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
// Get dynamic clip bounds from content at current time
|
||||
let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) {
|
||||
|
|
@ -11066,9 +11059,7 @@ impl StagePane {
|
|||
let document = shared.action_executor.document();
|
||||
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
|
||||
video_layer.clip_instances.iter().find(|inst| {
|
||||
let clip_duration = ClipDuration::Seconds(
|
||||
document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO),
|
||||
);
|
||||
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO);
|
||||
let tempo_map = document.tempo_map();
|
||||
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64();
|
||||
let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -55,7 +55,6 @@ struct PreferencesState {
|
|||
file_height: u32,
|
||||
scroll_speed: f64,
|
||||
audio_buffer_size: u32,
|
||||
cycle_midi_separate_takes: bool,
|
||||
reopen_last_session: bool,
|
||||
restore_layout_from_file: bool,
|
||||
debug: bool,
|
||||
|
|
@ -73,7 +72,6 @@ impl From<(&AppConfig, &Theme)> for PreferencesState {
|
|||
file_height: config.file_height,
|
||||
scroll_speed: config.scroll_speed,
|
||||
audio_buffer_size: config.audio_buffer_size,
|
||||
cycle_midi_separate_takes: config.cycle_midi_separate_takes,
|
||||
reopen_last_session: config.reopen_last_session,
|
||||
restore_layout_from_file: config.restore_layout_from_file,
|
||||
debug: config.debug,
|
||||
|
|
@ -93,7 +91,6 @@ impl Default for PreferencesState {
|
|||
file_height: 600,
|
||||
scroll_speed: 1.0,
|
||||
audio_buffer_size: 256,
|
||||
cycle_midi_separate_takes: false,
|
||||
reopen_last_session: false,
|
||||
restore_layout_from_file: true,
|
||||
debug: false,
|
||||
|
|
@ -546,39 +543,6 @@ impl PreferencesDialog {
|
|||
});
|
||||
|
||||
ui.label("Requires app restart to take effect");
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Cycle MIDI recording:");
|
||||
|
||||
egui::ComboBox::from_id_salt("cycle_midi_mode")
|
||||
.selected_text(if self.working_prefs.cycle_midi_separate_takes {
|
||||
"Separate takes"
|
||||
} else {
|
||||
"Merge"
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(
|
||||
&mut self.working_prefs.cycle_midi_separate_takes,
|
||||
false,
|
||||
"Merge",
|
||||
)
|
||||
.on_hover_text(
|
||||
"Every pass overdubs into one clip, and earlier passes play back as \
|
||||
you record so you can layer against them.",
|
||||
);
|
||||
ui.selectable_value(
|
||||
&mut self.working_prefs.cycle_midi_separate_takes,
|
||||
true,
|
||||
"Separate takes",
|
||||
)
|
||||
.on_hover_text(
|
||||
"Each pass becomes its own take in a take folder, as audio always \
|
||||
does. Earlier passes stay silent — they're alternatives, not layers.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -677,7 +641,6 @@ impl PreferencesDialog {
|
|||
temp_config.file_height = self.working_prefs.file_height;
|
||||
temp_config.scroll_speed = self.working_prefs.scroll_speed;
|
||||
temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size;
|
||||
temp_config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes;
|
||||
temp_config.reopen_last_session = self.working_prefs.reopen_last_session;
|
||||
temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
|
||||
temp_config.debug = self.working_prefs.debug;
|
||||
|
|
@ -712,7 +675,6 @@ impl PreferencesDialog {
|
|||
config.file_height = self.working_prefs.file_height;
|
||||
config.scroll_speed = self.working_prefs.scroll_speed;
|
||||
config.audio_buffer_size = self.working_prefs.audio_buffer_size;
|
||||
config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes;
|
||||
config.reopen_last_session = self.working_prefs.reopen_last_session;
|
||||
config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
|
||||
config.debug = self.working_prefs.debug;
|
||||
|
|
|
|||
Loading…
Reference in New Issue