Merge take management into painting UX branch

Conflicts were almost all upstream's take work inserting code next to
lines our earlier cycle-recording commits had touched, plus one API
change we had to follow:

- ClipInstance trim access changed (ci.trim_start -> a ci_trim_start
  local; trim_end now needs .raw()). Took upstream's form throughout
  timeline.rs; the already-merged lines around each hunk were using it,
  so keeping ours would not have compiled.
- recording.rs wrap_at_cycle now bumps current_pass after
  close_active_notes, so a note held across the boundary has its tail
  attributed to the pass that's ending and its re-opened half to the one
  beginning. Upstream's version is a superset of ours.
- Kept our headers-only timeline mode (drop the track area below 1.5x the
  layer-header width); upstream had not touched that block.

Everything else (TRASH glyph, take actions, SetCycleMidiSeparateTakes,
engine cycle-MIDI plumbing, render_take_menu) was purely additive.

Workspace compiles; daw-backend's 32 tests pass, including the cycle and
MIDI take-slicing suites that sit directly on the resolved code.
This commit is contained in:
Skyler Lehmkuhl 2026-07-14 13:25:19 -04:00
commit 07b2478a0e
36 changed files with 3688 additions and 774 deletions

View File

@ -41,6 +41,9 @@ pub struct Engine {
/// Cycle-region sample bounds frozen for the duration of an **audio** recording. /// Cycle-region sample bounds frozen for the duration of an **audio** recording.
/// See `loop_bounds_samples` for why. `None` = derive live from the tempo map. /// See `loop_bounds_samples` for why. `None` = derive live from the tempo map.
loop_bounds_frozen: Option<(i64, i64)>, loop_bounds_frozen: Option<(i64, i64)>,
/// How a cycle MIDI recording treats its passes: merge into one clip (default), or one clip per
/// pass for the editor to fold into a take folder.
cycle_midi_separate_takes: bool,
// Lock-free communication // Lock-free communication
command_rx: rtrb::Consumer<Command>, command_rx: rtrb::Consumer<Command>,
@ -167,6 +170,7 @@ impl Engine {
loop_region: None, loop_region: None,
loop_enabled: false, loop_enabled: false,
loop_bounds_frozen: None, loop_bounds_frozen: None,
cycle_midi_separate_takes: false,
command_rx, command_rx,
midi_command_rx: None, midi_command_rx: None,
event_tx, event_tx,
@ -494,6 +498,11 @@ impl Engine {
self.project.reset_read_ahead_targets(); self.project.reset_read_ahead_targets();
// Render the entire project hierarchy into the mix buffer // Render the entire project hierarchy into the mix buffer
// Silence everything else on the track being recorded into (see RenderContext).
let recording_midi = self
.midi_recording_state
.as_ref()
.map(|rec| (rec.track_id, rec.clip_id));
self.project.render( self.project.render(
&mut self.mix_buffer, &mut self.mix_buffer,
&self.audio_pool, &self.audio_pool,
@ -503,6 +512,7 @@ impl Engine {
self.sample_rate, self.sample_rate,
self.channels, self.channels,
false, false,
recording_midi,
); );
// Copy mix to output // Copy mix to output
@ -543,6 +553,63 @@ impl Engine {
rec.wrap_at_cycle(le_beats, ls_beats); rec.wrap_at_cycle(le_beats, ls_beats);
} }
// Overdub monitoring. In MERGE mode every pass layers onto the same clip, so
// the next pass has to PLAY BACK what was just laid down — otherwise you're
// overdubbing against silence, which defeats the point of merging (you can't
// put a hi-hat on a kick you can't hear).
//
// The notes only reach the pool clip at stop otherwise, so fold what's been
// captured so far into it now, at the boundary. Disjoint field borrows:
// `midi_recording_state` read, `project` written.
//
// Deliberately NOT done in separate-takes mode: there, each pass is an
// alternative rather than a layer, so hearing the previous take play back
// under you would just be confusing — you'd be playing along with the take
// you're trying to replace.
if let Some(rec) = self.midi_recording_state.as_ref().filter(|_| !self.cycle_midi_separate_takes) {
if let Some(clip) =
self.project.midi_clip_pool.get_clip_mut(rec.clip_id)
{
// Note offsets are relative to `start_time`, which for a cycle
// recording IS the region start — so they're already region-relative
// and drop straight in.
clip.duration = le_beats - ls_beats;
// Reusing the existing Vec keeps this allocation-free after the
// first wrap, which matters on the audio thread. (Mutating the pool
// here is the same thing `Command::UpdateMidiClipNotes` already does
// from this thread.)
clip.events.clear();
for (start, note, velocity, duration) in rec.get_notes() {
clip.events.push(MidiEvent::note_on(*start, 0, *note, *velocity));
clip.events.push(MidiEvent::note_off(
*start + *duration,
0,
*note,
64,
));
}
clip.events.sort_by(|a, b| {
a.timestamp.partial_cmp(&b.timestamp).unwrap()
});
}
}
// The clip INSTANCE is sized by the recording-progress block above, which
// now pins it to the whole region once `cycle_loop_len` is set (this wrap
// sets it). That's what lets the sequencer actually schedule the events we
// just wrote — an instance sized to the elapsed-since-start playhead would
// have collapsed back to nothing here.
// An audio recording in progress just completed a pass. This only decides
// *whether* the recording becomes multi-take — the takes themselves are cut
// geometrically at stop, since the playhead advances before the capture
// block below, so the wrap instant isn't sample-exact against the buffer
// that was just captured. The geometry is.
if let Some(ref mut rec) = self.recording_state {
if let Some(ref mut cycle) = rec.cycle {
cycle.wrap_count += 1;
}
}
if let Some(ref mut dr) = self.disk_reader { if let Some(ref mut dr) = self.disk_reader {
dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek {
frame: self.playhead.max(0) as u64, frame: self.playhead.max(0) as u64,
@ -561,7 +628,7 @@ impl Engine {
if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize
{ {
// Clamp to 0 during count-in pre-roll (negative playhead = before project start) // Clamp to 0 during count-in pre-roll (negative playhead = before project start)
let position_seconds = self.playhead.max(0) as f64 / self.sample_rate as f64; let position_seconds = Seconds(self.playhead.max(0) as f64 / self.sample_rate as f64);
let _ = self let _ = self
.event_tx .event_tx
.push(AudioEvent::PlaybackPosition(position_seconds)); .push(AudioEvent::PlaybackPosition(position_seconds));
@ -571,8 +638,31 @@ impl Engine {
if let Some(recording) = &self.midi_recording_state { if let Some(recording) = &self.midi_recording_state {
let current_time_secs = Seconds(self.playhead as f64 / self.sample_rate as f64); let current_time_secs = Seconds(self.playhead as f64 / self.sample_rate as f64);
let current_time = self.tempo_map.seconds_to_beats(current_time_secs); let current_time = self.tempo_map.seconds_to_beats(current_time_secs);
let duration = current_time - recording.start_time; // Once the transport has wrapped, the recording covers the WHOLE cycle region and
let notes = recording.get_notes_with_active(current_time); // stays there — every further pass merges into the same clip rather than
// extending it. Measuring from the playhead instead would reset to zero at each
// wrap (the playhead jumps back), which both made the clip bar restart from zero
// every pass and — because this block also resizes the backend clip instance
// below — shrank the instance back to nothing, so the notes just merged into it
// were never scheduled and you overdubbed against silence.
// Pinned only once a pass has actually completed — until then the bar still grows.
let duration = recording
.cycle_loop_len
.filter(|_| recording.wrapped)
.unwrap_or(current_time - recording.start_time);
// In separate-takes mode the preview shows only the pass being played now — the
// earlier passes are alternative takes, not layers, so drawing them all on top of
// each other would misrepresent what's being recorded.
let notes = if self.cycle_midi_separate_takes && recording.cycle_loop_len.is_some() {
let mut current = recording
.notes_by_pass(recording.pass_count())
.pop()
.unwrap_or_default();
current.extend(recording.active_notes_with_provisional_end(current_time));
current
} else {
recording.get_notes_with_active(current_time)
};
let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress( let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress(
recording.track_id, recording.track_id,
recording.clip_id, recording.clip_id,
@ -610,6 +700,7 @@ impl Engine {
self.sample_rate, self.sample_rate,
self.channels, self.channels,
true, // live_only true, // live_only
None, // no clips are scheduled at all in live_only, so nothing to mute
); );
output.copy_from_slice(&self.mix_buffer); output.copy_from_slice(&self.mix_buffer);
} }
@ -894,6 +985,9 @@ impl Engine {
Command::SetLoopEnabled(enabled) => { Command::SetLoopEnabled(enabled) => {
self.loop_enabled = enabled; self.loop_enabled = enabled;
} }
Command::SetCycleMidiSeparateTakes(separate) => {
self.cycle_midi_separate_takes = separate;
}
Command::Stop => { Command::Stop => {
self.playing = false; self.playing = false;
self.playhead = 0; self.playhead = 0;
@ -911,7 +1005,7 @@ impl Engine {
self.project.stop_all_notes(); self.project.stop_all_notes();
} }
Command::Seek(seconds) => { Command::Seek(seconds) => {
self.playhead = (seconds * self.sample_rate as f64) as i64; self.playhead = (seconds.seconds_to_f64() * self.sample_rate as f64) as i64;
// Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed) // Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed)
let clamped = self.playhead.max(0) as u64; let clamped = self.playhead.max(0) as u64;
self.playhead_atomic.store(clamped, Ordering::Relaxed); self.playhead_atomic.store(clamped, Ordering::Relaxed);
@ -944,36 +1038,56 @@ impl Engine {
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.external_start = Beats(new_start_time); clip.external_start = new_start_time;
} }
} }
Some(crate::audio::track::TrackNode::Midi(track)) => { Some(crate::audio::track::TrackNode::Midi(track)) => {
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) {
instance.external_start = Beats(new_start_time); instance.external_start = new_start_time;
} }
} }
_ => {} _ => {}
} }
self.refresh_clip_snapshot(); self.refresh_clip_snapshot();
} }
Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => { Command::TrimClip(track_id, clip_id, range) => {
// Trim changes which portion of the source content is used // Trim changes which portion of the source content is used.
// Also updates external_duration to match internal duration (no looping after trim) // Also collapses external_duration to the trimmed content length (no looping after
match self.project.get_track_mut(track_id) { // a trim).
Some(crate::audio::track::TrackNode::Audio(track)) => { let tempo_map = self.tempo_map.clone();
match (self.project.get_track_mut(track_id), range) {
(
Some(crate::audio::track::TrackNode::Audio(track)),
crate::command::TrimRange::Seconds { start, end },
) => {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.internal_start = Seconds(new_internal_start); clip.internal_start = start;
clip.internal_end = Seconds(new_internal_end); clip.internal_end = end;
clip.external_duration = Beats(new_internal_end - new_internal_start); // external_duration is BEATS while the trims are SECONDS, so the span
// has to be converted at the clip's position on the timeline — NOT
// reinterpreted as `Beats(end - start)`, which played a 1-second trim
// back as half a second at 120 BPM.
clip.external_duration = tempo_map.seconds_to_beats(
tempo_map.beats_to_seconds(clip.external_start) + (end - start),
) - clip.external_start;
} }
} }
Some(crate::audio::track::TrackNode::Midi(track)) => { (
Some(crate::audio::track::TrackNode::Midi(track)),
crate::command::TrimRange::Beats { start, end },
) => {
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
instance.internal_start = Beats(new_internal_start); instance.internal_start = start;
instance.internal_end = Beats(new_internal_end); instance.internal_end = end;
instance.external_duration = Beats(new_internal_end - new_internal_start); // MIDI content time IS beats, so the span carries over directly.
instance.external_duration = end - start;
} }
} }
// A domain that doesn't match the track kind (seconds at a MIDI track, or vice
// versa) is a caller bug, not something to guess at.
(Some(_), _) => {
debug_assert!(false, "TrimClip domain does not match the track kind");
}
_ => {} _ => {}
} }
self.refresh_clip_snapshot(); self.refresh_clip_snapshot();
@ -983,12 +1097,12 @@ impl Engine {
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.external_duration = Beats(new_external_duration); clip.external_duration = new_external_duration;
} }
} }
Some(crate::audio::track::TrackNode::Midi(track)) => { Some(crate::audio::track::TrackNode::Midi(track)) => {
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
instance.external_duration = Beats(new_external_duration); instance.external_duration = new_external_duration;
} }
} }
_ => {} _ => {}
@ -1015,7 +1129,7 @@ impl Engine {
} }
Command::SetOffset(track_id, offset) => { Command::SetOffset(track_id, offset) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
metatrack.offset = Seconds(offset); metatrack.offset = offset;
} }
} }
Command::SetPitchShift(track_id, semitones) => { Command::SetPitchShift(track_id, semitones) => {
@ -1025,12 +1139,12 @@ impl Engine {
} }
Command::SetTrimStart(track_id, trim_start) => { Command::SetTrimStart(track_id, trim_start) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
metatrack.trim_start = Seconds(trim_start.max(0.0)); metatrack.trim_start = Seconds(trim_start.seconds_to_f64().max(0.0));
} }
} }
Command::SetTrimEnd(track_id, trim_end) => { Command::SetTrimEnd(track_id, trim_end) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
metatrack.trim_end = trim_end.map(|t| Seconds(t.max(0.0))); metatrack.trim_end = trim_end.map(|t| Seconds(t.seconds_to_f64().max(0.0)));
} }
} }
Command::CreateAudioTrack(name, parent_id) => { Command::CreateAudioTrack(name, parent_id) => {
@ -1084,9 +1198,13 @@ impl Engine {
// Send chunks via MPSC channel (will be forwarded by audio thread) // Send chunks via MPSC channel (will be forwarded by audio thread)
if !chunks.is_empty() { if !chunks.is_empty() {
println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index); println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index);
let event_chunks: Vec<(u32, (f64, f64), Vec<crate::io::WaveformPeak>)> = chunks let event_chunks: Vec<(u32, (Seconds, Seconds), Vec<crate::io::WaveformPeak>)> = chunks
.into_iter() .into_iter()
.map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) .map(|chunk| {
// A chunk's time_range is a wall-clock span into the audio file.
let (start, end) = chunk.time_range;
(chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks)
})
.collect(); .collect();
match chunk_tx.send(AudioEvent::WaveformChunksReady { match chunk_tx.send(AudioEvent::WaveformChunksReady {
@ -1150,12 +1268,12 @@ impl Engine {
let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
// Create clip content in the pool // Create clip content in the pool
let clip = MidiClip::empty(clip_id, Beats(duration), format!("MIDI Clip {}", clip_id)); let clip = MidiClip::empty(clip_id, duration, format!("MIDI Clip {}", clip_id));
self.project.midi_clip_pool.add_existing_clip(clip); self.project.midi_clip_pool.add_existing_clip(clip);
// Create an instance for this clip on the track // Create an instance for this clip on the track
let instance_id = self.project.next_midi_clip_instance_id(); let instance_id = self.project.next_midi_clip_instance_id();
let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time)); let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time);
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
track.clip_instances.push(instance); track.clip_instances.push(instance);
@ -1170,11 +1288,11 @@ impl Engine {
// Note: clip_id here refers to the clip in the pool, not the instance // Note: clip_id here refers to the clip in the pool, not the instance
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) {
// Timestamp is in beats (canonical) // Timestamp is in beats (canonical)
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
clip.add_event(note_on); clip.add_event(note_on);
// Add note off event // Add note off event
let note_off_time = Beats(time_offset + duration); let note_off_time = time_offset + duration;
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.add_event(note_off); clip.add_event(note_off);
} else { } else {
@ -1183,9 +1301,9 @@ impl Engine {
if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) { if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) {
let actual_clip_id = instance.clip_id; let actual_clip_id = instance.clip_id;
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) { if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) {
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
clip.add_event(note_on); clip.add_event(note_on);
let note_off_time = Beats(time_offset + duration); let note_off_time = time_offset + duration;
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.add_event(note_off); clip.add_event(note_off);
} }
@ -1195,7 +1313,7 @@ impl Engine {
} }
Command::AddLoadedMidiClip(track_id, clip, start_time) => { Command::AddLoadedMidiClip(track_id, clip, start_time) => {
// Add a pre-loaded MIDI clip to the track with the given start time // Add a pre-loaded MIDI clip to the track with the given start time
if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) {
// instance positions are already in beats; nothing to sync // instance positions are already in beats; nothing to sync
} }
self.refresh_clip_snapshot(); self.refresh_clip_snapshot();
@ -1209,11 +1327,11 @@ impl Engine {
// Add new events from the notes array // Add new events from the notes array
// Timestamps are in beats (canonical) // Timestamps are in beats (canonical)
for (start_time, note, velocity, duration) in notes { for (start_time, note, velocity, duration) in notes {
let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity); let note_on = MidiEvent::note_on(start_time, 0, note, velocity);
clip.events.push(note_on); clip.events.push(note_on);
// Add note off event // Add note off event
let note_off_time = Beats(start_time + duration); let note_off_time = start_time + duration;
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.events.push(note_off); clip.events.push(note_off);
} }
@ -1275,7 +1393,7 @@ impl Engine {
} }
Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => { Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => {
// Add an automation point to the specified lane // Add an automation point to the specified lane
let point = crate::audio::AutomationPoint::new(Beats(time), value, curve); let point = crate::audio::AutomationPoint::new(time, value, curve);
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
@ -1301,17 +1419,17 @@ impl Engine {
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(lane) = track.get_automation_lane_mut(lane_id) { if let Some(lane) = track.get_automation_lane_mut(lane_id) {
lane.remove_point_at_time(Beats(time), Beats(tolerance)); lane.remove_point_at_time(time, tolerance);
} }
} }
Some(crate::audio::track::TrackNode::Midi(track)) => { Some(crate::audio::track::TrackNode::Midi(track)) => {
if let Some(lane) = track.get_automation_lane_mut(lane_id) { if let Some(lane) = track.get_automation_lane_mut(lane_id) {
lane.remove_point_at_time(Beats(time), Beats(tolerance)); lane.remove_point_at_time(time, tolerance);
} }
} }
Some(crate::audio::track::TrackNode::Group(group)) => { Some(crate::audio::track::TrackNode::Group(group)) => {
if let Some(lane) = group.get_automation_lane_mut(lane_id) { if let Some(lane) = group.get_automation_lane_mut(lane_id) {
lane.remove_point_at_time(Beats(time), Beats(tolerance)); lane.remove_point_at_time(time, tolerance);
} }
} }
None => {} None => {}
@ -1374,9 +1492,9 @@ impl Engine {
None => {} None => {}
} }
} }
Command::StartRecording(track_id, start_time) => { Command::StartRecording(track_id, start_time, force_takes) => {
// Start recording on the specified track // Start recording on the specified track
self.handle_start_recording(track_id, start_time); self.handle_start_recording(track_id, start_time, force_takes);
} }
Command::StopRecording => { Command::StopRecording => {
// Stop the current recording // Stop the current recording
@ -1394,9 +1512,9 @@ impl Engine {
recording.resume(); recording.resume();
} }
} }
Command::StartMidiRecording(track_id, clip_id, start_time) => { Command::StartMidiRecording(track_id, clip_id, start_time, force_takes) => {
// Start MIDI recording on the specified track // Start MIDI recording on the specified track
self.handle_start_midi_recording(track_id, clip_id, start_time); self.handle_start_midi_recording(track_id, clip_id, start_time, force_takes);
} }
Command::StopMidiRecording => { Command::StopMidiRecording => {
eprintln!("[ENGINE] Received StopMidiRecording command"); eprintln!("[ENGINE] Received StopMidiRecording command");
@ -2400,7 +2518,7 @@ impl Engine {
// Downcast to AutomationInputNode using as_any_mut // Downcast to AutomationInputNode using as_any_mut
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() { if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
let keyframe = AutomationKeyframe { let keyframe = AutomationKeyframe {
time: Beats(time), time,
value, value,
interpolation, interpolation,
ease_out, ease_out,
@ -2428,7 +2546,7 @@ impl Engine {
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() { if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
auto_node.remove_keyframe_at_time(Beats(time), Beats(0.001)); // 1ms tolerance auto_node.remove_keyframe_at_time(time, Beats(0.001)); // 1ms tolerance
} else { } else {
eprintln!("Node {} is not an AutomationInputNode", node_id); eprintln!("Node {} is not an AutomationInputNode", node_id);
} }
@ -2497,9 +2615,12 @@ impl Engine {
// Send chunks via MPSC channel (will be forwarded by audio thread) // Send chunks via MPSC channel (will be forwarded by audio thread)
if !chunks.is_empty() { if !chunks.is_empty() {
let event_chunks: Vec<(u32, (f64, f64), Vec<crate::io::WaveformPeak>)> = chunks let event_chunks: Vec<(u32, (Seconds, Seconds), Vec<crate::io::WaveformPeak>)> = chunks
.into_iter() .into_iter()
.map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) .map(|chunk| {
let (start, end) = chunk.time_range;
(chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks)
})
.collect(); .collect();
let _ = chunk_tx.send(AudioEvent::WaveformChunksReady { let _ = chunk_tx.send(AudioEvent::WaveformChunksReady {
@ -2663,7 +2784,7 @@ impl Engine {
path: path_str, path: path_str,
channels: metadata.channels, channels: metadata.channels,
sample_rate: metadata.sample_rate, sample_rate: metadata.sample_rate,
duration: metadata.duration, duration: Seconds(metadata.duration),
format: metadata.format, format: metadata.format,
}); });
@ -2771,7 +2892,7 @@ impl Engine {
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) { if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
use crate::command::MidiClipData; use crate::command::MidiClipData;
QueryResponse::MidiClipData(Ok(MidiClipData { QueryResponse::MidiClipData(Ok(MidiClipData {
duration: clip.duration.0, duration: clip.duration,
events: clip.events.clone(), events: clip.events.clone(),
})) }))
} else { } else {
@ -2803,7 +2924,7 @@ impl Engine {
InterpolationType::Hold => "hold", InterpolationType::Hold => "hold",
}.to_string(); }.to_string();
AutomationKeyframeData { AutomationKeyframeData {
time: kf.time.0, time: kf.time,
value: kf.value, value: kf.value,
interpolation: interpolation_str, interpolation: interpolation_str,
ease_out: kf.ease_out, ease_out: kf.ease_out,
@ -2972,7 +3093,11 @@ impl Engine {
} }
Query::GetPoolFileInfo(pool_index) => { Query::GetPoolFileInfo(pool_index) => {
match self.audio_pool.get_file_info(pool_index) { match self.audio_pool.get_file_info(pool_index) {
Some(info) => QueryResponse::PoolFileInfo(Ok(info)), // The pool measures a file's length in wall-clock seconds; name it as such at
// the boundary rather than handing a bare f64 to the UI.
Some((duration, sample_rate, channels)) => {
QueryResponse::PoolFileInfo(Ok((Seconds(duration), sample_rate, channels)))
}
None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))), None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))),
} }
} }
@ -3019,7 +3144,7 @@ impl Engine {
} }
Query::AddMidiClipSync(track_id, clip, start_time) => { Query::AddMidiClipSync(track_id, clip, start_time) => {
// Add MIDI clip to track and return the instance ID (positions already in beats) // Add MIDI clip to track and return the instance ID (positions already in beats)
let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { let result = match self.project.add_midi_clip_at(track_id, clip, start_time) {
Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())),
}; };
@ -3141,7 +3266,7 @@ impl Engine {
} }
/// Handle starting a recording /// Handle starting a recording
fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats) { fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats, force_takes: bool) {
use crate::io::WavWriter; use crate::io::WavWriter;
use std::env; use std::env;
@ -3212,8 +3337,29 @@ impl Engine {
self.recording_state = Some(recording_state); self.recording_state = Some(recording_state);
self.recording_progress_counter = 0; // Reset progress counter self.recording_progress_counter = 0; // Reset progress counter
// Arm cycle recording. `start_time` is the region start (the editor anchors a
// cycle recording there, for punch-in too), while capture actually begins at
// the current playhead — the gap between them is the lead pad that take 1 gets
// prepended as silence so it still spans the whole region.
let cycle_info = if self.loop_enabled {
self.loop_region.map(|(ls_beats, le_beats)| {
let (ls, le) = self.loop_bounds_samples(ls_beats, le_beats);
crate::audio::recording::CycleRecordInfo {
loop_start: ls_beats,
loop_len_beats: le_beats - ls_beats,
loop_len_frames: (le - ls).max(0) as usize,
lead_pad_frames: (self.playhead - ls).max(0) as usize,
wrap_count: 0,
force_takes,
}
})
} else {
None
};
// Set samples to skip (drained incrementally across callbacks) // Set samples to skip (drained incrementally across callbacks)
if let Some(recording) = &mut self.recording_state { if let Some(recording) = &mut self.recording_state {
recording.cycle = cycle_info;
recording.samples_to_skip = samples_in_buffer; recording.samples_to_skip = samples_in_buffer;
if self.debug_audio && samples_in_buffer > 0 { if self.debug_audio && samples_in_buffer > 0 {
eprintln!("[AUDIO DEBUG] Will skip {} stale samples from input buffer", samples_in_buffer); eprintln!("[AUDIO DEBUG] Will skip {} stale samples from input buffer", samples_in_buffer);
@ -3238,6 +3384,40 @@ impl Engine {
} }
} }
/// Write one cycle take to a temp WAV, add it to the audio pool, and return its pool index.
///
/// Mirrors what the single-recording path does with its own buffer: the pool file is backed by
/// the in-memory samples, and the temp WAV is written and then removed (the pool only reads the
/// path opportunistically, e.g. to keep original bytes on save).
fn write_take_to_pool(
&mut self,
samples: Vec<f32>,
sample_rate: u32,
channels: u32,
clip_id: ClipId,
take_index: usize,
) -> Result<usize, std::io::Error> {
use crate::io::WavWriter;
let path = std::env::temp_dir()
.join(format!("daw_take_{}_{}.wav", clip_id, take_index));
let mut writer = WavWriter::create(&path, sample_rate, channels)?;
writer.write_samples(&samples)?;
writer.finalize()?;
let pool_file = crate::audio::pool::AudioFile::with_format(
path.clone(),
samples,
channels,
sample_rate,
Some("wav".to_string()),
);
let pool_index = self.audio_pool.add_file(pool_file);
let _ = std::fs::remove_file(&path);
Ok(pool_index)
}
/// Handle stopping a recording /// Handle stopping a recording
fn handle_stop_recording(&mut self) { fn handle_stop_recording(&mut self) {
eprintln!("[STOP_RECORDING] handle_stop_recording called"); eprintln!("[STOP_RECORDING] handle_stop_recording called");
@ -3261,11 +3441,74 @@ impl Engine {
eprintln!("[STOP_RECORDING] Stopping recording for clip_id={}, track_id={}", clip_id, track_id); eprintln!("[STOP_RECORDING] Stopping recording for clip_id={}, track_id={}", clip_id, track_id);
// Slice cycle takes BEFORE finalize consumes the recording. `None` here means the
// transport never wrapped, which stays an ordinary single recording on the path below.
let cycle = recording.cycle;
let frames_per_peak = recording.frames_per_peak;
let cycle_takes = recording.slice_takes();
// Finalize the recording (flush buffers, close file, get waveform and audio data) // Finalize the recording (flush buffers, close file, get waveform and audio data)
let frames_recorded = recording.frames_written; let frames_recorded = recording.frames_written;
eprintln!("[STOP_RECORDING] Calling finalize() - frames_recorded={}", frames_recorded); eprintln!("[STOP_RECORDING] Calling finalize() - frames_recorded={}", frames_recorded);
match recording.finalize() { match recording.finalize() {
Ok((temp_file_path, waveform, audio_data)) => { Ok((temp_file_path, waveform, audio_data)) => {
// ---- Cycle recording: one take per pass, each spanning the whole region ----
if let (Some(takes), Some(cycle)) = (cycle_takes, cycle) {
eprintln!(
"[STOP_RECORDING] Cycle recording: {} wraps -> {} takes of {} frames",
cycle.wrap_count, takes.len(), cycle.loop_len_frames
);
let _ = std::fs::remove_file(&temp_file_path);
let mut pool_takes: Vec<(usize, Vec<crate::io::WaveformPeak>)> = Vec::new();
for (i, take) in takes.into_iter().enumerate() {
let peaks = crate::audio::recording::compute_peaks(
&take,
channels,
frames_per_peak,
);
match self.write_take_to_pool(take, sample_rate, channels, clip_id, i) {
Ok(pool_index) => pool_takes.push((pool_index, peaks)),
Err(e) => {
let _ = self.event_tx.push(AudioEvent::RecordingError(
format!("Failed to store take {}: {}", i + 1, e),
));
return;
}
}
}
// Point the engine's clip at the take the editor will make active (the last
// one, GarageBand-style) and stretch it to cover the whole cycle region —
// the clip was created at the punch-in point with zero length.
let loop_len_secs =
Seconds(cycle.loop_len_frames as f64 / sample_rate as f64);
if let Some(&(last_pool_index, _)) = pool_takes.last() {
if let Some(crate::audio::track::TrackNode::Audio(track)) =
self.project.get_track_mut(track_id)
{
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id)
{
clip.audio_pool_index = last_pool_index;
clip.internal_start = Seconds(0.0);
clip.internal_end = loop_len_secs;
clip.external_start = cycle.loop_start;
clip.external_duration = cycle.loop_len_beats;
}
}
self.refresh_clip_snapshot();
}
let _ = self.event_tx.push(AudioEvent::CycleRecordingStopped {
clip_id,
takes: pool_takes,
loop_start: cycle.loop_start,
loop_len_beats: cycle.loop_len_beats,
loop_len_seconds: loop_len_secs,
});
return;
}
eprintln!("[STOP_RECORDING] Finalize succeeded: {} frames written to {:?}, {} waveform peaks generated, {} samples in memory", eprintln!("[STOP_RECORDING] Finalize succeeded: {} frames written to {:?}, {} waveform peaks generated, {} samples in memory",
frames_recorded, temp_file_path, waveform.len(), audio_data.len()); frames_recorded, temp_file_path, waveform.len(), audio_data.len());
@ -3312,12 +3555,23 @@ impl Engine {
} }
/// Handle starting MIDI recording /// Handle starting MIDI recording
fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats, force_takes: bool) {
// Check if track exists and is a MIDI track // Check if track exists and is a MIDI track
if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) {
// Create MIDI recording state // Create MIDI recording state
let mut recording_state = MidiRecordingState::new(track_id, clip_id, start_time); let mut recording_state = MidiRecordingState::new(track_id, clip_id, start_time);
// Note the cycle region up front. `wrapped` stays false until a pass actually completes,
// so the clip bar still grows with the playhead through the first pass.
if self.loop_enabled {
if let Some((ls, le)) = self.loop_region {
if le > ls {
recording_state.cycle_loop_len = Some(le - ls);
recording_state.force_takes = force_takes;
}
}
}
// Inject any notes currently held on this track (pressed during count-in pre-roll) // Inject any notes currently held on this track (pressed during count-in pre-roll)
// so they start at t=0 of the recording rather than being lost // so they start at t=0 of the recording rather than being lost
if let Some(held) = self.midi_held_notes.get(&track_id) { if let Some(held) = self.midi_held_notes.get(&track_id) {
@ -3355,9 +3609,87 @@ impl Engine {
let clip_id = recording.clip_id; let clip_id = recording.clip_id;
let track_id = recording.track_id; let track_id = recording.track_id;
// ---- Separate takes: one pool clip per cycle pass ----
//
// Normally only when the transport actually wrapped: a recording that stopped inside the
// first pass is an ordinary single recording and falls through to the merge path below,
// just as it does for audio. `force_takes` overrides that, because the region already
// holds takes and this is another one however short it ran.
let takes_mode = self.cycle_midi_separate_takes
&& (recording.wrapped || recording.force_takes);
if let (true, Some(loop_len)) = (takes_mode, recording.cycle_loop_len) {
let loop_start = recording.start_time; // a cycle recording is anchored at the region
let passes = recording.pass_count();
let buckets = recording.notes_by_pass(passes);
eprintln!(
"[MIDI_RECORDING] Cycle recording (separate takes): {} passes",
passes
);
let mut clip_ids: Vec<MidiClipId> = Vec::with_capacity(buckets.len());
for (i, bucket) in buckets.iter().enumerate() {
// Pass 0 reuses the clip the recording started on; later passes get fresh ones.
let take_clip_id = if i == 0 {
clip_id
} else {
let id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
let clip = MidiClip::empty(id, loop_len, format!("Take {}", i + 1));
self.project.midi_clip_pool.add_existing_clip(clip);
id
};
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(take_clip_id) {
clip.events.clear();
clip.duration = loop_len;
for (start, note, velocity, duration) in bucket {
clip.events.push(MidiEvent::note_on(*start, 0, *note, *velocity));
clip.events.push(MidiEvent::note_off(*start + *duration, 0, *note, 64));
}
clip.events
.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
}
clip_ids.push(take_clip_id);
}
// Point the track's instance at the take the editor will make active (the last one,
// GarageBand-style) and stretch it over the region.
if let Some(&last) = clip_ids.last() {
if let Some(crate::audio::track::TrackNode::Midi(track)) =
self.project.get_track_mut(track_id)
{
if let Some(instance) =
track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id)
{
instance.clip_id = last;
instance.internal_start = Beats::ZERO;
instance.internal_end = loop_len;
instance.external_start = loop_start;
instance.external_duration = loop_len;
}
}
}
self.refresh_clip_snapshot();
let _ = self.event_tx.push(AudioEvent::MidiCycleRecordingStopped {
track_id,
clip_ids,
loop_start,
loop_len_beats: loop_len,
});
return;
}
let notes = recording.get_notes().to_vec(); let notes = recording.get_notes().to_vec();
let note_count = notes.len(); let note_count = notes.len();
let recording_duration = end_time - recording.start_time; // A cycle MIDI recording that came round is anchored at the region start and every pass
// overdubs into the same clip (MERGE), so the clip is exactly one region long — not
// however long the user held the record button, which would run past the loop end. One
// that stopped inside the first pass is just an ordinary recording.
let recording_duration = match recording.cycle_loop_len.filter(|_| recording.wrapped) {
Some(loop_len) => loop_len,
None => end_time - recording.start_time,
};
eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats", eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats",
clip_id, track_id, note_count, recording_duration.0); clip_id, track_id, note_count, recording_duration.0);
@ -3483,6 +3815,12 @@ impl EngineController {
let _ = self.command_tx.push(Command::SetLoopEnabled(enabled)); let _ = self.command_tx.push(Command::SetLoopEnabled(enabled));
} }
/// How a cycle MIDI recording treats its passes: merge into one clip (false, the default), or
/// one clip per pass (true) for the editor to fold into a take folder.
pub fn set_cycle_midi_separate_takes(&mut self, separate: bool) {
let _ = self.command_tx.push(Command::SetCycleMidiSeparateTakes(separate));
}
/// Stop playback and reset to beginning /// Stop playback and reset to beginning
pub fn stop(&mut self) { pub fn stop(&mut self) {
let _ = self.command_tx.push(Command::Stop); let _ = self.command_tx.push(Command::Stop);
@ -3490,7 +3828,7 @@ impl EngineController {
/// Seek to a specific position in seconds /// Seek to a specific position in seconds
pub fn seek(&mut self, seconds: Seconds) { pub fn seek(&mut self, seconds: Seconds) {
let _ = self.command_tx.push(Command::Seek(seconds.seconds_to_f64())); let _ = self.command_tx.push(Command::Seek(seconds));
} }
/// Set track volume (0.0 = silence, 1.0 = unity gain) /// Set track volume (0.0 = silence, 1.0 = unity gain)
@ -3522,22 +3860,22 @@ impl EngineController {
/// Move a clip to a new timeline position (changes external_start) /// Move a clip to a new timeline position (changes external_start)
pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: Beats) { pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: Beats) {
let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time.beats_to_f64())); let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time));
} }
/// Trim a clip's internal boundaries (changes which portion of source content is used) /// Trim a clip's internal content bounds — which portion of the source content plays.
/// This also resets external_duration to match internal duration (disables looping) ///
/// Trim a clip's internal content bounds. The units are content-domain and depend on the /// Collapses external_duration to the trimmed length (disables looping). The bounds are
/// track type: SECONDS for a sampled-audio clip, BEATS for a MIDI clip (see the TrimClip /// content-domain, which differs by clip kind, so they're passed as a [`TrimRange`] that names
/// handler). Left as raw f64 because a single newtype can't express both; callers pass the /// the domain: `Seconds` for sampled audio, `Beats` for MIDI. The engine rejects a range whose
/// clip's own `trim_start`/`trim_end`, which already match its content domain. /// domain doesn't match the track.
pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_internal_start: f64, new_internal_end: f64) { pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, range: crate::command::TrimRange) {
let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end)); let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, range));
} }
/// Extend or shrink a clip's external duration (enables looping if > internal duration) /// Extend or shrink a clip's external duration (enables looping if > internal duration)
pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: Beats) { pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: Beats) {
let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration.beats_to_f64())); let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration));
} }
/// Send a generic command to the audio thread /// Send a generic command to the audio thread
@ -3551,9 +3889,9 @@ impl EngineController {
} }
/// Get current playhead position in seconds /// Get current playhead position in seconds
pub fn get_playhead_seconds(&self) -> f64 { pub fn get_playhead_seconds(&self) -> Seconds {
let frames = self.playhead.load(Ordering::Relaxed); let frames = self.playhead.load(Ordering::Relaxed);
frames as f64 / self.sample_rate as f64 Seconds(frames as f64 / self.sample_rate as f64)
} }
/// Get the shared clip snapshot. The UI can read this each frame to display /// Get the shared clip snapshot. The UI can read this each frame to display
@ -3586,7 +3924,7 @@ impl EngineController {
/// Set metatrack time offset in seconds /// Set metatrack time offset in seconds
/// Positive = shift content later, negative = shift earlier /// Positive = shift content later, negative = shift earlier
pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) { pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) {
let _ = self.command_tx.push(Command::SetOffset(track_id, offset.seconds_to_f64())); let _ = self.command_tx.push(Command::SetOffset(track_id, offset));
} }
/// Set metatrack pitch shift in semitones (for future use) /// Set metatrack pitch shift in semitones (for future use)
@ -3596,12 +3934,12 @@ impl EngineController {
/// Set metatrack trim start in seconds /// Set metatrack trim start in seconds
pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: Seconds) { pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: Seconds) {
let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start.seconds_to_f64())); let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start));
} }
/// Set metatrack trim end in seconds (None = no end trim) /// Set metatrack trim end in seconds (None = no end trim)
pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option<Seconds>) { pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option<Seconds>) {
let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end.map(|s| s.seconds_to_f64()))); let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end));
} }
/// Create a new audio track /// Create a new audio track
@ -3755,23 +4093,22 @@ impl EngineController {
pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: Beats, duration: Beats) -> MidiClipId { pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: Beats, duration: Beats) -> MidiClipId {
// Peek at the next clip ID that will be used // Peek at the next clip ID that will be used
let clip_id = self.next_midi_clip_id.load(Ordering::Relaxed); let clip_id = self.next_midi_clip_id.load(Ordering::Relaxed);
let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time.beats_to_f64(), duration.beats_to_f64())); let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time, duration));
clip_id clip_id
} }
/// Add a MIDI note to a clip /// Add a MIDI note to a clip
pub fn add_midi_note(&mut self, track_id: TrackId, clip_id: MidiClipId, time_offset: Beats, note: u8, velocity: u8, duration: Beats) { pub fn add_midi_note(&mut self, track_id: TrackId, clip_id: MidiClipId, time_offset: Beats, note: u8, velocity: u8, duration: Beats) {
let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset.beats_to_f64(), note, velocity, duration.beats_to_f64())); let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset, note, velocity, duration));
} }
/// Add a pre-loaded MIDI clip to a track at the given timeline position (beats) /// Add a pre-loaded MIDI clip to a track at the given timeline position (beats)
pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) { pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) {
let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time.beats_to_f64())); let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time));
} }
/// Update all notes in a MIDI clip. Note tuples are (start [beats], note, velocity, duration [beats]). /// Update all notes in a MIDI clip. Note tuples are (start [beats], note, velocity, duration [beats]).
pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(Beats, u8, u8, Beats)>) { pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(Beats, u8, u8, Beats)>) {
let notes = notes.into_iter().map(|(t, n, v, d)| (t.beats_to_f64(), n, v, d.beats_to_f64())).collect();
let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes)); let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes));
} }
@ -3812,7 +4149,7 @@ impl EngineController {
curve: crate::audio::CurveType, curve: crate::audio::CurveType,
) { ) {
let _ = self.command_tx.push(Command::AddAutomationPoint( let _ = self.command_tx.push(Command::AddAutomationPoint(
track_id, lane_id, time.beats_to_f64(), value, curve, track_id, lane_id, time, value, curve,
)); ));
} }
@ -3825,7 +4162,7 @@ impl EngineController {
tolerance: Beats, tolerance: Beats,
) { ) {
let _ = self.command_tx.push(Command::RemoveAutomationPoint( let _ = self.command_tx.push(Command::RemoveAutomationPoint(
track_id, lane_id, time.beats_to_f64(), tolerance.beats_to_f64(), track_id, lane_id, time, tolerance,
)); ));
} }
@ -3864,13 +4201,13 @@ impl EngineController {
time: Beats, value: f32, interpolation: String, time: Beats, value: f32, interpolation: String,
ease_out: (f32, f32), ease_in: (f32, f32)) { ease_out: (f32, f32), ease_in: (f32, f32)) {
let _ = self.command_tx.push(Command::AutomationAddKeyframe( let _ = self.command_tx.push(Command::AutomationAddKeyframe(
track_id, node_id, time.beats_to_f64(), value, interpolation, ease_out, ease_in)); track_id, node_id, time, value, interpolation, ease_out, ease_in));
} }
/// Remove a keyframe from an AutomationInput node /// Remove a keyframe from an AutomationInput node
pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) { pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) {
let _ = self.command_tx.push(Command::AutomationRemoveKeyframe( let _ = self.command_tx.push(Command::AutomationRemoveKeyframe(
track_id, node_id, time.beats_to_f64())); track_id, node_id, time));
} }
/// Set the display name of an AutomationInput node /// Set the display name of an AutomationInput node
@ -3885,8 +4222,11 @@ impl EngineController {
} }
/// Start recording on a track /// Start recording on a track
pub fn start_recording(&mut self, track_id: TrackId, start_time: Beats) { /// `force_takes`: cut takes even if the transport never wraps, because the cycle region already
let _ = self.command_tx.push(Command::StartRecording(track_id, start_time)); /// holds takes and this recording is another one. Whether that's so is document state, so only
/// the editor can answer it.
pub fn start_recording(&mut self, track_id: TrackId, start_time: Beats, force_takes: bool) {
let _ = self.command_tx.push(Command::StartRecording(track_id, start_time, force_takes));
} }
/// Stop the current recording /// Stop the current recording
@ -3905,8 +4245,9 @@ impl EngineController {
} }
/// Start MIDI recording on a track /// Start MIDI recording on a track
pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { /// `force_takes`: see [`EngineController::start_recording`].
let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time)); pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats, force_takes: bool) {
let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time, force_takes));
} }
/// Stop the current MIDI recording /// Stop the current MIDI recording
@ -4406,7 +4747,7 @@ impl EngineController {
} }
/// Get file info from pool (duration, sample_rate, channels) /// Get file info from pool (duration, sample_rate, channels)
pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(f64, u32, u32), String> { pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(Seconds, u32, u32), String> {
// Send query // Send query
if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) { if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) {
return Err("Failed to send query - queue full".to_string()); return Err("Failed to send query - queue full".to_string());

View File

@ -211,6 +211,7 @@ pub fn render_to_memory(
settings.sample_rate, settings.sample_rate,
settings.channels, settings.channels,
false, false,
None, // export never runs with a recording in flight
); );
// Calculate how many samples we actually need from this chunk // Calculate how many samples we actually need from this chunk
@ -557,6 +558,7 @@ fn export_mp3<P: AsRef<Path>>(
settings.sample_rate, settings.sample_rate,
settings.channels, settings.channels,
false, false,
None, // export never runs with a recording in flight
); );
// Calculate how many samples we need from this chunk // Calculate how many samples we need from this chunk
@ -727,6 +729,7 @@ fn export_aac<P: AsRef<Path>>(
settings.sample_rate, settings.sample_rate,
settings.channels, settings.channels,
false, false,
None, // export never runs with a recording in flight
); );
// Calculate how many samples we need from this chunk // Calculate how many samples we need from this chunk

View File

@ -383,6 +383,7 @@ impl Project {
sample_rate: u32, sample_rate: u32,
channels: u32, channels: u32,
live_only: bool, live_only: bool,
recording_midi: Option<(TrackId, MidiClipId)>,
) { ) {
output.fill(0.0); output.fill(0.0);
@ -391,6 +392,7 @@ impl Project {
// Create initial render context // Create initial render context
let ctx = RenderContext { let ctx = RenderContext {
live_only, live_only,
recording_midi,
..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len()) ..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len())
}; };

View File

@ -5,6 +5,61 @@ use crate::time::{Beats, Seconds};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; 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 /// State of an active recording session
pub struct RecordingState { pub struct RecordingState {
/// Track being recorded to /// Track being recorded to
@ -35,6 +90,8 @@ pub struct RecordingState {
pub frames_per_peak: usize, pub frames_per_peak: usize,
/// All recorded audio data accumulated in memory (written to disk at finalization) /// All recorded audio data accumulated in memory (written to disk at finalization)
pub audio_data: Vec<f32>, pub audio_data: Vec<f32>,
/// Cycle-recording bookkeeping, when a cycle region was armed at record start.
pub cycle: Option<CycleRecordInfo>,
} }
impl RecordingState { impl RecordingState {
@ -69,9 +126,69 @@ impl RecordingState {
waveform_buffer: Vec::new(), waveform_buffer: Vec::new(),
frames_per_peak, frames_per_peak,
audio_data: Vec::new(), 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 /// Add samples to the accumulation buffer
/// Returns true if a flush occurred /// Returns true if a flush occurred
pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> { pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> {
@ -189,6 +306,26 @@ pub struct MidiRecordingState {
active_notes: HashMap<u8, ActiveMidiNote>, active_notes: HashMap<u8, ActiveMidiNote>,
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats /// Completed notes: (time_offset, note, velocity, duration) — all times in beats
pub completed_notes: Vec<(Beats, u8, u8, 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 { impl MidiRecordingState {
@ -199,9 +336,28 @@ impl MidiRecordingState {
start_time, start_time,
active_notes: HashMap::new(), active_notes: HashMap::new(),
completed_notes: Vec::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) { 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 }); self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time });
} }
@ -211,16 +367,38 @@ impl MidiRecordingState {
if absolute_time <= self.start_time { if absolute_time <= self.start_time {
return; return;
} }
let note_start = active_note.start_time.max(self.start_time); self.push_completed(&active_note, absolute_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)] { pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] {
&self.completed_notes &self.completed_notes
} }
@ -229,18 +407,28 @@ impl MidiRecordingState {
self.completed_notes.len() 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. /// 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)> { pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
let mut notes = self.completed_notes.clone(); let mut notes = self.completed_notes.clone();
for active in self.active_notes.values() { notes.extend(self.active_notes_with_provisional_end(current_time));
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 notes
} }
@ -252,13 +440,7 @@ impl MidiRecordingState {
let active_notes: Vec<_> = self.active_notes.drain().collect(); let active_notes: Vec<_> = self.active_notes.drain().collect();
for (_note_num, active_note) in active_notes { for (_note_num, active_note) in active_notes {
let note_start = active_note.start_time.max(self.start_time); self.push_completed(&active_note, end_time);
self.completed_notes.push((
note_start - self.start_time,
active_note.note,
active_note.velocity,
end_time - note_start,
));
} }
} }
@ -278,10 +460,239 @@ impl MidiRecordingState {
.map(|n| (n.note, n.velocity)) .map(|n| (n.note, n.velocity))
.collect(); .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.close_active_notes(region_end);
self.current_pass += 1;
for (note, velocity) in held { for (note, velocity) in held {
self.note_on(note, velocity, region_start); 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);
} }
} }

View File

@ -1,6 +1,6 @@
use super::automation::{AutomationLane, AutomationLaneId, ParameterId}; use super::automation::{AutomationLane, AutomationLaneId, ParameterId};
use super::clip::{AudioClipInstance, AudioClipInstanceId}; use super::clip::{AudioClipInstance, AudioClipInstanceId};
use super::midi::{MidiClipInstance, MidiClipInstanceId, MidiEvent}; use super::midi::{MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent};
use super::midi_pool::MidiClipPool; use super::midi_pool::MidiClipPool;
use super::node_graph::AudioGraph; use super::node_graph::AudioGraph;
use super::node_graph::nodes::{AudioInputNode, AudioOutputNode}; use super::node_graph::nodes::{AudioInputNode, AudioOutputNode};
@ -43,6 +43,13 @@ pub struct RenderContext<'a> {
/// Used after pause/stop to route note-off tails through the normal group hierarchy /// Used after pause/stop to route note-off tails through the normal group hierarchy
/// without re-triggering notes from clips at the paused position. /// without re-triggering notes from clips at the paused position.
pub live_only: bool, 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> { impl<'a> RenderContext<'a> {
@ -61,6 +68,7 @@ impl<'a> RenderContext<'a> {
buffer_size, buffer_size,
time_stretch: 1.0, time_stretch: 1.0,
live_only: false, live_only: false,
recording_midi: None,
} }
} }
@ -864,9 +872,21 @@ impl MidiTrack {
let playhead_beats = ctx.playhead_beats(); let playhead_beats = ctx.playhead_beats();
let buffer_end_beats = ctx.buffer_end_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 // Collect MIDI events from all clip instances that overlap with current beat range
let mut currently_active = HashSet::new(); let mut currently_active = HashSet::new();
for instance in &self.clip_instances { for instance in &self.clip_instances {
if muted_clip.is_some_and(|recording| instance.clip_id != recording) {
continue;
}
if instance.overlaps_range(playhead_beats, buffer_end_beats) { if instance.overlaps_range(playhead_beats, buffer_end_beats) {
currently_active.insert(instance.id); currently_active.insert(instance.id);
} }

View File

@ -1,3 +1,3 @@
pub mod types; pub mod types;
pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse}; pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse, TrimRange};

View File

@ -8,6 +8,21 @@ use crate::audio::node_graph::nodes::LoopMode;
use crate::io::WaveformPeak; use crate::io::WaveformPeak;
use crate::time::{Beats, Seconds}; 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 /// Commands sent from UI/control thread to audio thread
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Command { pub enum Command {
@ -19,7 +34,7 @@ pub enum Command {
/// Pause playback (maintains position) /// Pause playback (maintains position)
Pause, Pause,
/// Seek to a specific position in seconds /// Seek to a specific position in seconds
Seek(f64), Seek(Seconds),
// Track management commands // Track management commands
/// Set track volume (0.0 = silence, 1.0 = unity gain) /// Set track volume (0.0 = silence, 1.0 = unity gain)
@ -31,13 +46,12 @@ pub enum Command {
// Clip management commands // Clip management commands
/// Move a clip to a new timeline position (track_id, clip_id, new_external_start) /// Move a clip to a new timeline position (track_id, clip_id, new_external_start)
MoveClip(TrackId, ClipId, f64), MoveClip(TrackId, ClipId, Beats),
/// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end) /// Trim a clip's internal boundaries — which portion of the source content is used.
/// This changes which portion of the source content is used TrimClip(TrackId, ClipId, TrimRange),
TrimClip(TrackId, ClipId, f64, f64),
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
/// If duration > internal duration, the clip will loop /// If duration > internal duration, the clip will loop
ExtendClip(TrackId, ClipId, f64), ExtendClip(TrackId, ClipId, Beats),
// Metatrack management commands // Metatrack management commands
/// Create a new metatrack with a name and optional parent group /// Create a new metatrack with a name and optional parent group
@ -53,15 +67,15 @@ pub enum Command {
SetTimeStretch(TrackId, f32), SetTimeStretch(TrackId, f32),
/// Set metatrack time offset in seconds (track_id, offset) /// Set metatrack time offset in seconds (track_id, offset)
/// Positive = shift content later, negative = shift earlier /// Positive = shift content later, negative = shift earlier
SetOffset(TrackId, f64), SetOffset(TrackId, Seconds),
/// Set metatrack pitch shift in semitones (track_id, semitones) - for future use /// Set metatrack pitch shift in semitones (track_id, semitones) - for future use
SetPitchShift(TrackId, f32), SetPitchShift(TrackId, f32),
/// Set metatrack trim start in seconds (track_id, trim_start) /// Set metatrack trim start in seconds (track_id, trim_start)
/// Children won't hear content before this point /// Children won't hear content before this point
SetTrimStart(TrackId, f64), SetTrimStart(TrackId, Seconds),
/// Set metatrack trim end in seconds (track_id, trim_end) /// Set metatrack trim end in seconds (track_id, trim_end)
/// None means no end trim /// None means no end trim
SetTrimEnd(TrackId, Option<f64>), SetTrimEnd(TrackId, Option<Seconds>),
// Audio track commands // Audio track commands
/// Create a new audio track with a name and optional parent group /// Create a new audio track with a name and optional parent group
@ -80,14 +94,14 @@ pub enum Command {
/// Add a MIDI clip to the pool without placing it on a track /// Add a MIDI clip to the pool without placing it on a track
AddMidiClipToPool(MidiClip), AddMidiClipToPool(MidiClip),
/// Create a new MIDI clip on a track (track_id, start_time, duration) /// Create a new MIDI clip on a track (track_id, start_time, duration)
CreateMidiClip(TrackId, f64, f64), CreateMidiClip(TrackId, Beats, Beats),
/// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration) /// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration)
AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64), AddMidiNote(TrackId, MidiClipId, Beats, u8, u8, Beats),
/// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time) /// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time)
AddLoadedMidiClip(TrackId, MidiClip, f64), AddLoadedMidiClip(TrackId, MidiClip, Beats),
/// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>) /// 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 /// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes
UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>), UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(Beats, u8, u8, Beats)>),
/// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing. /// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing.
UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>), UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>),
/// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support /// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support
@ -103,9 +117,9 @@ pub enum Command {
/// Create a new automation lane on a track (track_id, parameter_id) /// Create a new automation lane on a track (track_id, parameter_id)
CreateAutomationLane(TrackId, ParameterId), CreateAutomationLane(TrackId, ParameterId),
/// Add an automation point to a lane (track_id, lane_id, time, value, curve) /// Add an automation point to a lane (track_id, lane_id, time, value, curve)
AddAutomationPoint(TrackId, AutomationLaneId, f64, f32, CurveType), AddAutomationPoint(TrackId, AutomationLaneId, Beats, f32, CurveType),
/// Remove an automation point at a specific time (track_id, lane_id, time, tolerance) /// Remove an automation point at a specific time (track_id, lane_id, time, tolerance)
RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64), RemoveAutomationPoint(TrackId, AutomationLaneId, Beats, Beats),
/// Clear all automation points from a lane (track_id, lane_id) /// Clear all automation points from a lane (track_id, lane_id)
ClearAutomationLane(TrackId, AutomationLaneId), ClearAutomationLane(TrackId, AutomationLaneId),
/// Remove an automation lane (track_id, lane_id) /// Remove an automation lane (track_id, lane_id)
@ -119,10 +133,18 @@ pub enum Command {
SetLoopRegion(Option<(Beats, Beats)>), SetLoopRegion(Option<(Beats, Beats)>),
/// Enable/disable wrapping at the cycle region's end. /// Enable/disable wrapping at the cycle region's end.
SetLoopEnabled(bool), 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 // Recording commands
/// Start recording on a track (track_id, start_time) /// Start recording on a track (track_id, start_time)
StartRecording(TrackId, Beats), /// (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),
/// Stop the current recording /// Stop the current recording
StopRecording, StopRecording,
/// Pause the current recording /// Pause the current recording
@ -132,7 +154,8 @@ pub enum Command {
// MIDI Recording commands // MIDI Recording commands
/// Start MIDI recording on a track (track_id, clip_id, start_time) /// Start MIDI recording on a track (track_id, clip_id, start_time)
StartMidiRecording(TrackId, MidiClipId, Beats), /// (track, clip, start_time, force_takes — see [`Command::StartRecording`])
StartMidiRecording(TrackId, MidiClipId, Beats, bool),
/// Stop the current MIDI recording /// Stop the current MIDI recording
StopMidiRecording, StopMidiRecording,
@ -244,9 +267,9 @@ pub enum Command {
// Automation Input Node commands // Automation Input Node commands
/// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in) /// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in)
AutomationAddKeyframe(TrackId, u32, f64, f32, String, (f32, f32), (f32, f32)), AutomationAddKeyframe(TrackId, u32, Beats, f32, String, (f32, f32), (f32, f32)),
/// Remove a keyframe from an AutomationInput node (track_id, node_id, time) /// Remove a keyframe from an AutomationInput node (track_id, node_id, time)
AutomationRemoveKeyframe(TrackId, u32, f64), AutomationRemoveKeyframe(TrackId, u32, Beats),
/// Set the display name of an AutomationInput node (track_id, node_id, name) /// Set the display name of an AutomationInput node (track_id, node_id, name)
AutomationSetName(TrackId, u32, String), AutomationSetName(TrackId, u32, String),
@ -278,7 +301,7 @@ pub enum Command {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AudioEvent { pub enum AudioEvent {
/// Current playback position in seconds /// Current playback position in seconds
PlaybackPosition(f64), PlaybackPosition(Seconds),
/// Playback has stopped (reached end of audio) /// Playback has stopped (reached end of audio)
PlaybackStopped, PlaybackStopped,
/// Audio buffer underrun detected /// Audio buffer underrun detected
@ -299,6 +322,35 @@ pub enum AudioEvent {
RecordingProgress(ClipId, Seconds), RecordingProgress(ClipId, Seconds),
/// Recording stopped (clip_id, pool_index, waveform) /// Recording stopped (clip_id, pool_index, waveform)
RecordingStopped(ClipId, usize, Vec<WaveformPeak>), RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
/// 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) /// Recording error (error_message)
RecordingError(String), RecordingError(String),
/// MIDI recording stopped (track_id, clip_id, note_count) /// MIDI recording stopped (track_id, clip_id, note_count)
@ -349,7 +401,7 @@ pub enum AudioEvent {
WaveformChunksReady { WaveformChunksReady {
pool_index: usize, pool_index: usize,
detail_level: u8, detail_level: u8,
chunks: Vec<(u32, (f64, f64), Vec<WaveformPeak>)>, chunks: Vec<(u32, (Seconds, Seconds), Vec<WaveformPeak>)>,
}, },
/// An audio file has been imported and is ready for playback. /// An audio file has been imported and is ready for playback.
@ -360,7 +412,7 @@ pub enum AudioEvent {
path: String, path: String,
channels: u32, channels: u32,
sample_rate: u32, sample_rate: u32,
duration: f64, duration: Seconds,
format: crate::io::audio_file::AudioFormat, format: crate::io::audio_file::AudioFormat,
}, },
@ -434,7 +486,7 @@ pub enum Query {
/// Export audio to file (settings, output_path) /// Export audio to file (settings, output_path)
ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), ExportAudio(crate::audio::ExportSettings, std::path::PathBuf),
/// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID /// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID
AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64), AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, Beats),
/// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID /// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID
/// The clip must already exist in the MidiClipPool /// The clip must already exist in the MidiClipPool
AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance),
@ -479,16 +531,21 @@ pub struct OscilloscopeData {
} }
/// MIDI clip data for serialization /// 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)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClipData { pub struct MidiClipData {
pub duration: f64, /// MIDI content length is musical, so beats.
pub duration: Beats,
pub events: Vec<crate::audio::midi::MidiEvent>, pub events: Vec<crate::audio::midi::MidiEvent>,
} }
/// Automation keyframe data for serialization /// Automation keyframe data for serialization
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AutomationKeyframeData { pub struct AutomationKeyframeData {
pub time: f64, /// Automation x-axes are all beats.
pub time: Beats,
pub value: f32, pub value: f32,
pub interpolation: String, pub interpolation: String,
pub ease_out: (f32, f32), pub ease_out: (f32, f32),
@ -525,7 +582,7 @@ pub enum QueryResponse {
/// Pool waveform data /// Pool waveform data
PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>), PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>),
/// Pool file info (duration, sample_rate, channels) /// Pool file info (duration, sample_rate, channels)
PoolFileInfo(Result<(f64, u32, u32), String>), PoolFileInfo(Result<(Seconds, u32, u32), String>),
/// Audio exported /// Audio exported
AudioExported(Result<(), String>), AudioExported(Result<(), String>),
/// MIDI clip instance added (returns instance ID) /// MIDI clip instance added (returns instance ID)

View File

@ -20,7 +20,7 @@ pub use audio::{
TrackNode, TrackNode,
}; };
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode}; pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
pub use time::{Beats, Seconds}; pub use time::{Beats, ContentTime, Seconds};
pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack}; pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
pub use command::{AudioEvent, Command, OscilloscopeData}; pub use command::{AudioEvent, Command, OscilloscopeData};
pub use command::types::AutomationKeyframeData; pub use command::types::AutomationKeyframeData;

View File

@ -16,6 +16,50 @@ pub struct Beats(pub f64);
#[serde(transparent)] #[serde(transparent)]
pub struct Seconds(pub f64); 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 { impl Beats {
pub const ZERO: Self = Self(0.0); pub const ZERO: Self = Self(0.0);

View File

@ -556,7 +556,7 @@ pub fn run_tui(
while let Ok(event) = rx.pop() { while let Ok(event) = rx.pop() {
match event { match event {
AudioEvent::PlaybackPosition(pos) => { AudioEvent::PlaybackPosition(pos) => {
app.update_playback_position(pos); app.update_playback_position(pos.seconds_to_f64());
} }
AudioEvent::PlaybackStopped => { AudioEvent::PlaybackStopped => {
app.set_playing(false); app.set_playing(false);

View File

@ -47,6 +47,113 @@ pub struct BackendContext<'a> {
// Future: pub video_controller: Option<&'a mut VideoController>, // 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 /// Action trait for undo/redo operations
/// ///
/// Each action must be able to execute (apply changes) and rollback (undo changes). /// Each action must be able to execute (apply changes) and rollback (undo changes).

View File

@ -89,10 +89,11 @@ impl Action for AddClipInstanceAction {
// `get_clip_duration` is the content length in seconds; the placement span // `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 // must be beats (the timeline is beats-domain), so convert via the clip's
// typed helper rather than treating the seconds span as beats. // typed helper rather than treating the seconds span as beats.
let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id) // 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)
.ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?; .ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?;
let effective_duration = self.clip_instance let effective_duration = self.clip_instance
.effective_duration_beats(clip_duration, document.tempo_map()); .effective_duration_beats(clip_content, document.tempo_map());
// Auto-adjust position for audio/video layers to avoid overlaps // Auto-adjust position for audio/video layers to avoid overlaps
let adjusted_start = document.find_nearest_valid_position( let adjusted_start = document.find_nearest_valid_position(
@ -196,113 +197,23 @@ impl Action for AddClipInstanceAction {
return Ok(()); return Ok(());
} }
// Look up the clip from the document // Add via the shared BackendContext helper — the same one SetActiveTakeAction uses, so
let clip = document // the trim/duration conversions (and take-folder resolution) live in exactly one place.
.get_audio_clip(&self.clip_instance.clip_id) if let Some((track_id, backend_id)) =
.ok_or_else(|| "Audio clip not found".to_string())?; backend.add_clip_instance(document, &self.layer_id, &self.clip_instance)?
{
// Look up backend track ID from layer mapping self.backend_track_id = Some(track_id);
let backend_track_id = backend match backend_id {
.layer_to_track_map crate::action::BackendClipInstanceId::Midi(id) => {
.get(&self.layer_id) self.backend_midi_instance_id = Some(id)
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; }
crate::action::BackendClipInstanceId::Audio(id) => {
// Get audio controller self.backend_audio_instance_id = Some(id)
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
});
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(())
}
} }
Ok(())
} }
fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> { fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> {

View File

@ -0,0 +1,141 @@
//! 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)
}
}

View File

@ -91,7 +91,7 @@ impl Action for LoopClipInstancesAction {
impl LoopClipInstancesAction { impl LoopClipInstancesAction {
fn sync_backend(&self, backend: &mut crate::action::BackendContext, document: &Document, rollback: bool) -> Result<(), String> { fn sync_backend(&self, backend: &mut crate::action::BackendContext, document: &Document, rollback: bool) -> Result<(), String> {
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
let controller = match backend.audio_controller.as_mut() { let controller = match backend.audio_controller.as_mut() {
Some(c) => c, Some(c) => c,
@ -128,26 +128,22 @@ impl LoopClipInstancesAction {
(new_dur, new_lb) (new_dur, new_lb)
}; };
let content_window = { // Natural content length as a beats span (the fallback when no explicit
let trim_end = instance.trim_end.unwrap_or(clip.content_duration().native()); // timeline_duration is set). Resolved in the clip's own domain, so MIDI's beats
(trim_end - instance.trim_start).max(0.0) // seconds // content carries over directly rather than being read as seconds.
}; let content_window_beats = instance.effective_duration_beats(
// Natural content length as a beats span at the clip's start (the clip.content_duration(),
// fallback when no explicit timeline_duration is set). document.tempo_map(),
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 right_duration = target_duration.unwrap_or(content_window_beats);
let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO); let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO);
let external_duration = left_duration + right_duration; let external_duration = left_duration + right_duration;
let external_start = instance.timeline_start - left_duration; let external_start = instance.timeline_start - left_duration;
let get_backend_clip_id = |inst_id: &Uuid| -> Result<u32, String> { let get_backend_clip_id = |inst_id: &Uuid| -> Result<u32, String> {
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { midi_clip_id } => Ok(*midi_clip_id), ResolvedContent::Midi { midi_clip_id } => Ok(*midi_clip_id),
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
let backend_id = backend.clip_instance_to_backend_map.get(inst_id) let backend_id = backend.clip_instance_to_backend_map.get(inst_id)
.ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?; .ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?;
match backend_id { match backend_id {
@ -155,7 +151,7 @@ impl LoopClipInstancesAction {
_ => Err("Expected audio instance ID for sampled clip".to_string()), _ => Err("Expected audio instance ID for sampled clip".to_string()),
} }
} }
AudioClipType::Recording => Err("Cannot sync recording clip".to_string()), ResolvedContent::Recording => Err("Cannot sync recording clip".to_string()),
} }
}; };

View File

@ -0,0 +1,419 @@
//! 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");
}
}

View File

@ -15,6 +15,9 @@ pub mod reorder_clip_instances;
pub mod paint_bucket; pub mod paint_bucket;
pub mod remove_effect; pub mod remove_effect;
pub mod set_cycle_region; 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_document_properties;
pub mod set_instance_properties; pub mod set_instance_properties;
pub mod set_layer_properties; pub mod set_layer_properties;
@ -52,6 +55,9 @@ pub mod resize_text_box;
pub use add_clip_instance::AddClipInstanceAction; pub use add_clip_instance::AddClipInstanceAction;
pub use set_cycle_region::SetCycleRegionAction; 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_effect::AddEffectAction;
pub use add_layer::AddLayerAction; pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction; pub use add_shape::AddShapeAction;

View File

@ -105,7 +105,7 @@ impl Action for MoveClipInstancesAction {
let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| { let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| {
let inst = clip_instances.iter().find(|ci| &ci.id == id)?; let inst = clip_instances.iter().find(|ci| &ci.id == id)?;
let dur = document.get_clip_duration(&inst.clip_id)?; let dur = document.clip_trim_duration(&inst.clip_id)?;
let eff = inst.effective_duration_beats(dur, document.tempo_map()); let eff = inst.effective_duration_beats(dur, document.tempo_map());
Some((*id, *old_start, eff)) Some((*id, *old_start, eff))
}).collect(); }).collect();
@ -190,7 +190,7 @@ impl Action for MoveClipInstancesAction {
fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
// Get audio controller // Get audio controller
let controller = match backend.audio_controller.as_mut() { let controller = match backend.audio_controller.as_mut() {
@ -211,8 +211,9 @@ impl Action for MoveClipInstancesAction {
// Check if this clip has a metatrack // Check if this clip has a metatrack
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_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(*new_start)); controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start));
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::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())));
} }
} }
} }
@ -246,12 +247,12 @@ impl Action for MoveClipInstancesAction {
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
// Handle move based on clip type // Handle move based on clip type
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
// For MIDI: move_clip expects the pool clip ID // For MIDI: move_clip expects the pool clip ID
controller.move_clip(*track_id, *midi_clip_id, *new_start); controller.move_clip(*track_id, *midi_clip_id, *new_start);
} }
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
// For sampled audio: move_clip expects the instance ID // For sampled audio: move_clip expects the instance ID
let backend_instance_id = backend.clip_instance_to_backend_map.get(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))?; .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
@ -263,7 +264,7 @@ impl Action for MoveClipInstancesAction {
_ => return Err("Expected audio instance ID for sampled clip".to_string()), _ => return Err("Expected audio instance ID for sampled clip".to_string()),
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording clips cannot be moved - skip // Recording clips cannot be moved - skip
} }
} }
@ -275,7 +276,7 @@ impl Action for MoveClipInstancesAction {
fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
// Get audio controller // Get audio controller
let controller = match backend.audio_controller.as_mut() { let controller = match backend.audio_controller.as_mut() {
@ -295,8 +296,9 @@ impl Action for MoveClipInstancesAction {
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { 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) { 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)); controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start));
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::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())));
} }
} }
} }
@ -330,12 +332,12 @@ impl Action for MoveClipInstancesAction {
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
// Handle move based on clip type (restore old position) // Handle move based on clip type (restore old position)
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
// For MIDI: move_clip expects the pool clip ID // For MIDI: move_clip expects the pool clip ID
controller.move_clip(*track_id, *midi_clip_id, *old_start); controller.move_clip(*track_id, *midi_clip_id, *old_start);
} }
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
// For sampled audio: move_clip expects the instance ID // For sampled audio: move_clip expects the instance ID
let backend_instance_id = backend.clip_instance_to_backend_map.get(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))?; .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
@ -347,7 +349,7 @@ impl Action for MoveClipInstancesAction {
_ => return Err("Expected audio instance ID for sampled clip".to_string()), _ => return Err("Expected audio instance ID for sampled clip".to_string()),
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording clips cannot be moved - skip // Recording clips cannot be moved - skip
} }
} }

View File

@ -138,92 +138,23 @@ impl Action for RemoveClipInstancesAction {
backend: &mut BackendContext, backend: &mut BackendContext,
document: &Document, document: &Document,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::clip::AudioClipType; if backend.audio_controller.is_none() {
return Ok(());
}
let controller = match backend.audio_controller.as_mut() { // Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same
Some(c) => c, // helper the add and split actions use, so the trim/duration conversions (and take-folder
None => return Ok(()), // 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);
// Re-add clips that were removed from backend for (layer_id, instance) in &saved {
for (layer_id, instance) in &self.saved { if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) {
let layer = match document.get_layer(layer_id) {
Some(l) => l,
None => continue,
};
if !matches!(layer, AnyLayer::Audio(_)) {
continue; continue;
} }
// A missing track/clip just means there's nothing to restore on the backend.
let track_id = match backend.layer_to_track_map.get(layer_id) { let _ = backend.add_clip_instance(document, layer_id, instance);
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 // Clear saved backend IDs
self.saved_backend_ids.clear(); self.saved_backend_ids.clear();

View File

@ -0,0 +1,117 @@
//! 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)
}
}

View File

@ -7,6 +7,7 @@ use crate::action::{Action, BackendContext};
use crate::clip::ClipInstance; use crate::clip::ClipInstance;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use daw_backend::ContentTime;
use uuid::Uuid; use uuid::Uuid;
/// Action that splits a clip instance at a specific timeline position /// Action that splits a clip instance at a specific timeline position
@ -25,7 +26,7 @@ pub struct SplitClipInstanceAction {
// Stored during execute for rollback // Stored during execute for rollback
/// Original trim_end value of the left (original) instance /// Original trim_end value of the left (original) instance
original_trim_end: Option<f64>, original_trim_end: Option<ContentTime>,
/// Original timeline_duration value of the left (original) instance (beats) /// Original timeline_duration value of the left (original) instance (beats)
original_timeline_duration: Option<daw_backend::Beats>, original_timeline_duration: Option<daw_backend::Beats>,
/// ID of the new (right) instance created by the split /// ID of the new (right) instance created by the split
@ -122,13 +123,15 @@ impl Action for SplitClipInstanceAction {
.find(|ci| ci.id == self.instance_id) .find(|ci| ci.id == self.instance_id)
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
// Get the clip's duration // The clip's content duration in its OWN domain — seconds for audio/video/vector, beats for
let clip_duration = document // MIDI. All the content math below is trim-domain, so it has to be done in whichever domain
.get_clip_duration(&instance.clip_id) // 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)
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
// Calculate the effective duration and timeline end (both in beats) // Calculate the effective duration and timeline end (both in beats)
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let effective_duration = instance.effective_duration(trim_duration, document.tempo_map());
let timeline_end = instance.timeline_start + effective_duration; let timeline_end = instance.timeline_start + effective_duration;
// Validate: split_time must be strictly within the clip's timeline span // Validate: split_time must be strictly within the clip's timeline span
@ -146,27 +149,30 @@ impl Action for SplitClipInstanceAction {
self.original_trim_end = instance.trim_end; self.original_trim_end = instance.trim_end;
self.original_timeline_duration = instance.timeline_duration; 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 is_looping = instance.timeline_duration.is_some();
let content_duration = instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()) - instance.trim_start; let content_duration = ContentTime(instance.content_window(trim_duration).native());
// Timeline split point (beats). // Timeline split point (beats).
let time_into_clip = self.split_time - instance.timeline_start; let time_into_clip = self.split_time - instance.timeline_start;
let left_duration = time_into_clip; let left_duration = time_into_clip;
let right_duration = effective_duration - left_duration; let right_duration = effective_duration - left_duration;
// How far the split lands into the clip's *content* (seconds, trim domain). // 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.
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let time_into_clip_secs = (tempo_map.beats_to_seconds(self.split_time) let time_into_content = ContentTime(match trim_duration {
- tempo_map.beats_to_seconds(instance.timeline_start)).seconds_to_f64(); 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(),
});
// Calculate content split time (seconds) // Calculate the content split point (content domain).
let content_split_time = if is_looping { let content_split_time = if is_looping && content_duration > ContentTime::ZERO {
// For looping clips, wrap around content // For looping clips, wrap around content
instance.trim_start + (time_into_clip_secs % content_duration) instance.trim_start + (time_into_content % content_duration)
} else { } else {
instance.trim_start + time_into_clip_secs instance.trim_start + time_into_content
}; };
// Clone the instance for the right side // Clone the instance for the right side
@ -357,119 +363,63 @@ impl Action for SplitClipInstanceAction {
.get_audio_clip(&new_instance.clip_id) .get_audio_clip(&new_instance.clip_id)
.ok_or_else(|| "Audio clip not found".to_string())?; .ok_or_else(|| "Audio clip not found".to_string())?;
// Look up backend track ID from layer mapping use crate::clip::ResolvedContent;
let backend_track_id = backend 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
.layer_to_track_map .layer_to_track_map
.get(&self.layer_id) .get(&self.layer_id)
.ok_or_else(|| format!("Layer {} not mapped to backend track", 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();
// Get audio controller {
let controller = backend let controller = backend
.audio_controller .audio_controller
.as_mut() .as_mut()
.ok_or_else(|| "Audio controller not available".to_string())?; .ok_or_else(|| "Audio controller not available".to_string())?;
match left_backend_id {
// Handle different clip types Some(crate::action::BackendClipInstanceId::Midi(id))
use crate::clip::AudioClipType; | Some(crate::action::BackendClipInstanceId::Audio(id)) => {
match &clip.clip_type { controller.trim_clip(backend_track_id, id, left_trim);
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);
} }
None => {}
// 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());
// 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())
} }
} }
// 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)
}
crate::action::BackendClipInstanceId::Audio(id) => {
self.backend_audio_instance_id = Some(id)
}
}
}
Ok(())
} }
fn rollback_backend( fn rollback_backend(
@ -499,26 +449,28 @@ impl Action for SplitClipInstanceAction {
if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) { 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) { if let Some(clip) = document.get_audio_clip(&instance.clip_id) {
let orig_internal_start = instance.trim_start; let orig_internal_start = instance.trim_start;
let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native()); let orig_internal_end = self
.original_trim_end
.unwrap_or(ContentTime(clip.content_duration().native()));
// Restore based on clip type // Restore based on clip type
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { .. } => { ResolvedContent::Midi { .. } => {
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
backend.clip_instance_to_backend_map.get(&self.instance_id) backend.clip_instance_to_backend_map.get(&self.instance_id)
{ {
controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end); controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
} }
} }
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) = if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
backend.clip_instance_to_backend_map.get(&self.instance_id) backend.clip_instance_to_backend_map.get(&self.instance_id)
{ {
controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end); controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording clips - nothing to rollback // Recording clips - nothing to rollback
} }
} }
@ -554,8 +506,8 @@ mod tests {
// Create a clip instance at timeline 0, with trim 0-10 (10 seconds) // Create a clip instance at timeline 0, with trim 0-10 (10 seconds)
let mut clip_instance = ClipInstance::new(clip_id); let mut clip_instance = ClipInstance::new(clip_id);
clip_instance.timeline_start = daw_backend::Beats::ZERO; clip_instance.timeline_start = daw_backend::Beats::ZERO;
clip_instance.trim_start = 0.0; clip_instance.trim_start = ContentTime::ZERO;
clip_instance.trim_end = Some(10.0); clip_instance.trim_end = Some(ContentTime(10.0));
let instance_id = clip_instance.id; let instance_id = clip_instance.id;
vector_layer.clip_instances.push(clip_instance); vector_layer.clip_instances.push(clip_instance);
@ -575,4 +527,42 @@ mod tests {
let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0)); let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0));
assert_eq!(action.description(), "Split clip instance"); 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");
}
} }

View File

@ -6,7 +6,7 @@ use crate::action::Action;
use crate::clip::ClipInstance; use crate::clip::ClipInstance;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use daw_backend::{Beats, Seconds}; use daw_backend::{Beats, ContentTime, Seconds};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
@ -32,15 +32,57 @@ pub struct TrimClipInstancesAction {
pub struct TrimData { pub struct TrimData {
/// For TrimLeft: trim_start value /// For TrimLeft: trim_start value
/// For TrimRight: trim_end value (Option because it can be None) /// For TrimRight: trim_end value (Option because it can be None)
pub trim_value: Option<f64>, ///
/// 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>,
/// For TrimLeft: timeline_start value (where the clip appears on timeline, beats) /// For TrimLeft: timeline_start value (where the clip appears on timeline, beats)
/// For TrimRight: unused (None) /// For TrimRight: unused (None)
pub timeline_start: Option<Beats>, 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 { impl TrimData {
/// Create TrimData for left trim /// Create TrimData for left trim
pub fn left(trim_start: f64, timeline_start: Beats) -> Self { pub fn left(trim_start: ContentTime, timeline_start: Beats) -> Self {
Self { Self {
trim_value: Some(trim_start), trim_value: Some(trim_start),
timeline_start: Some(timeline_start), timeline_start: Some(timeline_start),
@ -48,7 +90,7 @@ impl TrimData {
} }
/// Create TrimData for right trim /// Create TrimData for right trim
pub fn right(trim_end: Option<f64>) -> Self { pub fn right(trim_end: Option<ContentTime>) -> Self {
Self { Self {
trim_value: trim_end, trim_value: trim_end,
timeline_start: None, timeline_start: None,
@ -192,7 +234,8 @@ impl Action for TrimClipInstancesAction {
.find(|ci| &ci.id == instance_id) .find(|ci| &ci.id == instance_id)
.ok_or_else(|| format!("Instance {} not found", instance_id))?; .ok_or_else(|| format!("Instance {} not found", instance_id))?;
let clip_duration = document.get_clip_duration(&instance.clip_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)
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
let mut clamped_new = new.clone(); let mut clamped_new = new.clone();
@ -204,23 +247,34 @@ impl Action for TrimClipInstancesAction {
{ {
// If extending to the left (new_trim < old_trim) // If extending to the left (new_trim < old_trim)
if should_validate && new_trim < old_trim { if should_validate && new_trim < old_trim {
// 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,
).seconds_to_f64();
// Calculate how much we want to extend (content seconds)
let desired_extend = old_trim - new_trim;
// 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 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,
instance.timeline_start,
tempo_map,
);
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,
);
let clamped_timeline_start = tempo_map let clamped_timeline_start = tempo_map
.seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - Seconds(actual_extend)) .seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - shift)
.max(Beats::ZERO); .max(Beats::ZERO);
clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start); clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
@ -228,36 +282,39 @@ impl Action for TrimClipInstancesAction {
} }
} }
TrimType::TrimRight => { TrimType::TrimRight => {
let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64()); let content_end = ContentTime(clip_content.native());
let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64()); let old_trim_end = old.trim_value.unwrap_or(content_end);
let new_trim_end = new.trim_value.unwrap_or(content_end);
// If extending to the right (new_trim_end > old_trim_end) // If extending to the right (new_trim_end > old_trim_end)
if should_validate && new_trim_end > old_trim_end { if should_validate && new_trim_end > old_trim_end {
let tempo_map = document.tempo_map(); 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;
// Max rightward extension as content seconds (the gap's wall-clock span). // How long the clip currently occupies the timeline, in beats. Resolved
let max_extend_secs = document.find_max_trim_extend_right( // in the clip's own domain, so a MIDI clip's beats content isn't run
layer_id, // through the seconds→beats conversion a second time.
instance_id, 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,
instance.timeline_start, instance.timeline_start,
current_effective_duration, tempo_map,
).seconds_to_f64(); );
// Calculate how much we want to extend (content seconds)
let desired_extend = new_trim_end - old_trim_end; 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; let clamped_trim_end = old_trim_end + actual_extend;
// Don't exceed clip duration // Don't exceed the clip's content.
let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64()); let final_trim_end = clamped_trim_end.min(content_end);
clamped_new = TrimData::right(Some(final_trim_end)); clamped_new = TrimData::right(Some(final_trim_end));
} }
@ -366,7 +423,7 @@ impl Action for TrimClipInstancesAction {
fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
// Get audio controller // Get audio controller
let controller = match backend.audio_controller.as_mut() { let controller = match backend.audio_controller.as_mut() {
@ -387,8 +444,9 @@ impl Action for TrimClipInstancesAction {
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
// Instance already has new values after execute() // Instance already has new values after execute()
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::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())));
} }
} }
} }
@ -424,27 +482,29 @@ impl Action for TrimClipInstancesAction {
// Calculate new internal_start and internal_end for backend // Calculate new internal_start and internal_end for backend
// Note: instance already has the new trim values after execute() // Note: instance already has the new trim values after execute()
let internal_start = instance.trim_start; let internal_start = instance.trim_start;
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); let internal_end = instance
.trim_end
.unwrap_or(ContentTime(clip.content_duration().native()));
// Handle trim based on clip type // Handle trim based on clip type
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
// For MIDI: trim_clip expects the pool clip ID // For MIDI: trim_clip expects the pool clip ID
controller.trim_clip(*track_id, *midi_clip_id, internal_start, internal_end); controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end));
} }
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
// For sampled audio: trim_clip expects the instance ID // For sampled audio: trim_clip expects the instance ID
let backend_instance_id = backend.clip_instance_to_backend_map.get(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))?; .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
match backend_instance_id { match backend_instance_id {
crate::action::BackendClipInstanceId::Audio(audio_id) => { crate::action::BackendClipInstanceId::Audio(audio_id) => {
controller.trim_clip(*track_id, *audio_id, internal_start, internal_end); controller.trim_clip(*track_id, *audio_id, clip.trim_range(internal_start, internal_end));
} }
_ => return Err("Expected audio instance ID for sampled clip".to_string()), _ => return Err("Expected audio instance ID for sampled clip".to_string()),
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording clips cannot be trimmed - skip // Recording clips cannot be trimmed - skip
} }
} }
@ -456,7 +516,7 @@ impl Action for TrimClipInstancesAction {
fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
// Get audio controller // Get audio controller
let controller = match backend.audio_controller.as_mut() { let controller = match backend.audio_controller.as_mut() {
@ -477,8 +537,9 @@ impl Action for TrimClipInstancesAction {
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
// Instance already has old values after rollback() // Instance already has old values after rollback()
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::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())));
} }
} }
} }
@ -512,34 +573,35 @@ impl Action for TrimClipInstancesAction {
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
// Calculate old internal_start and internal_end for backend // Calculate old internal_start and internal_end for backend
let content_end = ContentTime(clip.content_duration().native());
let internal_start = match trim_type { let internal_start = match trim_type {
TrimType::TrimLeft => old.trim_value.unwrap_or(0.0), TrimType::TrimLeft => old.trim_value.unwrap_or(ContentTime::ZERO),
TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed
}; };
let internal_end = match trim_type { let internal_end = match trim_type {
TrimType::TrimLeft => instance.trim_end.unwrap_or(clip.content_duration().native()), // trim_end wasn't changed TrimType::TrimLeft => instance.trim_end.unwrap_or(content_end), // trim_end wasn't changed
TrimType::TrimRight => old.trim_value.unwrap_or(clip.content_duration().native()), TrimType::TrimRight => old.trim_value.unwrap_or(content_end),
}; };
// Handle trim based on clip type // Handle trim based on clip type
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
// For MIDI: trim_clip expects the pool clip ID // For MIDI: trim_clip expects the pool clip ID
controller.trim_clip(*track_id, *midi_clip_id, internal_start, internal_end); controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end));
} }
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
// For sampled audio: trim_clip expects the instance ID // For sampled audio: trim_clip expects the instance ID
let backend_instance_id = backend.clip_instance_to_backend_map.get(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))?; .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
match backend_instance_id { match backend_instance_id {
crate::action::BackendClipInstanceId::Audio(audio_id) => { crate::action::BackendClipInstanceId::Audio(audio_id) => {
controller.trim_clip(*track_id, *audio_id, internal_start, internal_end); controller.trim_clip(*track_id, *audio_id, clip.trim_range(internal_start, internal_end));
} }
_ => return Err("Expected audio instance ID for sampled clip".to_string()), _ => return Err("Expected audio instance ID for sampled clip".to_string()),
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording clips cannot be trimmed - skip // Recording clips cannot be trimmed - skip
} }
} }
@ -569,7 +631,7 @@ mod tests {
let mut clip_instance = ClipInstance::new(clip_id); let mut clip_instance = ClipInstance::new(clip_id);
clip_instance.timeline_start = Beats::ZERO; clip_instance.timeline_start = Beats::ZERO;
clip_instance.trim_start = 0.0; clip_instance.trim_start = ContentTime::ZERO;
let instance_id = clip_instance.id; let instance_id = clip_instance.id;
vector_layer.clip_instances.push(clip_instance); vector_layer.clip_instances.push(clip_instance);
@ -582,8 +644,8 @@ mod tests {
vec![( vec![(
instance_id, instance_id,
TrimType::TrimLeft, TrimType::TrimLeft,
TrimData::left(0.0, Beats::ZERO), TrimData::left(ContentTime::ZERO, Beats::ZERO),
TrimData::left(2.0, Beats(2.0)), TrimData::left(ContentTime(2.0), Beats(2.0)),
)], )],
); );
@ -599,7 +661,7 @@ mod tests {
.iter() .iter()
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_start, ContentTime(2.0));
assert_eq!(instance.timeline_start, Beats(2.0)); assert_eq!(instance.timeline_start, Beats(2.0));
} }
@ -613,7 +675,7 @@ mod tests {
.iter() .iter()
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.trim_start, 0.0); assert_eq!(instance.trim_start, ContentTime::ZERO);
assert_eq!(instance.timeline_start, Beats::ZERO); assert_eq!(instance.timeline_start, Beats::ZERO);
} }
} }
@ -644,7 +706,7 @@ mod tests {
instance_id, instance_id,
TrimType::TrimRight, TrimType::TrimRight,
TrimData::right(None), TrimData::right(None),
TrimData::right(Some(8.0)), TrimData::right(Some(ContentTime(8.0))),
)], )],
); );
@ -660,7 +722,7 @@ mod tests {
.iter() .iter()
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.trim_end, Some(8.0)); assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
} }
// Rollback // Rollback

View File

@ -14,7 +14,7 @@
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::layer_tree::LayerTree; use crate::layer_tree::LayerTree;
use crate::object::Transform; use crate::object::Transform;
use daw_backend::{Beats, Seconds}; use daw_backend::{Beats, ContentTime, Seconds};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use uuid::Uuid; use uuid::Uuid;
@ -130,10 +130,14 @@ impl VectorClip {
let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration { let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration {
ci.timeline_start + td_beats ci.timeline_start + td_beats
} else if let Some(te) = ci.trim_end { } else if let Some(te) = ci.trim_end {
let secs = (te - ci.trim_start).max(0.0); // `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);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) 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) { } else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
let secs = (clip_dur_secs - ci.trim_start).max(0.0); let secs = (clip_dur_secs - ci.trim_start.raw()).max(0.0);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
} else { } else {
continue; continue;
@ -201,7 +205,9 @@ impl VectorClip {
// Convert parent clip time (seconds) to nested clip local time (seconds). // Convert parent clip time (seconds) to nested clip local time (seconds).
// timeline_start is in beats; convert to seconds using document BPM. // 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(); let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; // 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();
// Look up the nested clip definition // Look up the nested clip definition
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) { let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
@ -468,6 +474,25 @@ pub enum AudioClipType {
Recording, 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. /// A clip's content duration, tagged by its native unit.
/// ///
/// Sampled/recording audio and video measure content in wall-clock **seconds**; MIDI measures /// Sampled/recording audio and video measure content in wall-clock **seconds**; MIDI measures
@ -497,6 +522,17 @@ impl ClipDuration {
ClipDuration::Beats(b) => b.beats_to_f64(), 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 /// Audio clip
@ -532,11 +568,10 @@ impl AudioClip {
/// The clip's content duration, tagged with its native domain (seconds for sampled/recording, /// 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. /// beats for MIDI). This is the only sanctioned way to read the raw `duration` field.
pub fn content_duration(&self) -> ClipDuration { pub fn content_duration(&self) -> ClipDuration {
match self.clip_type { if self.is_midi_domain() {
AudioClipType::Midi { .. } => ClipDuration::Beats(Beats(self.duration)), ClipDuration::Beats(Beats(self.duration))
AudioClipType::Sampled { .. } | AudioClipType::Recording => { } else {
ClipDuration::Seconds(Seconds(self.duration)) ClipDuration::Seconds(Seconds(self.duration))
}
} }
} }
@ -545,15 +580,19 @@ impl AudioClip {
pub fn set_content_duration(&mut self, duration: ClipDuration) { pub fn set_content_duration(&mut self, duration: ClipDuration) {
debug_assert!( debug_assert!(
matches!( matches!(
(&self.clip_type, duration), (self.is_midi_domain(), duration),
(AudioClipType::Midi { .. }, ClipDuration::Beats(_)) (true, ClipDuration::Beats(_)) | (false, ClipDuration::Seconds(_))
| (AudioClipType::Sampled { .. } | AudioClipType::Recording, ClipDuration::Seconds(_))
), ),
"clip duration domain must match clip type", "clip duration domain must match clip type",
); );
self.duration = duration.native(); 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 /// Create a new sampled audio clip
/// ///
/// # Arguments /// # Arguments
@ -637,6 +676,73 @@ impl AudioClip {
_ => None, _ => 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 /// Unified clip enum for polymorphic handling
@ -715,16 +821,17 @@ pub struct ClipInstance {
/// Default: None (use trimmed clip duration, no looping) /// Default: None (use trimmed clip duration, no looping)
pub timeline_duration: Option<Beats>, pub timeline_duration: Option<Beats>,
/// Trim start: offset into the clip's internal content, in **seconds**. /// Trim start: offset into the clip's internal content.
/// - For audio: byte-offset into the audio file ///
/// - For video: seek position in the video file /// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled
/// - For vector: time offset into the animation /// audio/video/vector but BEATS for MIDI. Resolve it against the clip
/// ([`Document::resolve_content_time`]) before combining it with anything on the timeline.
/// Default: 0.0 /// Default: 0.0
pub trim_start: f64, pub trim_start: ContentTime,
/// Trim end: offset into the clip's internal content, in **seconds**. /// Trim end: offset into the clip's internal content. See [`Self::trim_start`].
/// Default: None (use full clip duration) /// Default: None (use full clip duration)
pub trim_end: Option<f64>, pub trim_end: Option<ContentTime>,
/// Playback speed multiplier /// Playback speed multiplier
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
@ -741,6 +848,35 @@ pub struct ClipInstance {
/// Default: None (no pre-loop) /// Default: None (no pre-loop)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub loop_before: Option<Beats>, 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. /// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID.
@ -794,11 +930,14 @@ impl ClipInstance {
name: None, name: None,
timeline_start: Beats::ZERO, timeline_start: Beats::ZERO,
timeline_duration: None, timeline_duration: None,
trim_start: 0.0, trim_start: ContentTime::ZERO,
trim_end: None, trim_end: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None, loop_before: None,
takes: Vec::new(),
active_take: None,
recorded_loop_beats: None,
} }
} }
@ -812,11 +951,14 @@ impl ClipInstance {
name: None, name: None,
timeline_start: Beats::ZERO, timeline_start: Beats::ZERO,
timeline_duration: None, timeline_duration: None,
trim_start: 0.0, trim_start: ContentTime::ZERO,
trim_end: None, trim_end: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None, loop_before: None,
takes: Vec::new(),
active_take: None,
recorded_loop_beats: None,
} }
} }
@ -852,7 +994,7 @@ impl ClipInstance {
} }
/// Set trimming (start and end time within the clip's internal content) /// Set trimming (start and end time within the clip's internal content)
pub fn with_trimming(mut self, trim_start: f64, trim_end: Option<f64>) -> Self { pub fn with_trimming(mut self, trim_start: ContentTime, trim_end: Option<ContentTime>) -> Self {
self.trim_start = trim_start; self.trim_start = trim_start;
self.trim_end = trim_end; self.trim_end = trim_end;
self self
@ -876,24 +1018,89 @@ impl ClipInstance {
self self
} }
/// Content window size in seconds: `trim_end - trim_start`. /// 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.
/// Used for internal looping calculations. /// Used for internal looping calculations.
pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds { pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration {
let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64()); let end = self.trim_end.map_or(clip_content.native(), |t| t.raw());
Seconds((end - self.trim_start).max(0.0)) 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)),
}
} }
/// How long this instance appears on the timeline, in **beats**. /// How long this instance appears on the timeline, in **beats**.
/// ///
/// If `timeline_duration` is set, returns that (enabling content looping). /// If `timeline_duration` is set, returns that (enabling content looping). Otherwise the clip
/// Otherwise converts the content window from seconds to beats using the tempo map. /// occupies its content window — converted to beats *in the clip's own domain*:
pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { ///
/// - 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 let Some(td) = self.timeline_duration { if let Some(td) = self.timeline_duration {
return td; return td;
} }
let window = self.content_window_secs(clip_duration_secs); match self.content_window(clip_content) {
let start_secs = tempo_map.beats_to_seconds(self.timeline_start); ClipDuration::Beats(b) => b,
tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start 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
}
}
} }
/// Left edge of the clip's visual extent on the timeline, in **beats**. /// Left edge of the clip's visual extent on the timeline, in **beats**.
@ -902,27 +1109,32 @@ impl ClipInstance {
} }
/// Total visual duration (loop_before + effective_duration), in **beats**. /// Total visual duration (loop_before + effective_duration), in **beats**.
pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> 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_duration_secs, tempo_map) self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_content, tempo_map)
} }
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**). /// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
/// ///
/// Returns `None` if the clip instance is not active at `time_secs`. /// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats
pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> { /// 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> {
let start_secs = tempo_map.beats_to_seconds(self.timeline_start); let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map); let dur_beats = self.effective_duration_beats(clip_content, tempo_map);
let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats); let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats);
if time < start_secs || time >= end_secs { if time < start_secs || time >= end_secs {
return None; 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_time = (time - start_secs) * self.playback_speed;
let content_window = self.content_window_secs(clip_duration_secs); let content_window = self.content_window(clip_content).to_seconds(tempo_map);
if content_window == Seconds::ZERO { if content_window == Seconds::ZERO {
return Some(Seconds(self.trim_start)); return Some(trim_start_secs);
} }
let looped = if content_time > content_window { let looped = if content_time > content_window {
@ -931,19 +1143,19 @@ impl ClipInstance {
content_time content_time
}; };
Some(Seconds(self.trim_start) + looped) Some(trim_start_secs + looped)
} }
/// Alias for `remap_time_secs`. /// Alias for `remap_time_secs`.
#[inline] #[inline]
pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> { pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
self.remap_time_secs(time, clip_duration_secs, tempo_map) self.remap_time_secs(time, clip_content, tempo_map)
} }
/// Alias for `effective_duration_beats`. /// Alias for `effective_duration_beats`.
#[inline] #[inline]
pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
self.effective_duration_beats(clip_duration_secs, tempo_map) self.effective_duration_beats(clip_content, tempo_map)
} }
/// Convert to affine transform /// Convert to affine transform
@ -1020,7 +1232,7 @@ mod tests {
assert_eq!(instance.clip_id, clip_id); assert_eq!(instance.clip_id, clip_id);
assert_eq!(instance.opacity, 1.0); assert_eq!(instance.opacity, 1.0);
assert_eq!(instance.timeline_start, Beats::ZERO); assert_eq!(instance.timeline_start, Beats::ZERO);
assert_eq!(instance.trim_start, 0.0); assert_eq!(instance.trim_start, ContentTime::ZERO);
assert_eq!(instance.trim_end, None); assert_eq!(instance.trim_end, None);
assert_eq!(instance.playback_speed, 1.0); assert_eq!(instance.playback_speed, 1.0);
assert_eq!(instance.gain, 1.0); assert_eq!(instance.gain, 1.0);
@ -1030,27 +1242,52 @@ mod tests {
fn test_clip_instance_trimming() { fn test_clip_instance_trimming() {
let clip_id = Uuid::new_v4(); let clip_id = Uuid::new_v4();
let instance = ClipInstance::new(clip_id) let instance = ClipInstance::new(clip_id)
.with_trimming(2.0, Some(8.0)); .with_trimming(ContentTime(2.0), Some(ContentTime(8.0)));
assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_start, ContentTime(2.0));
assert_eq!(instance.trim_end, Some(8.0)); assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the // At 60 BPM the tempo map is identity (1 beat == 1 second), so the
// beats-domain effective duration equals the seconds content window. // beats-domain effective duration equals the seconds content window.
let tempo_map = crate::tempo_map::TempoMap::constant(60.0); let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(6.0)); let content = ClipDuration::Seconds(Seconds(10.0));
assert_eq!(instance.effective_duration(content, &tempo_map), Beats(6.0));
} }
#[test] #[test]
fn test_clip_instance_no_end_trim() { fn test_clip_instance_no_end_trim() {
let clip_id = Uuid::new_v4(); let clip_id = Uuid::new_v4();
let instance = ClipInstance::new(clip_id) let instance = ClipInstance::new(clip_id)
.with_trimming(2.0, None); .with_trimming(ContentTime(2.0), None);
assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_start, ContentTime(2.0));
assert_eq!(instance.trim_end, None); assert_eq!(instance.trim_end, None);
// At 60 BPM the tempo map is identity (1 beat == 1 second). // At 60 BPM the tempo map is identity (1 beat == 1 second).
let tempo_map = crate::tempo_map::TempoMap::constant(60.0); let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(8.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",
);
}
} }
#[test] #[test]
@ -1070,4 +1307,109 @@ mod tests {
assert_eq!(instance.playback_speed, 2.0); assert_eq!(instance.playback_speed, 2.0);
assert_eq!(instance.gain, 0.8); 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);
}
} }

View File

@ -464,8 +464,11 @@ impl Document {
let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration { let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration {
instance.timeline_start + timeline_duration instance.timeline_start + timeline_duration
} else { } else {
let trim_end = instance.trim_end.unwrap_or(clip_duration); // `clip_duration` arrives as seconds (the recursive helper's signature), so this
let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0); // 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 start_secs = tempo_map.beats_to_seconds(instance.timeline_start); let start_secs = tempo_map.beats_to_seconds(instance.timeline_start);
tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs)) tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs))
}; };
@ -780,16 +783,18 @@ impl Document {
} }
/// Find the document audio clip (UUID + ref) that owns the given backend pool index. /// 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)> { pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> {
self.audio_clips.iter() self.audio_clips.iter()
.find(|(_, c)| c.audio_pool_index() == Some(pool_index)) .find(|(_, c)| c.owns_audio_pool_index(pool_index))
.map(|(&id, c)| (id, c)) .map(|(&id, c)| (id, c))
} }
/// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID. /// 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)> { pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> {
self.audio_clips.iter() self.audio_clips.iter()
.find(|(_, c)| c.midi_clip_id() == Some(midi_clip_id)) .find(|(_, c)| c.owns_midi_clip_id(midi_clip_id))
.map(|(&id, c)| (id, c)) .map(|(&id, c)| (id, c))
} }
@ -911,6 +916,81 @@ impl Document {
/// Searches through all clip libraries to find the clip and return its duration. /// Searches through all clip libraries to find the clip and return its duration.
/// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects /// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects
/// have infinite internal duration. /// 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> { pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
if let Some(clip) = self.vector_clips.get(clip_id) { if let Some(clip) = self.vector_clips.get(clip_id) {
if clip.is_group { if clip.is_group {
@ -964,9 +1044,9 @@ impl Document {
}; };
let instance = instances.iter().find(|inst| &inst.id == instance_id)?; let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
let clip_duration = self.get_clip_duration(&instance.clip_id)?; // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
// End position on the timeline, in beats (convert the seconds content window via tempo map). let clip_content = self.clip_trim_duration(&instance.clip_id)?;
Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map())) Some(instance.timeline_start + instance.effective_duration_beats(clip_content, self.tempo_map()))
} }
/// Check if a time range overlaps with any existing clip on the layer /// Check if a time range overlaps with any existing clip on the layer
@ -1006,13 +1086,14 @@ impl Document {
continue; continue;
} }
// Calculate instance extent (accounting for loop_before) // Calculate instance extent (accounting for loop_before). Content duration in the clip's
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { // own domain, so the trims resolve correctly for MIDI.
let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else {
continue; continue;
}; };
let instance_start = instance.effective_start(); let instance_start = instance.effective_start();
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map()); let instance_end = instance.timeline_start + instance.effective_duration(clip_content, self.tempo_map());
// Check overlap: start_a < end_b AND start_b < end_a // Check overlap: start_a < end_b AND start_b < end_a
if start_time < instance_end && instance_start < end_time { if start_time < instance_end && instance_start < end_time {
@ -1069,7 +1150,7 @@ impl Document {
continue; continue;
} }
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) { if let Some(clip_dur) = self.clip_trim_duration(&instance.clip_id) {
let inst_start = instance.effective_start(); let inst_start = instance.effective_start();
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map()); let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
occupied_ranges.push((inst_start, inst_end, instance.id)); occupied_ranges.push((inst_start, inst_end, instance.id));
@ -1165,7 +1246,7 @@ impl Document {
if group_ids.contains(&inst.id) { if group_ids.contains(&inst.id) {
continue; continue;
} }
if let Some(dur) = self.get_clip_duration(&inst.clip_id) { if let Some(dur) = self.clip_trim_duration(&inst.clip_id) {
let start = inst.effective_start(); let start = inst.effective_start();
let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map()); let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
non_group.push((start, end)); non_group.push((start, end));
@ -1239,9 +1320,8 @@ impl Document {
} }
// Calculate other clip's extent (accounting for loop_before) // Calculate other clip's extent (accounting for loop_before)
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) {
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); 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 this clip is to the left and closer than current nearest
if other_end <= current_timeline_start && other_end > nearest_end { if other_end <= current_timeline_start && other_end > nearest_end {
@ -1339,7 +1419,7 @@ impl Document {
continue; continue;
} }
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) {
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
if other_end <= current_effective_start && other_end > nearest_end { if other_end <= current_effective_start && other_end > nearest_end {

View File

@ -152,7 +152,12 @@ impl EffectLayer {
self.clip_instances self.clip_instances
.iter() .iter()
.filter(|e| { .filter(|e| {
let end = e.timeline_start + e.effective_duration(daw_backend::Seconds(EFFECT_DURATION), tempo_map); // 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,
);
time_beats >= e.timeline_start && time_beats < end time_beats >= e.timeline_start && time_beats < end
}) })
.collect() .collect()

View File

@ -3,7 +3,7 @@
//! Provides functions for testing if points or rectangles intersect with //! Provides functions for testing if points or rectangles intersect with
//! vector graph elements and clip instances, taking into account transform hierarchies. //! vector graph elements and clip instances, taking into account transform hierarchies.
use crate::clip::ClipInstance; use crate::clip::{ClipDuration, ClipInstance};
use crate::vector_graph::{VertexId, EdgeId, FillId}; use crate::vector_graph::{VertexId, EdgeId, FillId};
use crate::layer::VectorLayer; use crate::layer::VectorLayer;
use crate::shape::Shape; use crate::shape::Shape;
@ -260,7 +260,10 @@ pub fn hit_test_clip_instances(
for clip_instance in clip_instances.iter().rev() { for clip_instance in clip_instances.iter().rev() {
// Check time bounds: skip clip instances not active at this time // Check time bounds: skip clip instances not active at this time
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO); // 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 instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); 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)); let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
@ -269,7 +272,8 @@ pub fn hit_test_clip_instances(
// clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds) // 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 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; let clip_time =
((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
vector_clip.calculate_content_bounds(document, clip_time) vector_clip.calculate_content_bounds(document, clip_time)
@ -304,7 +308,10 @@ pub fn hit_test_clip_instances_in_rect(
for clip_instance in clip_instances { for clip_instance in clip_instances {
// Check time bounds: skip clip instances not active at this time // Check time bounds: skip clip instances not active at this time
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO); // 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 instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); 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)); let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
@ -312,7 +319,8 @@ pub fn hit_test_clip_instances_in_rect(
} }
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); 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; let clip_time =
((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
vector_clip.calculate_content_bounds(document, clip_time) vector_clip.calculate_content_bounds(document, clip_time)

View File

@ -9,7 +9,7 @@
//! The compositing mode enables proper per-layer opacity, blend modes, and effects. //! The compositing mode enables proper per-layer opacity, blend modes, and effects.
use crate::animation::TransformProperty; use crate::animation::TransformProperty;
use crate::clip::{ClipInstance, ImageAsset}; use crate::clip::{ClipDuration, ClipInstance, ImageAsset};
use crate::document::Document; use crate::document::Document;
use daw_backend::Seconds; use daw_backend::Seconds;
use crate::gpu::BlendMode; use crate::gpu::BlendMode;
@ -568,7 +568,7 @@ pub fn render_layer_isolated(
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
for clip_instance in &video_layer.clip_instances { for clip_instance in &video_layer.clip_instances {
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { continue }; let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else { continue };
let clip_time = clip_time.seconds_to_f64(); 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 }; let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue };
@ -1019,7 +1019,10 @@ fn render_clip_instance(
} }
0.0 0.0
} else { } else {
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); // 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 Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else {
return; // Clip instance not active at this time return; // Clip instance not active at this time
}; };
@ -1174,7 +1177,7 @@ fn render_video_layer(
// Remap timeline time to clip's internal time // Remap timeline time to clip's internal time
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else {
continue; // Clip instance not active at this time continue; // Clip instance not active at this time
}; };
let clip_time = clip_time.seconds_to_f64(); let clip_time = clip_time.seconds_to_f64();
@ -1910,7 +1913,10 @@ fn render_clip_instance_cpu(
if time < start_secs || time >= end { return; } if time < start_secs || time >= end { return; }
0.0 0.0
} else { } else {
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); // 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 Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return }; let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return };
t.seconds_to_f64() t.seconds_to_f64()
}; };

View File

@ -35,6 +35,18 @@ pub struct AppConfig {
#[serde(default = "defaults::audio_buffer_size")] #[serde(default = "defaults::audio_buffer_size")]
pub audio_buffer_size: u32, 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 /// Reopen last session on startup
#[serde(default = "defaults::reopen_last_session")] #[serde(default = "defaults::reopen_last_session")]
pub reopen_last_session: bool, pub reopen_last_session: bool,
@ -131,6 +143,7 @@ impl Default for AppConfig {
file_height: defaults::file_height(), file_height: defaults::file_height(),
scroll_speed: defaults::scroll_speed(), scroll_speed: defaults::scroll_speed(),
audio_buffer_size: defaults::audio_buffer_size(), audio_buffer_size: defaults::audio_buffer_size(),
cycle_midi_separate_takes: defaults::cycle_midi_separate_takes(),
reopen_last_session: defaults::reopen_last_session(), reopen_last_session: defaults::reopen_last_session(),
restore_layout_from_file: defaults::restore_layout_from_file(), restore_layout_from_file: defaults::restore_layout_from_file(),
debug: defaults::debug(), debug: defaults::debug(),
@ -342,6 +355,7 @@ mod defaults {
pub fn file_height() -> u32 { 600 } pub fn file_height() -> u32 { 600 }
pub fn scroll_speed() -> f64 { 1.0 } pub fn scroll_speed() -> f64 { 1.0 }
pub fn audio_buffer_size() -> u32 { 256 } pub fn audio_buffer_size() -> u32 { 256 }
pub fn cycle_midi_separate_takes() -> bool { false }
pub fn reopen_last_session() -> bool { false } pub fn reopen_last_session() -> bool { false }
pub fn restore_layout_from_file() -> bool { true } pub fn restore_layout_from_file() -> bool { true }
pub fn debug() -> bool { false } pub fn debug() -> bool { false }

View File

@ -1064,7 +1064,12 @@ fn composite_document_to_hdr(
} }
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let effect_end_beats = effect_instance.timeline_start let effect_end_beats = effect_instance.timeline_start
+ effect_instance.effective_duration(daw_backend::Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map); + effect_instance.effective_duration(
lightningbeam_core::clip::ClipDuration::Seconds(daw_backend::Seconds(
lightningbeam_core::effect::EFFECT_DURATION,
)),
tempo_map,
);
let effect_inst = lightningbeam_core::effect::EffectInstance::new( let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def, effect_def,
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),

View File

@ -1105,6 +1105,23 @@ 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 { struct EditorApp {
layouts: Vec<LayoutDefinition>, layouts: Vec<LayoutDefinition>,
current_layout_index: usize, current_layout_index: usize,
@ -1212,6 +1229,10 @@ struct EditorApp {
metronome_enabled: bool, // Whether metronome clicks during recording metronome_enabled: bool, // Whether metronome clicks during recording
count_in_enabled: bool, // Whether count-in fires before recording count_in_enabled: bool, // Whether count-in fires before recording
recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during 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_start_time: f64, // Playback time when recording started
recording_layer_ids: Vec<Uuid>, // Layers being recorded to (for creating clips) recording_layer_ids: Vec<Uuid>, // Layers being recorded to (for creating clips)
// Asset drag-and-drop state // Asset drag-and-drop state
@ -1583,6 +1604,7 @@ impl EditorApp {
metronome_enabled: false, // Metronome off by default metronome_enabled: false, // Metronome off by default
count_in_enabled: false, // Count-in off by default count_in_enabled: false, // Count-in off by default
recording_clips: HashMap::new(), // No active recording clips 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_start_time: 0.0, // Will be set when recording starts
recording_layer_ids: Vec::new(), // Will be populated when recording starts recording_layer_ids: Vec::new(), // Will be populated when recording starts
dragging_asset: None, // No asset being dragged initially dragging_asset: None, // No asset being dragged initially
@ -2038,6 +2060,91 @@ impl EditorApp {
/// 2. For MIDI: Loads the default instrument /// 2. For MIDI: Loads the default instrument
/// 3. Stores the bidirectional mapping /// 3. Stores the bidirectional mapping
/// 4. Syncs any existing clips on the layer /// 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) { fn sync_audio_layers_to_backend(&mut self) {
use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
@ -2054,6 +2161,9 @@ impl EditorApp {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
controller.set_loop_region(region); controller.set_loop_region(region);
controller.set_loop_enabled(enabled); 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);
} }
} }
@ -2389,7 +2499,7 @@ impl EditorApp {
) -> Vec<uuid::Uuid> { ) -> Vec<uuid::Uuid> {
let mut result = Vec::new(); let mut result = Vec::new();
for instance in clip_instances { for instance in clip_instances {
if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) { if let Some(clip_duration) = document.clip_trim_duration(&instance.clip_id) {
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
let timeline_end = instance.timeline_start + effective_duration; let timeline_end = instance.timeline_start + effective_duration;
@ -3278,7 +3388,9 @@ impl EditorApp {
let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| { let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| {
let mut duplicate = original.clone(); let mut duplicate = original.clone();
duplicate.id = uuid::Uuid::new_v4(); duplicate.id = uuid::Uuid::new_v4();
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(Seconds(1.0)); let clip_duration = document
.clip_trim_duration(&original.clip_id)
.unwrap_or(ClipDuration::Seconds(Seconds(1.0)));
let effective_duration = original.effective_duration(clip_duration, document.tempo_map()); let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
duplicate.timeline_start = original.timeline_start + effective_duration; duplicate.timeline_start = original.timeline_start + effective_duration;
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) { if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
@ -5479,7 +5591,7 @@ impl EditorApp {
// matches the video clip exactly). // matches the video clip exactly).
let (_dur, sample_rate, channels) = controller let (_dur, sample_rate, channels) = controller
.get_pool_file_info(pool_index) .get_pool_file_info(pool_index)
.unwrap_or((video_duration, 0, 0)); .unwrap_or((Seconds(video_duration), 0, 0));
drop(controller); drop(controller);
let audio_clip_name = format!("{} (Audio)", video_name); let audio_clip_name = format!("{} (Audio)", video_name);
@ -6386,7 +6498,9 @@ impl eframe::App for EditorApp {
use daw_backend::AudioEvent; use daw_backend::AudioEvent;
match event { match event {
AudioEvent::PlaybackPosition(time) => { AudioEvent::PlaybackPosition(time) => {
self.playback_time = 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();
} }
AudioEvent::PlaybackStopped => { AudioEvent::PlaybackStopped => {
self.is_playing = false; self.is_playing = false;
@ -6492,6 +6606,25 @@ 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) // Then update the clip duration (mutable borrow)
if let Some(doc_clip_id) = doc_clip_id { if let Some(doc_clip_id) = doc_clip_id {
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
@ -6503,6 +6636,158 @@ impl eframe::App for EditorApp {
} }
ctx.request_repaint(); 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) => { AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => {
eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index); eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index);
@ -6528,6 +6813,7 @@ impl eframe::App for EditorApp {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
match controller.get_pool_file_info(pool_index) { match controller.get_pool_file_info(pool_index) {
Ok((dur, _, _)) => { Ok((dur, _, _)) => {
let dur = dur.seconds_to_f64();
eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur); eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur);
self.audio_duration_cache.insert(pool_index, dur); self.audio_duration_cache.insert(pool_index, dur);
dur dur
@ -6561,7 +6847,7 @@ impl eframe::App for EditorApp {
None None
} }
}) })
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, 0.0)) .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, daw_backend::ContentTime::ZERO))
}; };
if !clip_id.is_nil() { if !clip_id.is_nil() {
@ -6685,7 +6971,11 @@ impl eframe::App for EditorApp {
); );
} }
} }
// Update the clip's duration so the timeline bar grows // Update the clip's duration so the timeline bar grows. 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.
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
clip.set_content_duration(ClipDuration::Beats(duration)); clip.set_content_duration(ClipDuration::Beats(duration));
} }
@ -6705,6 +6995,144 @@ impl eframe::App for EditorApp {
} }
ctx.request_repaint(); 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) => { AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => {
println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes", println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes",
track_id, clip_id, note_count); track_id, clip_id, note_count);
@ -6723,7 +7151,7 @@ impl eframe::App for EditorApp {
.map(|(id, _)| id); .map(|(id, _)| id);
if let Some(doc_clip_id) = doc_clip_id { if let Some(doc_clip_id) = doc_clip_id {
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
clip.set_content_duration(ClipDuration::Beats(Beats(midi_clip_data.duration))); clip.set_content_duration(ClipDuration::Beats(midi_clip_data.duration));
clip.name = format!("MIDI Recording {}", clip_id); clip.name = format!("MIDI Recording {}", clip_id);
} }
} }
@ -6851,6 +7279,20 @@ 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) // 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| { let should_monitor = self.audio_controller.is_some() && self.active_layer_id.map_or(false, |layer_id| {
@ -7045,6 +7487,11 @@ impl eframe::App for EditorApp {
if result.buffer_size_changed { if result.buffer_size_changed {
println!("⚠️ Audio buffer size will be applied on next app restart"); 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 // Apply new keybindings if changed
if let Some(new_keymap) = result.new_keymap { if let Some(new_keymap) = result.new_keymap {
self.keymap = new_keymap; self.keymap = new_keymap;

View File

@ -43,6 +43,7 @@ pub const CHEVRONS_UP: &str = "\u{e074}";
pub const PLAY: &str = "\u{e13c}"; pub const PLAY: &str = "\u{e13c}";
pub const PAUSE: &str = "\u{e12e}"; pub const PAUSE: &str = "\u{e12e}";
pub const REPEAT: &str = "\u{e146}"; // cycle / loop region toggle 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 SETTINGS: &str = "\u{e154}";
pub const SEARCH: &str = "\u{e151}"; pub const SEARCH: &str = "\u{e151}";
pub const PLUS: &str = "\u{e13d}"; pub const PLUS: &str = "\u{e13d}";

View File

@ -7,7 +7,7 @@
//! - Image Assets (static images) //! - Image Assets (static images)
use eframe::egui; use eframe::egui;
use lightningbeam_core::clip::{AudioClipType, VectorClip}; use lightningbeam_core::clip::{ResolvedContent, VectorClip};
use lightningbeam_core::document::Document; use lightningbeam_core::document::Document;
use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::layer::AnyLayer;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -918,11 +918,11 @@ impl AssetLibraryPane {
continue; continue;
} }
let (extra_info, drag_clip_type) = match &clip.clip_type { let (extra_info, drag_clip_type) = match &clip.resolve() {
AudioClipType::Sampled { .. } => ("Sampled".to_string(), DragClipType::AudioSampled), ResolvedContent::Audio { .. } => ("Sampled".to_string(), DragClipType::AudioSampled),
AudioClipType::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi), ResolvedContent::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi),
AudioClipType::Recording => { ResolvedContent::Recording => {
// Skip recording-in-progress clips from asset library // Skip recording-in-progress clips (and empty take folders) from asset library
continue; continue;
} }
}; };
@ -1118,15 +1118,15 @@ impl AssetLibraryPane {
for (id, clip) in &document.audio_clips { for (id, clip) in &document.audio_clips {
if !linked_audio_ids.contains(id) && clip.folder_id == current_folder { if !linked_audio_ids.contains(id) && clip.folder_id == current_folder {
let (extra_info, drag_clip_type) = match &clip.clip_type { let (extra_info, drag_clip_type) = match &clip.resolve() {
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
("Sampled".to_string(), DragClipType::AudioSampled) ("Sampled".to_string(), DragClipType::AudioSampled)
} }
AudioClipType::Midi { .. } => { ResolvedContent::Midi { .. } => {
("MIDI".to_string(), DragClipType::AudioMidi) ("MIDI".to_string(), DragClipType::AudioMidi)
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Skip recording-in-progress clips // Skip recording-in-progress clips (and empty take folders)
continue; continue;
} }
}; };
@ -1765,7 +1765,7 @@ impl AssetLibraryPane {
let prefetched_waveform: Option<Vec<(f32, f32)>> = let prefetched_waveform: Option<Vec<(f32, f32)>> =
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
if let Some(clip) = document.audio_clips.get(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) {
if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() {
shared.raw_audio_cache.get(audio_pool_index) shared.raw_audio_cache.get(audio_pool_index)
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize))
} else { } else {
@ -1790,8 +1790,8 @@ impl AssetLibraryPane {
AssetCategory::Audio => { AssetCategory::Audio => {
if let Some(clip) = document.audio_clips.get(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) {
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
match &clip.clip_type { match &clip.resolve() {
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
let wave_color = egui::Color32::from_rgb(100, 200, 100); let wave_color = egui::Color32::from_rgb(100, 200, 100);
if let Some(ref peaks) = prefetched_waveform { if let Some(ref peaks) = prefetched_waveform {
Some(generate_waveform_thumbnail(peaks, bg_color, wave_color)) Some(generate_waveform_thumbnail(peaks, bg_color, wave_color))
@ -1799,7 +1799,7 @@ impl AssetLibraryPane {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
let note_color = egui::Color32::from_rgb(100, 200, 100); let note_color = egui::Color32::from_rgb(100, 200, 100);
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { 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)) 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)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording in progress - show placeholder // Recording in progress - show placeholder
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
@ -2344,8 +2344,8 @@ impl AssetLibraryPane {
AssetCategory::Audio => { AssetCategory::Audio => {
if let Some(clip) = document.audio_clips.get(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) {
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
match &clip.clip_type { match &clip.resolve() {
AudioClipType::Sampled { audio_pool_index } => { ResolvedContent::Audio { audio_pool_index } => {
let wave_color = egui::Color32::from_rgb(100, 200, 100); let wave_color = egui::Color32::from_rgb(100, 200, 100);
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index) 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)); .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
@ -2355,7 +2355,7 @@ impl AssetLibraryPane {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
let note_color = egui::Color32::from_rgb(100, 200, 100); let note_color = egui::Color32::from_rgb(100, 200, 100);
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { 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)) 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)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
@ -2481,8 +2481,8 @@ impl AssetLibraryPane {
AssetCategory::Audio => { AssetCategory::Audio => {
if let Some(clip) = document.audio_clips.get(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) {
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
match &clip.clip_type { match &clip.resolve() {
AudioClipType::Sampled { audio_pool_index } => { ResolvedContent::Audio { audio_pool_index } => {
let wave_color = egui::Color32::from_rgb(100, 200, 100); let wave_color = egui::Color32::from_rgb(100, 200, 100);
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index) 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)); .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
@ -2492,7 +2492,7 @@ impl AssetLibraryPane {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
let note_color = egui::Color32::from_rgb(100, 200, 100); let note_color = egui::Color32::from_rgb(100, 200, 100);
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { 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)) 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)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
@ -2802,7 +2802,7 @@ impl AssetLibraryPane {
let prefetched_waveform: Option<Vec<(f32, f32)>> = let prefetched_waveform: Option<Vec<(f32, f32)>> =
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
if let Some(clip) = document.audio_clips.get(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) {
if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() {
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index) 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)); .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
if waveform.is_some() { if waveform.is_some() {
@ -2842,8 +2842,8 @@ impl AssetLibraryPane {
// Check if it's sampled or MIDI // Check if it's sampled or MIDI
if let Some(clip) = document.audio_clips.get(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) {
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
match &clip.clip_type { match &clip.resolve() {
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
let wave_color = egui::Color32::from_rgb(100, 200, 100); let wave_color = egui::Color32::from_rgb(100, 200, 100);
if let Some(ref peaks) = prefetched_waveform { if let Some(ref peaks) = prefetched_waveform {
println!("✅ Generating waveform thumbnail with {} peaks for asset {}", peaks.len(), asset_id); 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)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
let note_color = egui::Color32::from_rgb(100, 200, 100); let note_color = egui::Color32::from_rgb(100, 200, 100);
@ -2863,7 +2863,7 @@ impl AssetLibraryPane {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) 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); println!("🎨 [ASSET_LIB] Checking for thumbnails to invalidate (pools: {:?})", shared.audio_pools_with_new_waveforms);
let mut invalidated_any = false; let mut invalidated_any = false;
for (asset_id, clip) in &document_arc.audio_clips { for (asset_id, clip) in &document_arc.audio_clips {
if let lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() {
if shared.audio_pools_with_new_waveforms.contains(audio_pool_index) { if shared.audio_pools_with_new_waveforms.contains(audio_pool_index) {
println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index); println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index);
self.thumbnail_cache.invalidate(asset_id); self.thumbnail_cache.invalidate(asset_id);

View File

@ -1590,8 +1590,12 @@ impl InfopanelPane {
ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64())); ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64()));
}); });
let clip_dur = document.get_clip_duration(&ci.clip_id) let clip_dur = document.clip_trim_duration(&ci.clip_id)
.unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); .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 total_dur = ci.total_duration(clip_dur, document.tempo_map()); 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) 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(); - document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64();
@ -1600,10 +1604,16 @@ impl InfopanelPane {
ui.label(format!("{:.2}s", total_dur_secs)); ui.label(format!("{:.2}s", total_dur_secs));
}); });
if ci.trim_start > 0.0 { if ci.trim_start > daw_backend::ContentTime::ZERO {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Trim Start:"); ui.label("Trim Start:");
ui.label(format!("{:.2}s", ci.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));
}); });
} }

View File

@ -465,9 +465,13 @@ impl PianoRollPane {
if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) { if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) {
for instance in &audio_layer.clip_instances { for instance in &audio_layer.clip_instances {
if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { // Resolve through the instance's active take, so a MIDI take folder edits
let duration = instance.effective_duration(clip.content_duration().to_seconds(document.tempo_map()), document.tempo_map()); // whichever take it's actually playing.
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id)); 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));
} }
} }
} }
@ -2463,7 +2467,8 @@ impl PianoRollPane {
}); });
// Get sample rate from raw_audio_cache // Get sample rate from raw_audio_cache
if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) { if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) {
clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), *sr)); // 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));
} }
} }
} }

View File

@ -6,7 +6,7 @@
use eframe::egui; use eframe::egui;
use daw_backend::Seconds; use daw_backend::Seconds;
use lightningbeam_core::action::Action; use lightningbeam_core::action::Action;
use lightningbeam_core::clip::ClipInstance; use lightningbeam_core::clip::{ClipDuration, ClipInstance};
use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter}; use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter};
use lightningbeam_core::layer::{AnyLayer, AudioLayer}; use lightningbeam_core::layer::{AnyLayer, AudioLayer};
use lightningbeam_core::renderer::RenderedLayerType; use lightningbeam_core::renderer::RenderedLayerType;
@ -1854,7 +1854,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// For now, create a simple effect instance with default parameters // For now, create a simple effect instance with default parameters
let tempo_map = self.ctx.document.tempo_map(); let tempo_map = self.ctx.document.tempo_map();
let effect_end_beats = effect_instance.timeline_start let effect_end_beats = effect_instance.timeline_start
+ effect_instance.effective_duration(Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map); + effect_instance.effective_duration(
ClipDuration::Seconds(Seconds(lightningbeam_core::effect::EFFECT_DURATION)),
tempo_map,
);
let effect_inst = lightningbeam_core::effect::EffectInstance::new( let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def, effect_def,
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
@ -2209,7 +2212,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Calculate clip bounds for preview // Calculate clip bounds for preview
let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64(); 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; let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start.raw();
let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) { 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) 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) { } else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) {
@ -2299,7 +2302,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
for &clip_id in self.ctx.selection.clip_instances() { 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) { 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). // Skip clip instances not active at current time (compare in seconds).
let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO); let clip_dur = ClipDuration::Seconds(
self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO),
);
let tempo_map = self.ctx.document.tempo_map(); let tempo_map = self.ctx.document.tempo_map();
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let instance_end = tempo_map.beats_to_seconds( let instance_end = tempo_map.beats_to_seconds(
@ -2310,7 +2315,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
} }
// Calculate clip-local time // Calculate clip-local time
let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
// Get dynamic clip bounds from content at current time // 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) { let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) {
@ -2680,7 +2685,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Find clip instance visible at playback time // Find clip instance visible at playback time
let visible_clip = video_layer.clip_instances.iter().find(|inst| { let visible_clip = video_layer.clip_instances.iter().find(|inst| {
let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO); let clip_duration = ClipDuration::Seconds(
self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO),
);
let tempo_map = self.ctx.document.tempo_map(); let tempo_map = self.ctx.document.tempo_map();
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); 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(); let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();
@ -10153,7 +10160,7 @@ impl StagePane {
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
// Calculate clip-local time // Calculate clip-local time
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); 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; let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
// Get dynamic clip bounds from content at current time // Get dynamic clip bounds from content at current time
use vello::kurbo::Rect as KurboRect; use vello::kurbo::Rect as KurboRect;
@ -10354,7 +10361,7 @@ impl StagePane {
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) {
// Calculate clip-local time // Calculate clip-local time
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); 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; let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
// Get dynamic clip bounds from content at current time // 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) { let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) {
@ -11070,7 +11077,9 @@ impl StagePane {
let document = shared.action_executor.document(); let document = shared.action_executor.document();
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) { if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
video_layer.clip_instances.iter().find(|inst| { video_layer.clip_instances.iter().find(|inst| {
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO); let clip_duration = ClipDuration::Seconds(
document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO),
);
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); 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(); let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();

View File

@ -7,8 +7,8 @@
/// - Basic layer visualization /// - Basic layer visualization
use eframe::egui; use eframe::egui;
use daw_backend::{Beats, Seconds}; use daw_backend::{Beats, ContentTime, Seconds};
use lightningbeam_core::clip::ClipInstance; use lightningbeam_core::clip::{ClipDuration, ClipInstance};
use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait};
use crate::mobile::icons; use crate::mobile::icons;
use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
@ -116,7 +116,7 @@ fn compute_clip_stacking(
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
// Stacking only needs relative overlap, so compare in the beats domain. // Stacking only needs relative overlap, so compare in the beats domain.
let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| { let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO); let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO));
let start = ci.effective_start(); let start = ci.effective_start();
let end = start + ci.total_duration(clip_dur, tempo_map); let end = start + ci.total_duration(clip_dur, tempo_map);
(start.beats_to_f64(), end.beats_to_f64()) (start.beats_to_f64(), end.beats_to_f64())
@ -230,11 +230,14 @@ fn draw_video_thumbnail_strip(
/// Get the effective clip duration for a clip instance on a given layer. /// Get the effective clip duration for a clip instance on a given layer.
/// For groups on vector layers, the duration spans all consecutive keyframes /// For groups on vector layers, the duration spans all consecutive keyframes
/// where the group is present. For regular clips, returns the clip's internal duration. /// where the group is present. For regular clips, returns the clip's internal duration.
/// A clip's content duration **in its own domain** — seconds for vector/video/effect and sampled
/// audio, BEATS for MIDI. Returning a `ClipDuration` rather than bare `Seconds` is what lets the
/// instance's trim bounds (which are content times, in that same domain) be resolved correctly.
fn effective_clip_duration( fn effective_clip_duration(
document: &lightningbeam_core::document::Document, document: &lightningbeam_core::document::Document,
layer: &AnyLayer, layer: &AnyLayer,
clip_instance: &ClipInstance, clip_instance: &ClipInstance,
) -> Option<Seconds> { ) -> Option<ClipDuration> {
match layer { match layer {
AnyLayer::Vector(vl) => { AnyLayer::Vector(vl) => {
let vc = document.get_vector_clip(&clip_instance.clip_id)?; let vc = document.get_vector_clip(&clip_instance.clip_id)?;
@ -242,17 +245,21 @@ fn effective_clip_duration(
let frame_duration = 1.0 / document.framerate; let frame_duration = 1.0 / document.framerate;
let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration); let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration);
Some(Seconds((end - start_secs).max(0.0))) Some(ClipDuration::Seconds(Seconds((end - start_secs).max(0.0))))
} else { } else {
// Movie clips: duration based on all internal content (keyframes + clip instances) // Movie clips: duration based on all internal content (keyframes + clip instances)
document.get_clip_duration(&clip_instance.clip_id) document.get_clip_duration(&clip_instance.clip_id).map(ClipDuration::Seconds)
} }
} }
// Delegate to get_clip_duration so MIDI clips (whose `duration` is stored in beats, // An audio layer can hold a sampled clip (seconds content) or a MIDI clip (beats content),
// not seconds) are converted correctly rather than read as raw seconds. // so ask the clip which it is rather than flattening both to seconds.
AnyLayer::Audio(_) => document.get_clip_duration(&clip_instance.clip_id), AnyLayer::Audio(_) => document.clip_trim_duration(&clip_instance.clip_id),
AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)), AnyLayer::Video(_) => document
AnyLayer::Effect(_) => Some(Seconds(lightningbeam_core::effect::EFFECT_DURATION)), .get_video_clip(&clip_instance.clip_id)
.map(|c| ClipDuration::Seconds(Seconds(c.duration))),
AnyLayer::Effect(_) => Some(ClipDuration::Seconds(Seconds(
lightningbeam_core::effect::EFFECT_DURATION,
))),
AnyLayer::Group(_) => None, AnyLayer::Group(_) => None,
AnyLayer::Raster(_) => None, AnyLayer::Raster(_) => None,
AnyLayer::Text(_) => None, AnyLayer::Text(_) => None,
@ -313,6 +320,18 @@ pub struct TimelinePane {
/// during the last `render_layers`. Used by `handle_input` (next frame) to snap the /// during the last `render_layers`. Used by `handle_input` (next frame) to snap the
/// playhead exactly to a keyframe when its diamond is clicked. /// playhead exactly to a keyframe when its diamond is clicked.
keyframe_diamond_hits: Vec<(egui::Rect, f64)>, keyframe_diamond_hits: Vec<(egui::Rect, f64)>,
/// Take-badge click targets recorded during render: (badge rect, layer, instance, active take,
/// take count). Collected while painting, dispatched after — the usual two-phase pattern.
take_badge_hits: Vec<(egui::Rect, uuid::Uuid, uuid::Uuid, usize, usize)>,
/// The take-folder instance whose take menu is open, if any.
open_take_menu: Option<(uuid::Uuid, uuid::Uuid)>,
/// The (instance, take index) being renamed inline in the take menu.
renaming_take: Option<(uuid::Uuid, usize)>,
take_rename_buffer: String,
/// Seconds between the cycle region's start and where the current recording actually began.
/// Zero unless the user punched in mid-region. Used to line the live waveform preview up with
/// the region on each pass.
cycle_record_lead_secs: f64,
/// Total duration of the animation /// Total duration of the animation
duration: f64, duration: f64,
@ -750,6 +769,11 @@ impl TimelinePane {
viewport_start_time: 0.0, viewport_start_time: 0.0,
viewport_scroll_y: 0.0, viewport_scroll_y: 0.0,
keyframe_diamond_hits: Vec::new(), keyframe_diamond_hits: Vec::new(),
take_badge_hits: Vec::new(),
open_take_menu: None,
renaming_take: None,
take_rename_buffer: String::new(),
cycle_record_lead_secs: 0.0,
duration: 10.0, // Default 10 seconds duration: 10.0, // Default 10 seconds
is_scrubbing: false, is_scrubbing: false,
cycle_drag: None, cycle_drag: None,
@ -874,7 +898,9 @@ impl TimelinePane {
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.map(|k| crate::curve_editor::CurvePoint { .map(|k| crate::curve_editor::CurvePoint {
time: k.time, // beats (backend stores beats; curve editor x-axis is beats) // The curve editor's x-axis is beats, same as the backend — unwrap at this
// boundary because CurvePoint stores a plain f64.
time: k.time.beats_to_f64(),
value: k.value, value: k.value,
interpolation: match k.interpolation.as_str() { interpolation: match k.interpolation.as_str() {
"bezier" => crate::curve_editor::CurveInterpolation::Bezier, "bezier" => crate::curve_editor::CurveInterpolation::Bezier,
@ -1076,7 +1102,41 @@ impl TimelinePane {
true true
}); });
let start_time = *shared.playback_time; let mut start_time = *shared.playback_time;
// With a cycle region armed, a recording is anchored at the REGION start rather than the
// playhead: every take spans the whole region, so the clip has to as well.
//
// Two ways in. From stopped, we also move the playhead to the region start, so recording
// begins with the loop (the count-in below then rolls in from a measure before it). Punching
// in while already rolling leaves the playhead where it is — the backend prepends silence to
// take 1's head to fill the gap back to the region start.
let cycle_start_secs = {
let doc = shared.action_executor.document();
match (doc.cycle_enabled, doc.cycle_region) {
(true, Some((ls, le))) if le > ls => {
Some(doc.tempo_map().beats_to_seconds(ls).seconds_to_f64())
}
_ => None,
}
};
if let Some(ls_secs) = cycle_start_secs {
// How far into the region we punched in (zero when starting from stopped, since we jump
// the playhead to the region start below). The live waveform preview needs this to know
// where each pass begins inside the recording buffer.
self.cycle_record_lead_secs = if *shared.is_playing {
(*shared.playback_time - ls_secs).max(0.0)
} else {
0.0
};
start_time = ls_secs;
if !*shared.is_playing {
if let Some(controller_arc) = shared.audio_controller {
controller_arc.lock().unwrap().seek(Seconds(ls_secs));
}
*shared.playback_time = ls_secs;
}
}
// Count-in: seek back N beats, start transport + metronome, defer ALL recording commands. // Count-in: seek back N beats, start transport + metronome, defer ALL recording commands.
// Must happen before Step 4 so no clips or backend recordings are created yet. // Must happen before Step 4 so no clips or backend recordings are created yet.
@ -1120,6 +1180,28 @@ impl TimelinePane {
// The backend records in the beats domain; start_time is the seconds playhead. // The backend records in the beats domain; start_time is the seconds playhead.
let start_beats = shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(start_time)); let start_beats = shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(start_time));
// Does this layer already hold takes over the cycle region? If so this recording is another
// take, however short it runs — without this, stopping before the loop came round would land
// it as a separate overlapping clip instead of joining the take list. The engine can't work
// this out for itself: whether takes exist is document state.
let force_takes: std::collections::HashMap<uuid::Uuid, bool> = {
let doc = shared.action_executor.document();
let region = match (doc.cycle_enabled, doc.cycle_region) {
(true, Some((ls, le))) if le > ls => Some((ls, le - ls)),
_ => None,
};
candidates
.iter()
.map(|&(layer_id, _, _)| {
let forced = region.is_some_and(|(loop_start, loop_len)| {
doc.take_folder_at(&layer_id, loop_start, loop_len, &uuid::Uuid::nil())
.is_some()
});
(layer_id, forced)
})
.collect()
};
// Step 4: Dispatch recording for each candidate // Step 4: Dispatch recording for each candidate
for &(layer_id, ref cat, _) in &candidates { for &(layer_id, ref cat, _) in &candidates {
match cat { match cat {
@ -1143,7 +1225,7 @@ impl TimelinePane {
} }
if let Some(controller_arc) = shared.audio_controller { if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
controller.start_recording(track_id, start_beats); controller.start_recording(track_id, start_beats, force_takes.get(&layer_id).copied().unwrap_or(false));
println!("🎤 Started audio recording on track {:?} at {:.2}s", track_id, start_time); println!("🎤 Started audio recording on track {:?} at {:.2}s", track_id, start_time);
} }
shared.recording_layer_ids.push(layer_id); shared.recording_layer_ids.push(layer_id);
@ -1156,7 +1238,7 @@ impl TimelinePane {
if let Some(controller_arc) = shared.audio_controller { if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
let clip_id = controller.create_midi_clip(track_id, start_beats, Beats::ZERO); let clip_id = controller.create_midi_clip(track_id, start_beats, Beats::ZERO);
controller.start_midi_recording(track_id, clip_id, start_beats); controller.start_midi_recording(track_id, clip_id, start_beats, force_takes.get(&layer_id).copied().unwrap_or(false));
shared.recording_clips.insert(layer_id, clip_id); shared.recording_clips.insert(layer_id, clip_id);
println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}", println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}",
track_id, start_time, clip_id); track_id, start_time, clip_id);
@ -1422,8 +1504,10 @@ impl TimelinePane {
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
for (_child_layer_id, ci) in &child_clips { for (_child_layer_id, ci) in &child_clips {
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| {
Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) ClipDuration::Seconds(Seconds(
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
))
}); });
let start = ci.effective_start(); let start = ci.effective_start();
let end = start + ci.total_duration(clip_dur, tempo_map); let end = start + ci.total_duration(clip_dur, tempo_map);
@ -1621,6 +1705,138 @@ impl TimelinePane {
painter.rect_filled(band, 2.0, fill); painter.rect_filled(band, 2.0, fill);
} }
/// Click handling + dropdown for the take badge painted on take-folder clips.
///
/// Runs after rendering, off the hit rects collected during it: clicking a badge opens (or
/// closes) a list of the clip's takes, and picking one dispatches `SetActiveTakeAction`.
/// Selection is per-*instance*, so doing this to one half of a split clip and something else to
/// the other half is exactly how you comp.
fn render_take_menu(
&mut self,
ui: &mut egui::Ui,
document: &lightningbeam_core::document::Document,
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
) {
let click = ui.input(|i| {
i.pointer
.primary_pressed()
.then(|| i.pointer.interact_pos())
.flatten()
});
if let Some(pos) = click {
if let Some((_, layer_id, instance_id, _, _)) =
self.take_badge_hits.iter().find(|(r, ..)| r.contains(pos))
{
let key = (*layer_id, *instance_id);
// Clicking the badge of the open menu closes it again.
self.open_take_menu = (self.open_take_menu != Some(key)).then_some(key);
}
}
let Some((layer_id, instance_id)) = self.open_take_menu else {
return;
};
// The badge is only in the hit list while it's on screen; if the clip scrolled away, the
// menu has nothing to hang off, so drop it.
let Some((badge, _, _, active, count)) = self
.take_badge_hits
.iter()
.find(|(_, l, i, _, _)| *l == layer_id && *i == instance_id)
.copied()
else {
self.open_take_menu = None;
return;
};
let Some(instance) = document
.get_layer(&layer_id)
.and_then(|l| match l {
lightningbeam_core::layer::AnyLayer::Audio(al) => {
al.clip_instances.iter().find(|ci| ci.id == instance_id)
}
_ => None,
})
else {
self.open_take_menu = None;
return;
};
// The instance's *stored* selection, which is what rollback must restore — not `active`,
// which is that value clamped for display.
let old_take = instance.active_take;
let take_names: Vec<String> = instance.takes.iter().map(|t| t.name.clone()).collect();
let mut close = false;
let area = egui::Area::new(ui.id().with(("take_menu", instance_id)))
.order(egui::Order::Foreground)
.fixed_pos(egui::pos2(badge.min.x, badge.max.y + 2.0))
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
for i in 0..count {
let is_active = i == active;
let renaming = self.renaming_take == Some((instance_id, i));
ui.horizontal(|ui| {
if renaming {
let edit = ui.add(
egui::TextEdit::singleline(&mut self.take_rename_buffer)
.desired_width(110.0),
);
edit.request_focus();
// Commit on Enter or on clicking away; Escape abandons.
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
if enter || edit.lost_focus() && !escape {
let name = self.take_rename_buffer.trim().to_string();
if !name.is_empty() && name != take_names[i] {
pending_actions.push(Box::new(
lightningbeam_core::actions::RenameTakeAction::new(
layer_id, instance_id, i, name,
),
));
}
self.renaming_take = None;
} else if escape {
self.renaming_take = None;
}
return;
}
let label = ui.selectable_label(is_active, &take_names[i]);
if label.clicked() {
if !is_active {
pending_actions.push(Box::new(
lightningbeam_core::actions::SetActiveTakeAction::new(
layer_id, instance_id, i, old_take,
),
));
}
close = true;
}
// Double-click a take to rename it in place. Deleting lives on the clip's
// right-click menu, not here — a trash icon per row is a small target
// sitting right next to the one you actually meant to click.
if label.double_clicked() {
self.renaming_take = Some((instance_id, i));
self.take_rename_buffer = take_names[i].clone();
}
});
}
});
});
// A press anywhere outside the menu (and outside the badge, which toggles) dismisses it.
if let Some(pos) = click {
if !badge.contains(pos) && !area.response.rect.contains(pos) {
close = true;
}
}
if close {
self.open_take_menu = None;
self.renaming_take = None;
}
}
/// Convert time (seconds) to pixel x-coordinate /// Convert time (seconds) to pixel x-coordinate
fn time_to_x(&self, time: f64) -> f32 { fn time_to_x(&self, time: f64) -> f32 {
((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32 ((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32
@ -1642,15 +1858,18 @@ impl TimelinePane {
/// Effective on-timeline duration for a clip instance, in seconds. /// Effective on-timeline duration for a clip instance, in seconds.
/// ///
/// `total_duration` is in beats; converts to seconds using the current (preview) BPM. /// `total_duration` is in beats; converts to seconds using the current (preview) BPM.
fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, tempo_map: &daw_backend::TempoMap) -> f64 { fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, tempo_map: &daw_backend::TempoMap) -> f64 {
(tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) (tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_content, tempo_map))
- tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64() - tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64()
} }
/// Returns the clip content start (trim_start) and duration in display seconds. /// The clip's content start (trim_start) and window length, as raw magnitudes in the clip's own
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, _bpm: f64) -> (f64, f64) { /// content domain — seconds for audio/video/vector, beats for MIDI. The drag/preview math below
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs.seconds_to_f64()); /// works in that domain throughout, converting to the timeline only at the edges.
(ci.trim_start, (trim_end - ci.trim_start).max(0.0)) fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, _bpm: f64) -> (f64, f64) {
let trim_end = ci.trim_end.map_or(clip_content.native(), |t| t.raw());
let start = ci.trim_start.raw();
(start, (trim_end - start).max(0.0))
} }
/// Convert pixel x-coordinate to time (seconds) /// Convert pixel x-coordinate to time (seconds)
@ -3011,6 +3230,7 @@ impl TimelinePane {
let mut pending_lane_renders: Vec<AutomationLaneRender> = Vec::new(); let mut pending_lane_renders: Vec<AutomationLaneRender> = Vec::new();
// Rebuilt each frame; consumed by handle_input (next frame) for click-to-seek. // Rebuilt each frame; consumed by handle_input (next frame) for click-to-seek.
self.keyframe_diamond_hits.clear(); self.keyframe_diamond_hits.clear();
self.take_badge_hits.clear();
// Collect video clip rects for hover detection (to avoid borrow conflicts) // Collect video clip rects for hover detection (to avoid borrow conflicts)
let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f32)> = Vec::new(); let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f32)> = Vec::new();
@ -3228,8 +3448,10 @@ impl TimelinePane {
let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move); let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move);
let mut ranges: Vec<(Beats, Beats)> = Vec::new(); let mut ranges: Vec<(Beats, Beats)> = Vec::new();
for (_child_layer_id, ci) in &child_clips { for (_child_layer_id, ci) in &child_clips {
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| {
Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) ClipDuration::Seconds(Seconds(
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
))
}); });
let mut start = ci.effective_start(); let mut start = ci.effective_start();
let dur = ci.total_duration(clip_dur, document.tempo_map()); let dur = ci.total_duration(clip_dur, document.tempo_map());
@ -3302,8 +3524,10 @@ impl TimelinePane {
if let Some(video_child) = g.children.iter().find(|c| matches!(c, AnyLayer::Video(_))) { if let Some(video_child) = g.children.iter().find(|c| matches!(c, AnyLayer::Video(_))) {
if let AnyLayer::Video(vl) = video_child { if let AnyLayer::Video(vl) = video_child {
for ci in &vl.clip_instances { for ci in &vl.clip_instances {
let clip_dur = document.get_clip_duration(&ci.clip_id) let clip_dur = document.clip_trim_duration(&ci.clip_id)
.unwrap_or_else(|| Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); .unwrap_or_else(|| ClipDuration::Seconds(Seconds(
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
)));
let mut ci_start = ci.effective_start(); let mut ci_start = ci.effective_start();
if is_move_drag && selection.contains_clip_instance(&ci.id) { if is_move_drag && selection.contains_clip_instance(&ci.id) {
ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate); ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
@ -3330,7 +3554,8 @@ impl TimelinePane {
); );
// 4th elem = clip's TRUE (unclamped) origin x, for correct // 4th elem = clip's TRUE (unclamped) origin x, for correct
// hover content time when scrolled partly off the left. // hover content time when scrolled partly off the left.
video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, rect.min.x + sx)); // Video content is wall-clock, so its content time IS seconds.
video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start.raw(), rect.min.x + sx));
let thumb_display_height = (thumb_y_max - span_y_min) - 4.0; let thumb_display_height = (thumb_y_max - span_y_min) - 4.0;
if thumb_display_height > 8.0 { if thumb_display_height > 8.0 {
@ -3343,7 +3568,7 @@ impl TimelinePane {
&video_mgr, &video_mgr,
&mut self.video_thumbnail_textures, &mut self.video_thumbnail_textures,
ci.clip_id, ci.clip_id,
ci.trim_start, ci.trim_start.raw(),
rect.min.x + sx, rect.min.x + sx,
ex - sx, ex - sx,
ci_rect, ci_rect,
@ -3392,7 +3617,7 @@ impl TimelinePane {
}; };
let audio_file_duration = total_frames as f64 / eff_sr as f64; let audio_file_duration = total_frames as f64 / eff_sr as f64;
let clip_dur = audio_clip.content_duration().to_seconds(document.tempo_map()); let clip_dur = audio_clip.content_duration();
let mut ci_start = ci.effective_start(); let mut ci_start = ci.effective_start();
if is_move_drag && selection.contains_clip_instance(&ci.id) { if is_move_drag && selection.contains_clip_instance(&ci.id) {
ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate); ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
@ -3443,7 +3668,8 @@ impl TimelinePane {
audio_duration: audio_file_duration as f32, audio_duration: audio_file_duration as f32,
sample_rate: eff_sr, sample_rate: eff_sr,
clip_start_time: ci_screen_start, clip_start_time: ci_screen_start,
trim_start: ci.trim_start as f32, // A sampled clip's content time is seconds.
trim_start: ci.trim_start.raw() as f32,
tex_width: crate::waveform_gpu::tex_width() as f32, tex_width: crate::waveform_gpu::tex_width() as f32,
total_frames: total_frames as f32, total_frames: total_frames as f32,
segment_start_frame: 0.0, segment_start_frame: 0.0,
@ -3526,7 +3752,7 @@ impl TimelinePane {
let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter() let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter()
.filter(|ci| selection.contains_clip_instance(&ci.id)) .filter(|ci| selection.contains_clip_instance(&ci.id))
.filter_map(|ci| { .filter_map(|ci| {
let dur = document.get_clip_duration(&ci.clip_id)?; let dur = document.clip_trim_duration(&ci.clip_id)?;
Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map()))) Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map())))
}) })
.collect(); .collect();
@ -3550,8 +3776,11 @@ impl TimelinePane {
let shift_beats = |anchor: Beats, secs: f64| let shift_beats = |anchor: Beats, secs: f64|
tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs)); tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs));
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO); let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO));
let clip_dur_secs = clip_dur.seconds_to_f64(); // Raw magnitudes in the clip's own content domain — the drag math below stays in
// that domain and only converts at the timeline edges.
let clip_dur_secs = clip_dur.native();
let ci_trim_start = ci.trim_start.raw();
let mut start = ci.effective_start(); let mut start = ci.effective_start();
let mut duration = ci.total_duration(clip_dur, tmap); let mut duration = ci.total_duration(clip_dur, tmap);
@ -3573,25 +3802,25 @@ impl TimelinePane {
} }
} }
ClipDragType::TrimLeft => { ClipDragType::TrimLeft => {
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs); let new_trim = self.snap_to_grid(ci_trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs);
let trim_offset_secs = new_trim - ci.trim_start; let trim_offset_secs = new_trim - ci_trim_start;
start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO); start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO);
let dur_secs = if let Some(trim_end) = ci.trim_end { let dur_secs = if let Some(trim_end) = ci.trim_end {
(trim_end - new_trim).max(0.0) (trim_end.raw() - new_trim).max(0.0)
} else { } else {
(clip_dur_secs - new_trim).max(0.0) (clip_dur_secs - new_trim).max(0.0)
}; };
duration = secs_to_beats_at(start, dur_secs); duration = secs_to_beats_at(start, dur_secs);
} }
ClipDragType::TrimRight => { ClipDragType::TrimRight => {
let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs); let old_trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw());
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci.trim_start).min(clip_dur_secs); let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci_trim_start).min(clip_dur_secs);
let dur_secs = (new_trim_end - ci.trim_start).max(0.0); let dur_secs = (new_trim_end - ci_trim_start).max(0.0);
duration = secs_to_beats_at(start, dur_secs); duration = secs_to_beats_at(start, dur_secs);
} }
ClipDragType::LoopExtendRight => { ClipDragType::LoopExtendRight => {
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw());
let content_window_secs = (trim_end - ci.trim_start).max(0.0); let content_window_secs = (trim_end - ci_trim_start).max(0.0);
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs); let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
let current_right = ci.timeline_duration.unwrap_or(content_window); let current_right = ci.timeline_duration.unwrap_or(content_window);
let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset; let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset;
@ -3602,8 +3831,8 @@ impl TimelinePane {
duration = loop_before + new_right; duration = loop_before + new_right;
} }
ClipDragType::LoopExtendLeft => { ClipDragType::LoopExtendLeft => {
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw());
let content_window_secs = (trim_end - ci.trim_start).max(0.001); let content_window_secs = (trim_end - ci_trim_start).max(0.001);
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs); let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO); let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
// drag_offset (seconds) as a beats delta at this clip's start. // drag_offset (seconds) as a beats delta at this clip's start.
@ -3664,6 +3893,9 @@ impl TimelinePane {
// Track preview trim values for note/waveform rendering. // Track preview trim values for note/waveform rendering.
// In Measures mode, derive from beats so they track BPM during live drag. // In Measures mode, derive from beats so they track BPM during live drag.
let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm()); let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm());
// The instance's trim start as a raw magnitude in the clip's content domain; the
// preview math below stays in that domain.
let ci_trim_start = clip_instance.trim_start.raw();
let mut preview_trim_start = base_trim_start; let mut preview_trim_start = base_trim_start;
let mut preview_clip_duration = base_clip_duration; let mut preview_clip_duration = base_clip_duration;
@ -3679,11 +3911,11 @@ impl TimelinePane {
} }
ClipDragType::TrimLeft => { ClipDragType::TrimLeft => {
// Trim left: calculate new trim_start with snap to adjacent clips // Trim left: calculate new trim_start with snap to adjacent clips
let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) let desired_trim_start = self.snap_to_grid(ci_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE)
.max(0.0) .max(0.0)
.min(clip_duration.seconds_to_f64()); .min(clip_duration.native());
let new_trim_start = if desired_trim_start < clip_instance.trim_start { let new_trim_start = if desired_trim_start < ci_trim_start {
// Extending left - limit is the content-seconds gap to the previous clip. // Extending left - limit is the content-seconds gap to the previous clip.
let max_extend_secs = document.find_max_trim_extend_left( let max_extend_secs = document.find_max_trim_extend_left(
&layer.id(), &layer.id(),
@ -3691,25 +3923,25 @@ impl TimelinePane {
clip_instance.effective_start(), clip_instance.effective_start(),
).seconds_to_f64(); ).seconds_to_f64();
let desired_extend = clip_instance.trim_start - desired_trim_start; let desired_extend = ci_trim_start - desired_trim_start;
let actual_extend = desired_extend.min(max_extend_secs); let actual_extend = desired_extend.min(max_extend_secs);
clip_instance.trim_start - actual_extend ci_trim_start - actual_extend
} else { } else {
// Shrinking - no snap needed // Shrinking - no snap needed
desired_trim_start desired_trim_start
}; };
let actual_offset = new_trim_start - clip_instance.trim_start; let actual_offset = new_trim_start - ci_trim_start;
// Move start (display seconds) and reduce duration by the clamped offset. // Move start (display seconds) and reduce duration by the clamped offset.
instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset) instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset)
.max(0.0); .max(0.0);
instance_duration = (clip_duration.seconds_to_f64() - new_trim_start).max(0.0); instance_duration = (clip_duration.native() - new_trim_start).max(0.0);
// Adjust for existing trim_end // Adjust for existing trim_end
if let Some(trim_end) = clip_instance.trim_end { if let Some(trim_end) = clip_instance.trim_end {
instance_duration = (trim_end - new_trim_start).max(0.0); instance_duration = (trim_end.raw() - new_trim_start).max(0.0);
} }
// Update preview trim for waveform rendering // Update preview trim for waveform rendering
@ -3718,14 +3950,14 @@ impl TimelinePane {
} }
ClipDragType::TrimRight => { ClipDragType::TrimRight => {
// Trim right: extend or reduce duration with snap to adjacent clips // Trim right: extend or reduce duration with snap to adjacent clips
let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let old_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE)
.max(clip_instance.trim_start) .max(ci_trim_start)
.min(clip_duration.seconds_to_f64()); .min(clip_duration.native());
let new_trim_end = if desired_trim_end > old_trim_end { let new_trim_end = if desired_trim_end > old_trim_end {
// Extending right - limit is the content-seconds gap to the next clip. // Extending right - limit is the content-seconds gap to the next clip.
let current_duration_secs = old_trim_end - clip_instance.trim_start; let current_duration_secs = old_trim_end - ci_trim_start;
let tmap = document.tempo_map(); let tmap = document.tempo_map();
let current_duration = tmap.seconds_to_beats( let current_duration = tmap.seconds_to_beats(
tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs) tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs)
@ -3745,7 +3977,7 @@ impl TimelinePane {
desired_trim_end desired_trim_end
}; };
instance_duration = (new_trim_end - clip_instance.trim_start).max(0.0); instance_duration = (new_trim_end - ci_trim_start).max(0.0);
// Update preview clip duration for waveform rendering // Update preview clip duration for waveform rendering
// (the waveform system uses clip_duration to determine visible range) // (the waveform system uses clip_duration to determine visible range)
@ -3753,8 +3985,8 @@ impl TimelinePane {
} }
ClipDragType::LoopExtendRight => { ClipDragType::LoopExtendRight => {
// Loop extend right: extend clip beyond content window // Loop extend right: extend clip beyond content window
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); let content_window_secs = (trim_end - ci_trim_start).max(0.0);
let tmap = document.tempo_map(); let tmap = document.tempo_map();
let ts = clip_instance.timeline_start; let ts = clip_instance.timeline_start;
// content window and right-duration are beats-domain timeline spans. // content window and right-duration are beats-domain timeline spans.
@ -3789,8 +4021,8 @@ impl TimelinePane {
ClipDragType::LoopExtendLeft => { ClipDragType::LoopExtendLeft => {
// Loop extend left: extend loop_before (pre-loop region) // Loop extend left: extend loop_before (pre-loop region)
// Snap to multiples of content_window so iterations align with backend // Snap to multiples of content_window so iterations align with backend
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); let content_window_secs = (trim_end - ci_trim_start).max(0.001);
let tmap = document.tempo_map(); let tmap = document.tempo_map();
let ts = clip_instance.timeline_start; let ts = clip_instance.timeline_start;
// content window is a beats-domain span; guard against zero for division. // content window is a beats-domain span; guard against zero for division.
@ -3916,9 +4148,11 @@ impl TimelinePane {
// AUDIO VISUALIZATION: Draw piano roll or waveform overlay // AUDIO VISUALIZATION: Draw piano roll or waveform overlay
if let lightningbeam_core::layer::AnyLayer::Audio(_) = layer { if let lightningbeam_core::layer::AnyLayer::Audio(_) = layer {
if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) { if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) {
match &clip.clip_type { // Resolve through the instance's active take, so a take folder draws
// whichever take it actually plays.
match &clip_instance.resolve(clip) {
// MIDI: Draw piano roll (with loop iterations) // MIDI: Draw piano roll (with loop iterations)
lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } => { lightningbeam_core::clip::ResolvedContent::Midi { midi_clip_id } => {
if let Some(events) = midi_event_cache.get(midi_clip_id) { if let Some(events) = midi_event_cache.get(midi_clip_id) {
// Calculate content window for loop detection // Calculate content window for loop detection
// preview_clip_duration accounts for TrimLeft/TrimRight drag previews // preview_clip_duration accounts for TrimLeft/TrimRight drag previews
@ -3977,7 +4211,7 @@ impl TimelinePane {
} }
} }
// Sampled Audio: Draw waveform via GPU // Sampled Audio: Draw waveform via GPU
lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } => { lightningbeam_core::clip::ResolvedContent::Audio { audio_pool_index } => {
if let Some((samples, sr, ch)) = raw_audio_cache.get(audio_pool_index) { if let Some((samples, sr, ch)) = raw_audio_cache.get(audio_pool_index) {
// Min/max overview pools: 4 f32/texel at rate sr/B. // Min/max overview pools: 4 f32/texel at rate sr/B.
let minmax_b = waveform_minmax_pools.get(audio_pool_index).copied(); let minmax_b = waveform_minmax_pools.get(audio_pool_index).copied();
@ -4028,7 +4262,7 @@ impl TimelinePane {
// Calculate content window for loop detection // Calculate content window for loop detection
// Use trimmed content window (preview_trim_start accounts for TrimLeft drag) // Use trimmed content window (preview_trim_start accounts for TrimLeft drag)
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let preview_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
let content_window = (preview_trim_end - preview_trim_start).max(0.0); let content_window = (preview_trim_end - preview_trim_start).max(0.0);
let is_looping = instance_duration > content_window + 0.001; let is_looping = instance_duration > content_window + 0.001;
@ -4106,7 +4340,7 @@ impl TimelinePane {
} }
} }
// Recording in progress: show live waveform // Recording in progress: show live waveform
lightningbeam_core::clip::AudioClipType::Recording => { lightningbeam_core::clip::ResolvedContent::Recording => {
let rec_pool_idx = usize::MAX; let rec_pool_idx = usize::MAX;
if let Some((samples, sr, ch)) = raw_audio_cache.get(&rec_pool_idx) { if let Some((samples, sr, ch)) = raw_audio_cache.get(&rec_pool_idx) {
let total_frames = samples.len() / (*ch).max(1) as usize; let total_frames = samples.len() / (*ch).max(1) as usize;
@ -4141,6 +4375,36 @@ impl TimelinePane {
egui::pos2(clip_screen_end.min(clip_rect.max.x), clip_rect.max.y), egui::pos2(clip_screen_end.min(clip_rect.max.x), clip_rect.max.y),
); );
// Cycle recording: the clip covers ONE region, but the
// recorded buffer keeps growing across passes. Show the
// *current* pass by offsetting into the buffer to where
// that pass began, so the waveform restarts at the region
// start on each wrap and fills in behind the playhead —
// rather than running on past the clip's end.
//
// The playhead gives the position within the region
// directly, so a punch-in (whose first pass starts partway
// in) needs no extra bookkeeping here.
let mut rec_trim_start = preview_trim_start;
if let (true, Some((ls, le))) = (document.cycle_enabled, document.cycle_region) {
let tm = document.tempo_map();
let loop_len = (tm.beats_to_seconds(le)
- tm.beats_to_seconds(ls))
.seconds_to_f64();
if loop_len > 0.0 {
// Which pass we're on, straight from how much
// audio has been captured. Deriving this from
// the playhead instead would jitter: the
// playhead and the recording buffer advance on
// different clocks, so their difference wobbles
// frame to frame and the waveform slides
// horizontally.
let lead = self.cycle_record_lead_secs;
let pass = ((lead + audio_file_duration) / loop_len).floor();
rec_trim_start = (pass * loop_len - lead).max(0.0);
}
}
if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 { if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 {
let instance_id = clip_instance.id.as_u128() as u64; let instance_id = clip_instance.id.as_u128() as u64;
let callback = crate::waveform_gpu::WaveformCallback { let callback = crate::waveform_gpu::WaveformCallback {
@ -4153,7 +4417,7 @@ impl TimelinePane {
audio_duration: audio_file_duration as f32, audio_duration: audio_file_duration as f32,
sample_rate: *sr as f32, sample_rate: *sr as f32,
clip_start_time: clip_screen_start, clip_start_time: clip_screen_start,
trim_start: preview_trim_start as f32, trim_start: rec_trim_start as f32,
tex_width: crate::waveform_gpu::tex_width() as f32, tex_width: crate::waveform_gpu::tex_width() as f32,
total_frames: total_frames as f32, total_frames: total_frames as f32,
segment_start_frame: 0.0, segment_start_frame: 0.0,
@ -4195,7 +4459,7 @@ impl TimelinePane {
&video_mgr, &video_mgr,
&mut self.video_thumbnail_textures, &mut self.video_thumbnail_textures,
clip_instance.clip_id, clip_instance.clip_id,
clip_instance.trim_start, clip_instance.trim_start.raw(),
rect.min.x + start_x, rect.min.x + start_x,
end_x - start_x, end_x - start_x,
clip_rect, clip_rect,
@ -4210,7 +4474,7 @@ impl TimelinePane {
// clip's TRUE (unclamped) origin x so the hover content time is // clip's TRUE (unclamped) origin x so the hover content time is
// correct even when the clip is scrolled partly off the left. // correct even when the clip is scrolled partly off the left.
if let lightningbeam_core::layer::AnyLayer::Video(_) = layer { if let lightningbeam_core::layer::AnyLayer::Video(_) = layer {
video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, rect.min.x + start_x)); video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start.raw(), rect.min.x + start_x));
} }
// Draw border per segment (per loop iteration for looping clips) // Draw border per segment (per loop iteration for looping clips)
@ -4267,6 +4531,66 @@ impl TimelinePane {
); );
} }
} }
// Take badge — "Take 2/4" in the clip's bottom-left, on take-folder clips
// only. Records a hit rect so the click that opens the take menu can be
// dispatched after rendering (the usual two-phase pattern), rather than
// mutating the document mid-paint.
// Only worth showing when there's actually a choice to make.
if clip_instance.takes.len() > 1 {
let take_count = clip_instance.takes.len();
let active = clip_instance.active_take_index();
let label = format!("Take {}/{}", active + 1, take_count);
let text_color = theme.text_color(
&["#timeline", ".take-badge"],
ui.ctx(),
egui::Color32::WHITE,
);
let galley = painter.layout_no_wrap(
label,
egui::FontId::proportional(10.0),
text_color,
);
let pad = egui::vec2(4.0, 2.0);
let size = galley.size() + pad * 2.0;
// Bottom-left of the clip, but only when the clip is wide enough that
// the badge wouldn't swamp it.
if clip_rect.width() > size.x + 10.0 && clip_rect.height() > size.y + 4.0 {
let badge = egui::Rect::from_min_size(
egui::pos2(
clip_rect.min.x + 4.0,
clip_rect.max.y - size.y - 3.0,
),
size,
);
let hovered = ui
.ctx()
.pointer_hover_pos()
.is_some_and(|p| badge.contains(p));
let bg = if hovered {
theme.bg_color(
&["#timeline", ".take-badge:hover"],
ui.ctx(),
egui::Color32::from_black_alpha(210),
)
} else {
theme.bg_color(
&["#timeline", ".take-badge"],
ui.ctx(),
egui::Color32::from_black_alpha(150),
)
};
painter.rect_filled(badge, 3.0, bg);
painter.galley(badge.min + pad, galley, text_color);
self.take_badge_hits.push((
badge,
layer.id(),
clip_instance.id,
active,
take_count,
));
}
}
} }
} }
} }
@ -4746,7 +5070,12 @@ impl TimelinePane {
if !alt_held && !self.is_scrubbing && !self.is_panning { if !alt_held && !self.is_scrubbing && !self.is_panning {
if response.drag_started() { if response.drag_started() {
// Use cached mousedown position for edge detection // Use cached mousedown position for edge detection
if let Some(mousedown_pos) = self.mousedown_pos { if let Some(mousedown_pos) = self
.mousedown_pos
// A press that landed on a take badge is opening the take menu, not grabbing
// the clip it sits on.
.filter(|p| !self.take_badge_hits.iter().any(|(r, ..)| r.contains(*p)))
{
if let Some((drag_type, clip_id)) = self.detect_clip_at_pointer( if let Some((drag_type, clip_id)) = self.detect_clip_at_pointer(
mousedown_pos, mousedown_pos,
document, document,
@ -4883,18 +5212,23 @@ impl TimelinePane {
for clip_instance in clip_instances { for clip_instance in clip_instances {
if selection.contains_clip_instance(&clip_instance.id) { if selection.contains_clip_instance(&clip_instance.id) {
let clip_duration = effective_clip_duration(document, layer, clip_instance); let clip_duration = effective_clip_duration(document, layer, clip_instance);
// Raw magnitude in the clip's content domain; re-tagged as a
// ContentTime when it goes back into TrimData.
let ci_trim_start = clip_instance.trim_start.raw();
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
match drag_type { match drag_type {
ClipDragType::TrimLeft => { ClipDragType::TrimLeft => {
let old_trim_start = clip_instance.trim_start; // Raw magnitude in the clip's content domain; re-tagged as a
// ContentTime when it goes back into TrimData below.
let old_trim_start = clip_instance.trim_start.raw();
let old_timeline_start = let old_timeline_start =
clip_instance.timeline_start; clip_instance.timeline_start;
// New trim_start is snapped then clamped to valid range // New trim_start is snapped then clamped to valid range
let desired_trim_start = self.snap_to_grid( let desired_trim_start = self.snap_to_grid(
old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE, old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE,
).max(0.0).min(clip_duration.seconds_to_f64()); ).max(0.0).min(clip_duration.native());
// Apply overlap prevention when extending left (content-seconds gap). // Apply overlap prevention when extending left (content-seconds gap).
let new_trim_start = if desired_trim_start < old_trim_start { let new_trim_start = if desired_trim_start < old_trim_start {
@ -4924,11 +5258,11 @@ impl TimelinePane {
clip_instance.id, clip_instance.id,
lightningbeam_core::actions::TrimType::TrimLeft, lightningbeam_core::actions::TrimType::TrimLeft,
lightningbeam_core::actions::TrimData::left( lightningbeam_core::actions::TrimData::left(
old_trim_start, ContentTime(old_trim_start),
old_timeline_start, old_timeline_start,
), ),
lightningbeam_core::actions::TrimData::left( lightningbeam_core::actions::TrimData::left(
new_trim_start, ContentTime(new_trim_start),
new_timeline_start, new_timeline_start,
), ),
)); ));
@ -4939,10 +5273,10 @@ impl TimelinePane {
// Calculate new trim_end based on current duration // Calculate new trim_end based on current duration
let current_duration = let current_duration =
clip_instance.effective_duration(clip_duration, document.tempo_map()); clip_instance.effective_duration(clip_duration, document.tempo_map());
let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let old_trim_end_val = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
let desired_trim_end = self.snap_to_grid( let desired_trim_end = self.snap_to_grid(
old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE, old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE,
).max(clip_instance.trim_start).min(clip_duration.seconds_to_f64()); ).max(ci_trim_start).min(clip_duration.native());
// Apply overlap prevention when extending right (content-seconds gap). // Apply overlap prevention when extending right (content-seconds gap).
let new_trim_end_val = if desired_trim_end > old_trim_end_val { let new_trim_end_val = if desired_trim_end > old_trim_end_val {
@ -4959,13 +5293,15 @@ impl TimelinePane {
desired_trim_end desired_trim_end
}; };
let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0); let new_duration = (new_trim_end_val - ci_trim_start).max(0.0);
// Convert new duration back to trim_end value // Convert new duration back to trim_end value
let new_trim_end = if new_duration >= clip_duration.seconds_to_f64() { let new_trim_end = if new_duration >= clip_duration.native() {
None // Use full clip duration None // Use full clip duration
} else { } else {
Some((clip_instance.trim_start + new_duration).min(clip_duration.seconds_to_f64())) Some(ContentTime(
(ci_trim_start + new_duration).min(clip_duration.native()),
))
}; };
layer_trims layer_trims
@ -5017,8 +5353,9 @@ impl TimelinePane {
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
let tmap = document.tempo_map(); let tmap = document.tempo_map();
let ts = clip_instance.timeline_start; let ts = clip_instance.timeline_start;
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let ci_trim_start = clip_instance.trim_start.raw();
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw());
let content_window_secs = (trim_end - ci_trim_start).max(0.0);
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts; let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
let current_right = clip_instance.timeline_duration.unwrap_or(content_window); let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
// Snap the right edge in the seconds/pixel domain. // Snap the right edge in the seconds/pixel domain.
@ -5091,8 +5428,9 @@ impl TimelinePane {
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
let tmap = document.tempo_map(); let tmap = document.tempo_map();
let ts = clip_instance.timeline_start; let ts = clip_instance.timeline_start;
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let ci_trim_start = clip_instance.trim_start.raw();
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw());
let content_window_secs = (trim_end - ci_trim_start).max(0.001);
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts; let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
let cw = content_window.beats_to_f64().max(1e-9); let cw = content_window.beats_to_f64().max(1e-9);
let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO); let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO);
@ -6181,6 +6519,8 @@ impl PaneRenderer for TimelinePane {
editing_clip_id.as_ref(), editing_clip_id.as_ref(),
); );
self.render_take_menu(ui, document, shared.pending_actions);
// Render automation lanes AFTER handle_input so our ui.interact registers last and wins // Render automation lanes AFTER handle_input so our ui.interact registers last and wins
// egui's interaction priority over handle_input's full-content-area allocation. // egui's interaction priority over handle_input's full-content-area allocation.
// All automation lanes use beats as the x-axis; convert via the tempo map. // All automation lanes use beats as the x-axis; convert via the tempo map.
@ -6348,7 +6688,7 @@ impl PaneRenderer for TimelinePane {
let instances = layer_clips(layer); let instances = layer_clips(layer);
for inst in instances { for inst in instances {
if !shared.selection.contains_clip_instance(&inst.id) { continue; } if !shared.selection.contains_clip_instance(&inst.id) { continue; }
if let Some(dur) = document.get_clip_duration(&inst.clip_id) { if let Some(dur) = document.clip_trim_duration(&inst.clip_id) {
let eff = inst.effective_duration(dur, document.tempo_map()); let eff = inst.effective_duration(dur, document.tempo_map());
let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64(); let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64();
let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64(); let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64();
@ -6374,7 +6714,7 @@ impl PaneRenderer for TimelinePane {
enabled = instances.iter() enabled = instances.iter()
.filter(|ci| shared.selection.contains_clip_instance(&ci.id)) .filter(|ci| shared.selection.contains_clip_instance(&ci.id))
.all(|ci| { .all(|ci| {
if let Some(dur) = document.get_clip_duration(&ci.clip_id) { if let Some(dur) = document.clip_trim_duration(&ci.clip_id) {
let eff = ci.effective_duration(dur, document.tempo_map()); let eff = ci.effective_duration(dur, document.tempo_map());
// Room to duplicate = seconds gap to the right ≥ this clip's own length. // Room to duplicate = seconds gap to the right ≥ this clip's own length.
let max_extend_secs = document.find_max_trim_extend_right( let max_extend_secs = document.find_max_trim_extend_right(
@ -6419,7 +6759,7 @@ impl PaneRenderer for TimelinePane {
enabled = instances.iter().all(|ci| { enabled = instances.iter().all(|ci| {
let paste_start = (ci.timeline_start + offset).max(Beats::ZERO); let paste_start = (ci.timeline_start + offset).max(Beats::ZERO);
if let Some(dur) = document.get_clip_duration(&ci.clip_id) { if let Some(dur) = document.clip_trim_duration(&ci.clip_id) {
let eff = ci.effective_duration(dur, document.tempo_map()); let eff = ci.effective_duration(dur, document.tempo_map());
document document
.find_nearest_valid_position( .find_nearest_valid_position(
@ -6448,6 +6788,22 @@ impl PaneRenderer for TimelinePane {
enabled enabled
}; };
// Take management for the clip that was right-clicked. Only offered when there's more
// than one take — with a single take there's nothing to choose between, and "delete the
// only take" would leave the instance with nothing to play.
let take_target: Option<(uuid::Uuid, uuid::Uuid, usize, String)> = ctx_clip_id.and_then(|instance_id| {
let context_layers = document.context_layers(shared.editing_clip_id.as_ref());
for (layer, instances) in all_layer_clip_instances(&context_layers) {
if let Some(ci) = instances.iter().find(|ci| ci.id == instance_id) {
if ci.takes.len() > 1 {
let active = ci.active_take_index();
return Some((layer.id(), instance_id, active, ci.takes[active].name.clone()));
}
}
}
None
});
let area_id = ui.id().with("clip_context_menu"); let area_id = ui.id().with("clip_context_menu");
let mut item_clicked = false; let mut item_clicked = false;
let area_response = egui::Area::new(area_id) let area_response = egui::Area::new(area_id)
@ -6513,6 +6869,34 @@ impl PaneRenderer for TimelinePane {
shared.pending_menu_actions.push(crate::menu::MenuAction::Delete); shared.pending_menu_actions.push(crate::menu::MenuAction::Delete);
item_clicked = true; item_clicked = true;
} }
// Take management, on the clip that was right-clicked. Takes live on the
// INSTANCE, so on a comped split this prunes the half you clicked and leaves
// the other half's alternatives alone.
if let Some((take_layer_id, take_instance_id, active_index, active_name)) = &take_target {
ui.separator();
// Deletes the take that's PLAYING — the one the badge is showing — so
// it's named rather than just "Delete Take".
if menu_item(ui, &format!("Delete \"{}\"", active_name), true) {
shared.pending_actions.push(Box::new(
lightningbeam_core::actions::DeleteTakeAction::new(
*take_layer_id,
*take_instance_id,
*active_index,
),
));
item_clicked = true;
}
if menu_item(ui, "Delete Unused Takes", true) {
shared.pending_actions.push(Box::new(
lightningbeam_core::actions::DeleteUnusedTakesAction::new(
*take_layer_id,
*take_instance_id,
),
));
item_clicked = true;
}
}
}); });
}); });

View File

@ -55,6 +55,7 @@ struct PreferencesState {
file_height: u32, file_height: u32,
scroll_speed: f64, scroll_speed: f64,
audio_buffer_size: u32, audio_buffer_size: u32,
cycle_midi_separate_takes: bool,
reopen_last_session: bool, reopen_last_session: bool,
restore_layout_from_file: bool, restore_layout_from_file: bool,
debug: bool, debug: bool,
@ -74,6 +75,7 @@ impl From<(&AppConfig, &Theme)> for PreferencesState {
file_height: config.file_height, file_height: config.file_height,
scroll_speed: config.scroll_speed, scroll_speed: config.scroll_speed,
audio_buffer_size: config.audio_buffer_size, audio_buffer_size: config.audio_buffer_size,
cycle_midi_separate_takes: config.cycle_midi_separate_takes,
reopen_last_session: config.reopen_last_session, reopen_last_session: config.reopen_last_session,
restore_layout_from_file: config.restore_layout_from_file, restore_layout_from_file: config.restore_layout_from_file,
debug: config.debug, debug: config.debug,
@ -95,6 +97,7 @@ impl Default for PreferencesState {
file_height: 600, file_height: 600,
scroll_speed: 1.0, scroll_speed: 1.0,
audio_buffer_size: 256, audio_buffer_size: 256,
cycle_midi_separate_takes: false,
reopen_last_session: false, reopen_last_session: false,
restore_layout_from_file: true, restore_layout_from_file: true,
debug: false, debug: false,
@ -551,6 +554,39 @@ impl PreferencesDialog {
}); });
ui.label("Requires app restart to take effect"); 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.",
);
});
});
}); });
} }
@ -684,6 +720,7 @@ impl PreferencesDialog {
temp_config.file_height = self.working_prefs.file_height; temp_config.file_height = self.working_prefs.file_height;
temp_config.scroll_speed = self.working_prefs.scroll_speed; temp_config.scroll_speed = self.working_prefs.scroll_speed;
temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size; 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.reopen_last_session = self.working_prefs.reopen_last_session;
temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
temp_config.debug = self.working_prefs.debug; temp_config.debug = self.working_prefs.debug;
@ -720,6 +757,7 @@ impl PreferencesDialog {
config.file_height = self.working_prefs.file_height; config.file_height = self.working_prefs.file_height;
config.scroll_speed = self.working_prefs.scroll_speed; config.scroll_speed = self.working_prefs.scroll_speed;
config.audio_buffer_size = self.working_prefs.audio_buffer_size; 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.reopen_last_session = self.working_prefs.reopen_last_session;
config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
config.debug = self.working_prefs.debug; config.debug = self.working_prefs.debug;