diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index b782cd2..b129a5f 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -41,6 +41,9 @@ pub struct Engine { /// 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. 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 command_rx: rtrb::Consumer, @@ -167,6 +170,7 @@ impl Engine { loop_region: None, loop_enabled: false, loop_bounds_frozen: None, + cycle_midi_separate_takes: false, command_rx, midi_command_rx: None, event_tx, @@ -494,6 +498,11 @@ impl Engine { self.project.reset_read_ahead_targets(); // 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( &mut self.mix_buffer, &self.audio_pool, @@ -503,6 +512,7 @@ impl Engine { self.sample_rate, self.channels, false, + recording_midi, ); // Copy mix to output @@ -543,6 +553,63 @@ impl Engine { 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 { dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { 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 { // 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 .event_tx .push(AudioEvent::PlaybackPosition(position_seconds)); @@ -571,8 +638,31 @@ impl Engine { 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 = self.tempo_map.seconds_to_beats(current_time_secs); - let duration = current_time - recording.start_time; - let notes = recording.get_notes_with_active(current_time); + // Once the transport has wrapped, the recording covers the WHOLE cycle region and + // 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( recording.track_id, recording.clip_id, @@ -610,6 +700,7 @@ impl Engine { self.sample_rate, self.channels, true, // live_only + None, // no clips are scheduled at all in live_only, so nothing to mute ); output.copy_from_slice(&self.mix_buffer); } @@ -894,6 +985,9 @@ impl Engine { Command::SetLoopEnabled(enabled) => { self.loop_enabled = enabled; } + Command::SetCycleMidiSeparateTakes(separate) => { + self.cycle_midi_separate_takes = separate; + } Command::Stop => { self.playing = false; self.playhead = 0; @@ -911,7 +1005,7 @@ impl Engine { self.project.stop_all_notes(); } 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) let clamped = self.playhead.max(0) as u64; self.playhead_atomic.store(clamped, Ordering::Relaxed); @@ -944,36 +1038,56 @@ impl Engine { match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.external_start = Beats(new_start_time); + clip.external_start = new_start_time; } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) { - instance.external_start = Beats(new_start_time); + instance.external_start = new_start_time; } } _ => {} } self.refresh_clip_snapshot(); } - Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => { - // Trim changes which portion of the source content is used - // Also updates external_duration to match internal duration (no looping after trim) - match self.project.get_track_mut(track_id) { - Some(crate::audio::track::TrackNode::Audio(track)) => { + Command::TrimClip(track_id, clip_id, range) => { + // Trim changes which portion of the source content is used. + // Also collapses external_duration to the trimmed content length (no looping after + // a trim). + 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) { - clip.internal_start = Seconds(new_internal_start); - clip.internal_end = Seconds(new_internal_end); - clip.external_duration = Beats(new_internal_end - new_internal_start); + clip.internal_start = start; + clip.internal_end = end; + // 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) { - instance.internal_start = Beats(new_internal_start); - instance.internal_end = Beats(new_internal_end); - instance.external_duration = Beats(new_internal_end - new_internal_start); + instance.internal_start = start; + instance.internal_end = end; + // 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(); @@ -983,12 +1097,12 @@ impl Engine { match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.external_duration = Beats(new_external_duration); + clip.external_duration = new_external_duration; } } Some(crate::audio::track::TrackNode::Midi(track)) => { 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) => { 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) => { @@ -1025,12 +1139,12 @@ impl Engine { } Command::SetTrimStart(track_id, trim_start) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.trim_start = Seconds(trim_start.max(0.0)); + metatrack.trim_start = Seconds(trim_start.seconds_to_f64().max(0.0)); } } Command::SetTrimEnd(track_id, trim_end) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.trim_end = trim_end.map(|t| 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) => { @@ -1084,9 +1198,13 @@ impl Engine { // Send chunks via MPSC channel (will be forwarded by audio thread) if !chunks.is_empty() { println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index); - let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + let event_chunks: Vec<(u32, (Seconds, Seconds), Vec)> = chunks .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(); 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); // 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); // Create an instance for this clip on the track 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) { 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 if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { // 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); // 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); clip.add_event(note_off); } else { @@ -1183,9 +1301,9 @@ impl Engine { if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) { let actual_clip_id = instance.clip_id; if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) { - let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); + let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); 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); clip.add_event(note_off); } @@ -1195,7 +1313,7 @@ impl Engine { } Command::AddLoadedMidiClip(track_id, clip, 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 } self.refresh_clip_snapshot(); @@ -1209,11 +1327,11 @@ impl Engine { // Add new events from the notes array // Timestamps are in beats (canonical) 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); // 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); clip.events.push(note_off); } @@ -1275,7 +1393,7 @@ impl Engine { } Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => { // Add an automation point to the specified lane - let point = crate::audio::AutomationPoint::new(Beats(time), value, curve); + let point = crate::audio::AutomationPoint::new(time, value, curve); match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { @@ -1301,17 +1419,17 @@ impl Engine { match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(lane) = track.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(Beats(time), Beats(tolerance)); + lane.remove_point_at_time(time, tolerance); } } Some(crate::audio::track::TrackNode::Midi(track)) => { 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)) => { 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 => {} @@ -1374,9 +1492,9 @@ impl Engine { None => {} } } - Command::StartRecording(track_id, start_time) => { + Command::StartRecording(track_id, start_time, force_takes) => { // 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 => { // Stop the current recording @@ -1394,9 +1512,9 @@ impl Engine { 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 - 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 => { eprintln!("[ENGINE] Received StopMidiRecording command"); @@ -2400,7 +2518,7 @@ impl Engine { // Downcast to AutomationInputNode using as_any_mut if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { let keyframe = AutomationKeyframe { - time: Beats(time), + time, value, interpolation, ease_out, @@ -2428,7 +2546,7 @@ impl Engine { 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::() { - 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 { 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) if !chunks.is_empty() { - let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + let event_chunks: Vec<(u32, (Seconds, Seconds), Vec)> = chunks .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(); let _ = chunk_tx.send(AudioEvent::WaveformChunksReady { @@ -2663,7 +2784,7 @@ impl Engine { path: path_str, channels: metadata.channels, sample_rate: metadata.sample_rate, - duration: metadata.duration, + duration: Seconds(metadata.duration), format: metadata.format, }); @@ -2771,7 +2892,7 @@ impl Engine { if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) { use crate::command::MidiClipData; QueryResponse::MidiClipData(Ok(MidiClipData { - duration: clip.duration.0, + duration: clip.duration, events: clip.events.clone(), })) } else { @@ -2803,7 +2924,7 @@ impl Engine { InterpolationType::Hold => "hold", }.to_string(); AutomationKeyframeData { - time: kf.time.0, + time: kf.time, value: kf.value, interpolation: interpolation_str, ease_out: kf.ease_out, @@ -2972,7 +3093,11 @@ impl Engine { } Query::GetPoolFileInfo(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))), } } @@ -3019,7 +3144,7 @@ impl Engine { } Query::AddMidiClipSync(track_id, clip, start_time) => { // 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)), Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), }; @@ -3141,7 +3266,7 @@ impl Engine { } /// 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 std::env; @@ -3212,8 +3337,29 @@ impl Engine { self.recording_state = Some(recording_state); 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) if let Some(recording) = &mut self.recording_state { + recording.cycle = cycle_info; recording.samples_to_skip = samples_in_buffer; if self.debug_audio && samples_in_buffer > 0 { 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, + sample_rate: u32, + channels: u32, + clip_id: ClipId, + take_index: usize, + ) -> Result { + 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 fn handle_stop_recording(&mut self) { 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); + // 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) let frames_recorded = recording.frames_written; eprintln!("[STOP_RECORDING] Calling finalize() - frames_recorded={}", frames_recorded); match recording.finalize() { 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)> = 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", frames_recorded, temp_file_path, waveform.len(), audio_data.len()); @@ -3312,12 +3555,23 @@ impl Engine { } /// 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 if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) { // Create MIDI recording state 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) // so they start at t=0 of the recording rather than being lost if let Some(held) = self.midi_held_notes.get(&track_id) { @@ -3355,9 +3609,87 @@ impl Engine { let clip_id = recording.clip_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 = 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 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", clip_id, track_id, note_count, recording_duration.0); @@ -3483,6 +3815,12 @@ impl EngineController { 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 pub fn stop(&mut self) { let _ = self.command_tx.push(Command::Stop); @@ -3490,7 +3828,7 @@ impl EngineController { /// Seek to a specific position in 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) @@ -3522,22 +3860,22 @@ impl EngineController { /// 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) { - 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) - /// 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 - /// track type: SECONDS for a sampled-audio clip, BEATS for a MIDI clip (see the TrimClip - /// handler). Left as raw f64 because a single newtype can't express both; callers pass the - /// clip's own `trim_start`/`trim_end`, which already match its content domain. - pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_internal_start: f64, new_internal_end: f64) { - let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end)); + /// Trim a clip's internal content bounds — which portion of the source content plays. + /// + /// Collapses external_duration to the trimmed length (disables looping). The bounds are + /// content-domain, which differs by clip kind, so they're passed as a [`TrimRange`] that names + /// the domain: `Seconds` for sampled audio, `Beats` for MIDI. The engine rejects a range whose + /// domain doesn't match the track. + 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, range)); } /// 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) { - 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 @@ -3551,9 +3889,9 @@ impl EngineController { } /// 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); - 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 @@ -3586,7 +3924,7 @@ impl EngineController { /// Set metatrack time offset in seconds /// Positive = shift content later, negative = shift earlier 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) @@ -3596,12 +3934,12 @@ impl EngineController { /// Set metatrack trim start in 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) pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option) { - 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 @@ -3755,23 +4093,22 @@ impl EngineController { 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 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 } /// 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) { - 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) 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]). 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)); } @@ -3812,7 +4149,7 @@ impl EngineController { curve: crate::audio::CurveType, ) { 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, ) { 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, ease_out: (f32, f32), ease_in: (f32, f32)) { 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 pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) { 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 @@ -3885,8 +4222,11 @@ impl EngineController { } /// Start recording on a track - pub fn start_recording(&mut self, track_id: TrackId, start_time: Beats) { - let _ = self.command_tx.push(Command::StartRecording(track_id, start_time)); + /// `force_takes`: cut takes even if the transport never wraps, because the cycle region already + /// 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 @@ -3905,8 +4245,9 @@ impl EngineController { } /// Start MIDI recording on a track - pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { - let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time)); + /// `force_takes`: see [`EngineController::start_recording`]. + 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 @@ -4406,7 +4747,7 @@ impl EngineController { } /// 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 if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) { return Err("Failed to send query - queue full".to_string()); diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs index 789b9d8..3110024 100644 --- a/daw-backend/src/audio/export.rs +++ b/daw-backend/src/audio/export.rs @@ -211,6 +211,7 @@ pub fn render_to_memory( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we actually need from this chunk @@ -557,6 +558,7 @@ fn export_mp3>( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we need from this chunk @@ -727,6 +729,7 @@ fn export_aac>( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we need from this chunk diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index 4cce4a1..c44eafc 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -383,6 +383,7 @@ impl Project { sample_rate: u32, channels: u32, live_only: bool, + recording_midi: Option<(TrackId, MidiClipId)>, ) { output.fill(0.0); @@ -391,6 +392,7 @@ impl Project { // Create initial render context let ctx = RenderContext { live_only, + recording_midi, ..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len()) }; diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index f1b3cbb..652d603 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -5,6 +5,61 @@ use crate::time::{Beats, Seconds}; use std::collections::HashMap; use std::path::PathBuf; +/// Cycle-recording bookkeeping attached to a recording that started with a cycle region armed. +/// +/// Takes are sliced **geometrically** at stop, in exact `loop_len_frames` multiples — not at the +/// instant the wrap was detected. The playhead advances before the capture block in `process()`, so +/// the wrap instant isn't sample-exact against the buffer that was just captured, but the geometry +/// is. `wrap_count` therefore only decides *whether* this is a multi-take recording, never where the +/// cuts land. +#[derive(Debug, Clone, Copy)] +pub struct CycleRecordInfo { + /// Where the cycle region starts, in beats. Takes are laid down here, not at the punch-in point. + pub loop_start: Beats, + /// The cycle region's length in beats — what the take folder records as `recorded_loop_beats`. + pub loop_len_beats: Beats, + /// One cycle pass, in frames. The take size. + pub loop_len_frames: usize, + /// Frames between the region start and where capture actually began. Non-zero only for a + /// punch-in (record while already rolling); take 1 gets this much silence prepended so it still + /// spans the whole region. + pub lead_pad_frames: usize, + /// How many times the transport wrapped during this recording. Zero normally means the user + /// stopped before completing a pass, which stays an ordinary single recording — unless + /// `force_takes` says otherwise. + pub wrap_count: usize, + /// Cut takes even if the transport never wrapped. + /// + /// Set when the region already holds takes: a further recording there is another take, however + /// short, and it gets padded out to the region like any partial pass. Without this a run that + /// stopped before the loop came round would land as a separate overlapping clip instead of + /// joining the take list. + /// + /// The editor decides this at record start, because whether takes already exist is document + /// state the engine can't see. + pub force_takes: bool, +} + +/// Min/max waveform peaks for a finished buffer of interleaved samples. +/// +/// The live recording path builds its peaks incrementally as samples arrive; cycle takes don't +/// exist until the recording is sliced at stop, so they get theirs in one pass here. +pub fn compute_peaks(samples: &[f32], channels: u32, frames_per_peak: usize) -> Vec { + let samples_per_peak = (frames_per_peak * channels.max(1) as usize).max(1); + samples + .chunks(samples_per_peak) + .map(|chunk| { + let mut min = 0.0f32; + let mut max = 0.0f32; + for s in chunk { + min = min.min(*s); + max = max.max(*s); + } + WaveformPeak { min, max } + }) + .collect() +} + /// State of an active recording session pub struct RecordingState { /// Track being recorded to @@ -35,6 +90,8 @@ pub struct RecordingState { pub frames_per_peak: usize, /// All recorded audio data accumulated in memory (written to disk at finalization) pub audio_data: Vec, + /// Cycle-recording bookkeeping, when a cycle region was armed at record start. + pub cycle: Option, } impl RecordingState { @@ -69,9 +126,69 @@ impl RecordingState { waveform_buffer: Vec::new(), frames_per_peak, audio_data: Vec::new(), + cycle: None, } } + /// Slice the recording into cycle takes: one per pass, each spanning the FULL cycle region. + /// + /// Partial passes are padded with silence — the head of take 1 for a punch-in, the tail of the + /// last take when the user stops mid-pass — so every take is the same length and aligned to the + /// region. That uniformity is what makes comping-via-split work: take 1 on the left half and + /// take 3 on the right always line up. + /// + /// Returns `None` if this wasn't a cycle recording or the transport never wrapped (an ordinary + /// single recording, which keeps the existing path untouched). + pub fn slice_takes(&self) -> Option>> { + 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::with_capacity(take_count); + for i in 0..take_count { + let mut take = vec![0.0f32; take_len]; + let take_begin = i * take_len; + for slot in 0..take_len { + // Position in the virtual (lead-padded) buffer. + let v = take_begin + slot; + if v < lead { + continue; // still in the punch-in silence + } + match self.audio_data.get(v - lead) { + Some(s) => take[slot] = *s, + None => break, // past the end of capture; the rest stays silent + } + } + takes.push(take); + } + + // A final take holding only a sliver of real audio is a stop artifact (the user hit stop a + // moment after the wrap), not a performance. Drop it — but only if it's actually a PARTIAL + // pass, and never the only take. A pass that filled the region is a real take no matter how + // short the region is. + const MIN_TAKE_SECONDS: f64 = 0.05; + if takes.len() > 1 { + let last_real_samples = virtual_len - (takes.len() - 1) * take_len; + let last_real_seconds = (last_real_samples / ch) as f64 / self.sample_rate as f64; + if last_real_samples < take_len && last_real_seconds < MIN_TAKE_SECONDS { + takes.pop(); + } + } + + Some(takes) + } + /// Add samples to the accumulation buffer /// Returns true if a flush occurred pub fn add_samples(&mut self, samples: &[f32]) -> Result { @@ -189,6 +306,26 @@ pub struct MidiRecordingState { active_notes: HashMap, /// Completed notes: (time_offset, note, velocity, duration) — all times in beats pub completed_notes: Vec<(Beats, u8, u8, Beats)>, + /// The cycle region's length in beats, if one was armed at record start. + /// + /// A cycle MIDI recording is anchored at the region start (`start_time == loop_start`), which is + /// what makes MERGE fall out for free: the transport always wraps back into the region, so every + /// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each + /// other with no folding needed. + pub cycle_loop_len: Option, + /// 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, } impl MidiRecordingState { @@ -199,9 +336,28 @@ impl MidiRecordingState { start_time, active_notes: HashMap::new(), completed_notes: Vec::new(), + cycle_loop_len: None, + wrapped: false, + force_takes: false, + current_pass: 0, + note_pass: Vec::new(), } } + /// Record a finished note, tagging it with the pass it was played in. + /// + /// Every completion goes through here so `completed_notes` and `note_pass` can't drift apart. + fn push_completed(&mut self, note: &ActiveMidiNote, end_time: Beats) { + let note_start = note.start_time.max(self.start_time); + self.completed_notes.push(( + note_start - self.start_time, + note.note, + note.velocity, + end_time - note_start, + )); + self.note_pass.push(self.current_pass); + } + pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) { self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time }); } @@ -211,16 +367,38 @@ impl MidiRecordingState { if absolute_time <= self.start_time { return; } - let note_start = active_note.start_time.max(self.start_time); - self.completed_notes.push(( - note_start - self.start_time, - active_note.note, - active_note.velocity, - absolute_time - note_start, - )); + self.push_completed(&active_note, absolute_time); } } + /// 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> { + let mut buckets = vec![Vec::new(); passes.max(1)]; + for (note, &pass) in self.completed_notes.iter().zip(self.note_pass.iter()) { + if let Some(bucket) = buckets.get_mut(pass) { + bucket.push(*note); + } + } + // Never drop the only take. + while buckets.len() > 1 && buckets.last().is_some_and(|b| b.is_empty()) { + buckets.pop(); + } + buckets + } + + /// How many cycle passes this recording covered (1 if the transport never wrapped). + pub fn pass_count(&self) -> usize { + self.current_pass + 1 + } + pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] { &self.completed_notes } @@ -229,18 +407,28 @@ impl MidiRecordingState { self.completed_notes.len() } + /// The still-held notes, given a provisional duration running to `current_time`. + /// + /// These belong to whatever pass is in progress, so a per-pass view can append them as-is. + pub fn active_notes_with_provisional_end(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { + self.active_notes + .values() + .map(|active| { + let note_start = active.start_time.max(self.start_time); + ( + note_start - self.start_time, + active.note, + active.velocity, + (current_time - note_start).max(Beats::ZERO), + ) + }) + .collect() + } + /// Get all completed notes plus currently-held notes with a provisional duration. pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { let mut notes = self.completed_notes.clone(); - for active in self.active_notes.values() { - let note_start = active.start_time.max(self.start_time); - notes.push(( - note_start - self.start_time, - active.note, - active.velocity, - (current_time - note_start).max(Beats::ZERO), - )); - } + notes.extend(self.active_notes_with_provisional_end(current_time)); notes } @@ -252,13 +440,7 @@ impl MidiRecordingState { let active_notes: Vec<_> = self.active_notes.drain().collect(); for (_note_num, active_note) in active_notes { - let note_start = active_note.start_time.max(self.start_time); - self.completed_notes.push(( - note_start - self.start_time, - active_note.note, - active_note.velocity, - end_time - note_start, - )); + self.push_completed(&active_note, end_time); } } @@ -278,10 +460,239 @@ impl MidiRecordingState { .map(|n| (n.note, n.velocity)) .collect(); + // Close first, so a note held across the boundary has its tail attributed to the pass that's + // ending; then advance, so the re-opened half belongs to the pass that's beginning. self.close_active_notes(region_end); + self.current_pass += 1; for (note, velocity) in held { self.note_on(note, velocity, region_start); } + + // A pass has completed, so from here the clip spans the whole region rather than however long + // the user happens to hold the record button. + self.cycle_loop_len = Some(region_end - region_start); + self.wrapped = true; + } +} + +#[cfg(test)] +mod cycle_tests { + use super::*; + + /// A recording state holding `audio_data`, armed for cycle recording. Mono, 100 Hz, so a frame + /// is a sample and 5 frames is 50 ms (exactly the min-take threshold). + fn rec_forced(audio: Vec, 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, 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 = (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 = (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 = (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 = (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 = (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 = (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> = 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); } } diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index f7da3d2..b79cb1a 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -1,6 +1,6 @@ use super::automation::{AutomationLane, AutomationLaneId, ParameterId}; 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::node_graph::AudioGraph; 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 /// without re-triggering notes from clips at the paused position. pub live_only: bool, + /// The MIDI recording in progress, if any: (track being recorded to, clip being recorded into). + /// + /// On that track, every OTHER clip is silenced for the duration of the recording. You're playing + /// a part into this region — hearing what's already there (a previous take, say) fighting with + /// what you're playing now is just noise. The clip being recorded into is exempt, because in + /// merge mode that's exactly what you DO want to hear: the overdub you've been building up. + pub recording_midi: Option<(TrackId, MidiClipId)>, } impl<'a> RenderContext<'a> { @@ -61,6 +68,7 @@ impl<'a> RenderContext<'a> { buffer_size, time_stretch: 1.0, live_only: false, + recording_midi: None, } } @@ -864,9 +872,21 @@ impl MidiTrack { let playhead_beats = ctx.playhead_beats(); let buffer_end_beats = ctx.buffer_end_beats(); + // While recording into this track, every clip EXCEPT the one being recorded into is + // silenced. Otherwise a take folder already sitting in the cycle region would play its + // active take underneath you on every pass, fighting the part you're trying to record. + // The recording clip itself is exempt: in merge mode that's the overdub monitoring. + let muted_clip = match ctx.recording_midi { + Some((track_id, clip_id)) if track_id == self.id => Some(clip_id), + _ => None, + }; + // Collect MIDI events from all clip instances that overlap with current beat range let mut currently_active = HashSet::new(); for instance in &self.clip_instances { + if muted_clip.is_some_and(|recording| instance.clip_id != recording) { + continue; + } if instance.overlaps_range(playhead_beats, buffer_end_beats) { currently_active.insert(instance.id); } diff --git a/daw-backend/src/command/mod.rs b/daw-backend/src/command/mod.rs index 5baaac2..917ec0c 100644 --- a/daw-backend/src/command/mod.rs +++ b/daw-backend/src/command/mod.rs @@ -1,3 +1,3 @@ pub mod types; -pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse}; +pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse, TrimRange}; diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 4a57152..a87d58a 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -8,6 +8,21 @@ use crate::audio::node_graph::nodes::LoopMode; use crate::io::WaveformPeak; use crate::time::{Beats, Seconds}; +/// A clip's internal (content) boundaries, tagged with the domain they're measured in. +/// +/// A clip's content time is SECONDS for sampled audio but BEATS for MIDI — the same polymorphism +/// `ClipInstance::trim_start`/`trim_end` carry. Passing these as bare `f64`s meant the caller and +/// the engine could disagree about the unit with nothing to catch it: an audio trim of "1.0" was +/// once stored as `Beats(1.0)` for the clip's external duration, so a 1-second split played back as +/// half a second at 120 BPM. Tagging the domain makes that a type error instead of a bug report. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TrimRange { + /// Sampled-audio content time. + Seconds { start: Seconds, end: Seconds }, + /// MIDI content time. + Beats { start: Beats, end: Beats }, +} + /// Commands sent from UI/control thread to audio thread #[derive(Debug, Clone)] pub enum Command { @@ -19,7 +34,7 @@ pub enum Command { /// Pause playback (maintains position) Pause, /// Seek to a specific position in seconds - Seek(f64), + Seek(Seconds), // Track management commands /// Set track volume (0.0 = silence, 1.0 = unity gain) @@ -31,13 +46,12 @@ pub enum Command { // Clip management commands /// Move a clip to a new timeline position (track_id, clip_id, new_external_start) - MoveClip(TrackId, ClipId, f64), - /// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end) - /// This changes which portion of the source content is used - TrimClip(TrackId, ClipId, f64, f64), + MoveClip(TrackId, ClipId, Beats), + /// Trim a clip's internal boundaries — which portion of the source content is used. + TrimClip(TrackId, ClipId, TrimRange), /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) /// If duration > internal duration, the clip will loop - ExtendClip(TrackId, ClipId, f64), + ExtendClip(TrackId, ClipId, Beats), // Metatrack management commands /// Create a new metatrack with a name and optional parent group @@ -53,15 +67,15 @@ pub enum Command { SetTimeStretch(TrackId, f32), /// Set metatrack time offset in seconds (track_id, offset) /// Positive = shift content later, negative = shift earlier - SetOffset(TrackId, f64), + SetOffset(TrackId, Seconds), /// Set metatrack pitch shift in semitones (track_id, semitones) - for future use SetPitchShift(TrackId, f32), /// Set metatrack trim start in seconds (track_id, trim_start) /// Children won't hear content before this point - SetTrimStart(TrackId, f64), + SetTrimStart(TrackId, Seconds), /// Set metatrack trim end in seconds (track_id, trim_end) /// None means no end trim - SetTrimEnd(TrackId, Option), + SetTrimEnd(TrackId, Option), // Audio track commands /// 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 AddMidiClipToPool(MidiClip), /// 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) - 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) - AddLoadedMidiClip(TrackId, MidiClip, f64), + AddLoadedMidiClip(TrackId, MidiClip, Beats), /// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>) /// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes - UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(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. UpdateMidiClipEvents(TrackId, MidiClipId, Vec), /// 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) CreateAutomationLane(TrackId, ParameterId), /// 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) - RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64), + RemoveAutomationPoint(TrackId, AutomationLaneId, Beats, Beats), /// Clear all automation points from a lane (track_id, lane_id) ClearAutomationLane(TrackId, AutomationLaneId), /// Remove an automation lane (track_id, lane_id) @@ -119,10 +133,18 @@ pub enum Command { SetLoopRegion(Option<(Beats, Beats)>), /// Enable/disable wrapping at the cycle region's end. SetLoopEnabled(bool), + /// How a cycle MIDI recording treats its passes. + /// + /// `false` (default) = MERGE: every pass overdubs into one clip. `true` = SEPARATE TAKES: each + /// pass becomes its own MIDI clip, and the editor folds them into a take folder — the same shape + /// audio always gets. + SetCycleMidiSeparateTakes(bool), // Recording commands /// Start recording on a track (track_id, start_time) - 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 StopRecording, /// Pause the current recording @@ -132,7 +154,8 @@ pub enum Command { // MIDI Recording commands /// 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 StopMidiRecording, @@ -244,9 +267,9 @@ pub enum Command { // Automation Input Node commands /// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in) - AutomationAddKeyframe(TrackId, u32, 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) - AutomationRemoveKeyframe(TrackId, u32, f64), + AutomationRemoveKeyframe(TrackId, u32, Beats), /// Set the display name of an AutomationInput node (track_id, node_id, name) AutomationSetName(TrackId, u32, String), @@ -278,7 +301,7 @@ pub enum Command { #[derive(Debug, Clone)] pub enum AudioEvent { /// Current playback position in seconds - PlaybackPosition(f64), + PlaybackPosition(Seconds), /// Playback has stopped (reached end of audio) PlaybackStopped, /// Audio buffer underrun detected @@ -299,6 +322,35 @@ pub enum AudioEvent { RecordingProgress(ClipId, Seconds), /// Recording stopped (clip_id, pool_index, waveform) RecordingStopped(ClipId, usize, Vec), + /// 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, + /// 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)>, + /// Where the takes sit on the timeline — the cycle region's start, not the punch-in point. + loop_start: Beats, + /// The region's length in beats (what the take folder stores as `recorded_loop_beats`). + loop_len_beats: Beats, + /// The same length in seconds — the take folder's content duration, which is seconds-domain + /// for audio. + loop_len_seconds: Seconds, + }, /// Recording error (error_message) RecordingError(String), /// MIDI recording stopped (track_id, clip_id, note_count) @@ -349,7 +401,7 @@ pub enum AudioEvent { WaveformChunksReady { pool_index: usize, detail_level: u8, - chunks: Vec<(u32, (f64, f64), Vec)>, + chunks: Vec<(u32, (Seconds, Seconds), Vec)>, }, /// An audio file has been imported and is ready for playback. @@ -360,7 +412,7 @@ pub enum AudioEvent { path: String, channels: u32, sample_rate: u32, - duration: f64, + duration: Seconds, format: crate::io::audio_file::AudioFormat, }, @@ -434,7 +486,7 @@ pub enum Query { /// Export audio to file (settings, output_path) ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), /// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID - AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64), + AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, Beats), /// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID /// The clip must already exist in the MidiClipPool AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), @@ -479,16 +531,21 @@ pub struct OscilloscopeData { } /// MIDI clip data for serialization +/// +/// `Beats`/`Seconds` are `#[serde(transparent)]`, so naming the domain here costs nothing on disk — +/// the `.beam` still holds a plain number. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MidiClipData { - pub duration: f64, + /// MIDI content length is musical, so beats. + pub duration: Beats, pub events: Vec, } /// Automation keyframe data for serialization #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AutomationKeyframeData { - pub time: f64, + /// Automation x-axes are all beats. + pub time: Beats, pub value: f32, pub interpolation: String, pub ease_out: (f32, f32), @@ -525,7 +582,7 @@ pub enum QueryResponse { /// Pool waveform data PoolWaveform(Result, String>), /// Pool file info (duration, sample_rate, channels) - PoolFileInfo(Result<(f64, u32, u32), String>), + PoolFileInfo(Result<(Seconds, u32, u32), String>), /// Audio exported AudioExported(Result<(), String>), /// MIDI clip instance added (returns instance ID) diff --git a/daw-backend/src/lib.rs b/daw-backend/src/lib.rs index a8153e6..c258ca9 100644 --- a/daw-backend/src/lib.rs +++ b/daw-backend/src/lib.rs @@ -20,7 +20,7 @@ pub use audio::{ TrackNode, }; 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 command::{AudioEvent, Command, OscilloscopeData}; pub use command::types::AutomationKeyframeData; diff --git a/daw-backend/src/time.rs b/daw-backend/src/time.rs index b2e2913..fdea4f4 100644 --- a/daw-backend/src/time.rs +++ b/daw-backend/src/time.rs @@ -16,6 +16,50 @@ pub struct Beats(pub f64); #[serde(transparent)] pub struct Seconds(pub f64); +/// A time *inside a clip's own content*, in whatever unit that clip measures content in. +/// +/// Clip content time is domain-polymorphic: SECONDS for sampled audio, video and vector, but BEATS +/// for MIDI (musical, so it survives tempo changes). `ClipInstance::trim_start`/`trim_end` are +/// content times, and storing them as bare `f64`s is what let a seconds delta get added to a MIDI +/// clip's beats trim — splitting a MIDI clip at beat 4 landed at beat 2 at 120 BPM. +/// +/// This type is deliberately a **dead end**: it has no `.to_seconds()`, no `.to_beats()`, and no +/// arithmetic with `Seconds` or `Beats`. Content times can be compared and combined with each other +/// (that's domain-safe — both operands are in the same clip's domain), but the only way to get a +/// real timeline duration out is to resolve it against the clip that knows the domain, via +/// `AudioClip::resolve_content_time` / `Document::resolve_content_time`. So a passthrough costs +/// nothing, and mixing domains won't compile. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ContentTime(pub f64); + +impl ContentTime { + pub const ZERO: Self = Self(0.0); + + pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) } + pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) } + + /// The raw magnitude, with the domain discarded. + /// + /// Only for code that is *already* working in this clip's content domain (trim arithmetic, + /// serialization, drawing a waveform whose x-axis is the clip's own content). If you are about + /// to combine this with a timeline position, resolve it against the clip instead. + pub fn raw(self) -> f64 { self.0 } +} + +impl Add for ContentTime { + type Output = Self; + fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) } +} +impl Sub for ContentTime { + type Output = Self; + fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) } +} +impl Rem for ContentTime { + type Output = Self; + fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) } +} + impl Beats { pub const ZERO: Self = Self(0.0); diff --git a/daw-backend/src/tui/mod.rs b/daw-backend/src/tui/mod.rs index 0d44a6b..645c699 100644 --- a/daw-backend/src/tui/mod.rs +++ b/daw-backend/src/tui/mod.rs @@ -556,7 +556,7 @@ pub fn run_tui( while let Ok(event) = rx.pop() { match event { AudioEvent::PlaybackPosition(pos) => { - app.update_playback_position(pos); + app.update_playback_position(pos.seconds_to_f64()); } AudioEvent::PlaybackStopped => { app.set_playing(false); diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 226ddfb..2c96842 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -47,6 +47,113 @@ pub struct BackendContext<'a> { // Future: pub video_controller: Option<&'a mut VideoController>, } +impl BackendContext<'_> { + /// Hand a clip instance to the audio engine and record it in the instance→backend map. + /// + /// Take folders are resolved through the instance's `active_take`, so the backend gets whichever + /// take is selected. Returns the backend track and instance IDs, or `None` when there's nothing + /// to sync yet (a recording in progress, or an empty take folder). + /// + /// Lives here rather than in any one action because more than one action needs it: adding an + /// instance, and switching a take folder's active take (which is a remove + re-add, there being + /// no in-place pool-swap command). Keeping one implementation keeps the trim/duration + /// conversions — the easy thing to get subtly wrong, since `trim_*` is SECONDS while + /// `timeline_*` is BEATS — from drifting between copies. + pub fn add_clip_instance( + &mut self, + document: &Document, + layer_id: &Uuid, + instance: &crate::clip::ClipInstance, + ) -> Result, String> { + use crate::clip::ResolvedContent; + + let clip = document + .get_audio_clip(&instance.clip_id) + .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; + + let track_id = *self + .layer_to_track_map + .get(layer_id) + .ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?; + + let resolved = instance.resolve(clip); + let content = clip.content_duration(); + let internal_start = instance.trim_start; + let internal_end = instance + .trim_end + .unwrap_or(daw_backend::ContentTime(content.native())); + let start_time = instance.timeline_start; + + // How long the clip occupies the timeline, in BEATS. `effective_duration_beats` resolves the + // content window in the clip's own domain — beats content carries over directly, wall-clock + // content converts at the clip's position — so neither kind can be read as the other here. + let effective_duration = instance.effective_duration_beats(content, document.tempo_map()); + + let controller = self + .audio_controller + .as_mut() + .ok_or_else(|| "Audio controller not available".to_string())?; + + let backend_id = match resolved { + ResolvedContent::Midi { midi_clip_id } => { + use daw_backend::command::{Query, QueryResponse}; + + // MIDI content time IS beats, so the trims carry straight over. + let midi_instance = daw_backend::MidiClipInstance::new( + 0, // assigned by the backend + midi_clip_id, + daw_backend::Beats(internal_start.raw()), + daw_backend::Beats(internal_end.raw()), + start_time, + effective_duration, + ); + + match controller + .send_query(Query::AddMidiClipInstanceSync(track_id, midi_instance))? + { + QueryResponse::MidiClipInstanceAdded(Ok(id)) => BackendClipInstanceId::Midi(id), + QueryResponse::MidiClipInstanceAdded(Err(e)) => return Err(e), + _ => return Err("Unexpected query response".to_string()), + } + } + ResolvedContent::Audio { audio_pool_index } => { + // Sampled-audio content time is SECONDS; the backend's start/duration are BEATS. + let id = controller.add_audio_clip( + track_id, + audio_pool_index, + start_time, + effective_duration, + daw_backend::Seconds(internal_start.raw()), + ); + BackendClipInstanceId::Audio(id) + } + // Nothing to sync until it has content. + ResolvedContent::Recording => return Ok(None), + }; + + self.clip_instance_to_backend_map + .insert(instance.id, backend_id); + + Ok(Some((track_id, backend_id))) + } + + /// Remove a clip instance's backend clip and drop it from the instance→backend map. + pub fn remove_clip_instance( + &mut self, + track_id: daw_backend::TrackId, + backend_id: BackendClipInstanceId, + instance_id: Uuid, + ) { + if let Some(controller) = self.audio_controller.as_mut() { + match backend_id { + BackendClipInstanceId::Midi(id) => controller.remove_midi_clip(track_id, id), + BackendClipInstanceId::Audio(id) => controller.remove_audio_clip(track_id, id), + } + } + self.clip_instance_to_backend_map.remove(&instance_id); + } +} + /// Action trait for undo/redo operations /// /// Each action must be able to execute (apply changes) and rollback (undo changes). diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index f39d8ad..e7aa768 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -89,10 +89,11 @@ impl Action for AddClipInstanceAction { // `get_clip_duration` is the content length in seconds; the placement span // must be beats (the timeline is beats-domain), so convert via the clip's // typed helper rather than treating the seconds span as beats. - 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))?; 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 let adjusted_start = document.find_nearest_valid_position( @@ -196,113 +197,23 @@ impl Action for AddClipInstanceAction { return Ok(()); } - // Look up the clip from the document - let clip = document - .get_audio_clip(&self.clip_instance.clip_id) - .ok_or_else(|| "Audio clip not found".to_string())?; - - // Look up backend track ID from layer mapping - let backend_track_id = backend - .layer_to_track_map - .get(&self.layer_id) - .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; - - // Get audio controller - let controller = backend - .audio_controller - .as_mut() - .ok_or_else(|| "Audio controller not available".to_string())?; - - // Handle different clip types - use crate::clip::AudioClipType; - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { - // Create a MIDI clip instance referencing the existing clip in the backend pool - // No need to add to pool again - it was added during MIDI import - use daw_backend::command::{Query, QueryResponse}; - - // Calculate internal start/end from trim parameters - let internal_start = self.clip_instance.trim_start; - let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native()); - let external_start = self.clip_instance.timeline_start; - - // Calculate external duration (for looping if timeline_duration is set). - // MIDI trims are beats-domain, so the fallback span is beats too. - let external_duration = self.clip_instance.timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - - // Create MidiClipInstance - let instance = daw_backend::MidiClipInstance::new( - 0, // Instance ID will be assigned by backend - *midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), - external_start, - external_duration, - ); - - // Send query to add instance and get instance ID - let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance); - - match controller.send_query(query)? { - QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => { - self.backend_track_id = Some(*backend_track_id); - self.backend_midi_instance_id = Some(instance_id); - - // Add to global clip instance mapping - backend.clip_instance_to_backend_map.insert( - self.clip_instance.id, - crate::action::BackendClipInstanceId::Midi(instance_id) - ); - - Ok(()) - } - QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e), - _ => Err("Unexpected query response".to_string()), + // Add via the shared BackendContext helper — the same one SetActiveTakeAction uses, so + // the trim/duration conversions (and take-folder resolution) live in exactly one place. + if let Some((track_id, backend_id)) = + backend.add_clip_instance(document, &self.layer_id, &self.clip_instance)? + { + self.backend_track_id = Some(track_id); + match backend_id { + crate::action::BackendClipInstanceId::Midi(id) => { + self.backend_midi_instance_id = Some(id) + } + crate::action::BackendClipInstanceId::Audio(id) => { + self.backend_audio_instance_id = Some(id) } } - 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> { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs new file mode 100644 index 0000000..24f9bf4 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs @@ -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, + + // Stored during execute for rollback. + old_take_count: usize, + old_active_take: Option, + executed: bool, +} + +impl AppendTakesAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, new_takes: Vec) -> 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 = 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) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index 4e05db1..4def017 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -91,7 +91,7 @@ impl Action for LoopClipInstancesAction { impl LoopClipInstancesAction { fn sync_backend(&self, backend: &mut crate::action::BackendContext, document: &Document, rollback: bool) -> Result<(), String> { - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; let controller = match backend.audio_controller.as_mut() { Some(c) => c, @@ -128,26 +128,22 @@ impl LoopClipInstancesAction { (new_dur, new_lb) }; - let content_window = { - let trim_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - (trim_end - instance.trim_start).max(0.0) // seconds - }; - // Natural content length as a beats span at the clip's start (the - // fallback when no explicit timeline_duration is set). - let tempo_map = document.tempo_map(); - let content_window_beats = tempo_map.seconds_to_beats( - tempo_map.beats_to_seconds(instance.timeline_start) - + daw_backend::Seconds(content_window), - ) - instance.timeline_start; + // Natural content length as a beats span (the fallback when no explicit + // timeline_duration is set). Resolved in the clip's own domain, so MIDI's beats + // content carries over directly rather than being read as seconds. + let content_window_beats = instance.effective_duration_beats( + clip.content_duration(), + document.tempo_map(), + ); let right_duration = target_duration.unwrap_or(content_window_beats); let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO); let external_duration = left_duration + right_duration; let external_start = instance.timeline_start - left_duration; let get_backend_clip_id = |inst_id: &Uuid| -> Result { - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => Ok(*midi_clip_id), - AudioClipType::Sampled { .. } => { + match &instance.resolve(clip) { + ResolvedContent::Midi { midi_clip_id } => Ok(*midi_clip_id), + ResolvedContent::Audio { .. } => { let backend_id = backend.clip_instance_to_backend_map.get(inst_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?; match backend_id { @@ -155,7 +151,7 @@ impl LoopClipInstancesAction { _ => 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()), } }; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/manage_takes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/manage_takes.rs new file mode 100644 index 0000000..4790a2b --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/manage_takes.rs @@ -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 = 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, + old_active_take: Option, +} + +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, + old_active_take: Option, +} + +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"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 83d426f..a848cab 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -15,6 +15,9 @@ pub mod reorder_clip_instances; pub mod paint_bucket; pub mod remove_effect; pub mod set_cycle_region; +pub mod append_takes; +pub mod manage_takes; +pub mod set_active_take; pub mod set_document_properties; pub mod set_instance_properties; pub mod set_layer_properties; @@ -52,6 +55,9 @@ pub mod resize_text_box; pub use add_clip_instance::AddClipInstanceAction; pub use set_cycle_region::SetCycleRegionAction; +pub use append_takes::AppendTakesAction; +pub use manage_takes::{DeleteTakeAction, DeleteUnusedTakesAction, RenameTakeAction}; +pub use set_active_take::SetActiveTakeAction; pub use add_effect::AddEffectAction; pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index 73f49b2..20aa7ef 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -105,7 +105,7 @@ impl Action for MoveClipInstancesAction { let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| { let inst = clip_instances.iter().find(|ci| &ci.id == id)?; - let dur = document.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()); Some((*id, *old_start, eff)) }).collect(); @@ -190,7 +190,7 @@ impl Action for MoveClipInstancesAction { fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -211,8 +211,9 @@ impl Action for MoveClipInstancesAction { // Check if this clip has a metatrack if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -246,12 +247,12 @@ impl Action for MoveClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Handle move based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &instance.resolve(clip) { + ResolvedContent::Midi { midi_clip_id } => { // For MIDI: move_clip expects the pool clip ID controller.move_clip(*track_id, *midi_clip_id, *new_start); } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { // For sampled audio: move_clip expects the instance ID let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; @@ -263,7 +264,7 @@ impl Action for MoveClipInstancesAction { _ => return Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // 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> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller 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(&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_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -330,12 +332,12 @@ impl Action for MoveClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Handle move based on clip type (restore old position) - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &instance.resolve(clip) { + ResolvedContent::Midi { midi_clip_id } => { // For MIDI: move_clip expects the pool clip ID controller.move_clip(*track_id, *midi_clip_id, *old_start); } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { // For sampled audio: move_clip expects the instance ID let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; @@ -347,7 +349,7 @@ impl Action for MoveClipInstancesAction { _ => return Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be moved - skip } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs index 947e6e0..319e397 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -138,92 +138,23 @@ impl Action for RemoveClipInstancesAction { backend: &mut BackendContext, document: &Document, ) -> Result<(), String> { - use crate::clip::AudioClipType; + if backend.audio_controller.is_none() { + return Ok(()); + } - let controller = match backend.audio_controller.as_mut() { - Some(c) => c, - None => return Ok(()), - }; - - // Re-add clips that were removed from backend - for (layer_id, instance) in &self.saved { - let layer = match document.get_layer(layer_id) { - Some(l) => l, - None => continue, - }; - if !matches!(layer, AnyLayer::Audio(_)) { + // Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same + // helper the add and split actions use, so the trim/duration conversions (and take-folder + // resolution) stay in exactly one place instead of being copied into every action that has + // to put a clip back. + let saved = std::mem::take(&mut self.saved); + for (layer_id, instance) in &saved { + if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) { continue; } - - let track_id = match backend.layer_to_track_map.get(layer_id) { - Some(id) => *id, - None => continue, - }; - - let clip = match document.get_audio_clip(&instance.clip_id) { - Some(c) => c, - None => continue, - }; - - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { - use daw_backend::command::{Query, QueryResponse}; - - let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - let external_start = instance.timeline_start; - // MIDI trims are beats-domain, so the fallback span is beats too. - let external_duration = instance - .timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - - let midi_instance = daw_backend::MidiClipInstance::new( - 0, - *midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), - external_start, - external_duration, - ); - - let query = Query::AddMidiClipInstanceSync(track_id, midi_instance); - if let Ok(QueryResponse::MidiClipInstanceAdded(Ok(new_id))) = - controller.send_query(query) - { - backend.clip_instance_to_backend_map.insert( - instance.id, - BackendClipInstanceId::Midi(new_id), - ); - } - } - AudioClipType::Sampled { audio_pool_index } => { - let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - let start_time = instance.timeline_start; - // Fallback span is the content seconds converted to beats at the - // clip's start (not the seconds span treated as beats). - let effective_duration = instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - - let new_id = controller.add_audio_clip( - track_id, - *audio_pool_index, - start_time, - effective_duration, - daw_backend::Seconds(internal_start), - ); - backend.clip_instance_to_backend_map.insert( - instance.id, - BackendClipInstanceId::Audio(new_id), - ); - } - AudioClipType::Recording => {} - } + // A missing track/clip just means there's nothing to restore on the backend. + let _ = backend.add_clip_instance(document, layer_id, instance); } + self.saved = saved; // Clear saved backend IDs self.saved_backend_ids.clear(); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_active_take.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_active_take.rs new file mode 100644 index 0000000..142d485 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_active_take.rs @@ -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, + old_take: Option, + /// The backend track/clip the instance was on before we swapped, so rollback can undo it. + backend_track_id: Option, +} + +impl SetActiveTakeAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, new_take: usize, old_take: Option) -> 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) -> 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 = 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) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index 1e2c6d4..2f19b85 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -7,6 +7,7 @@ use crate::action::{Action, BackendContext}; use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; +use daw_backend::ContentTime; use uuid::Uuid; /// Action that splits a clip instance at a specific timeline position @@ -25,7 +26,7 @@ pub struct SplitClipInstanceAction { // Stored during execute for rollback /// Original trim_end value of the left (original) instance - original_trim_end: Option, + original_trim_end: Option, /// Original timeline_duration value of the left (original) instance (beats) original_timeline_duration: Option, /// 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) .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; - // Get the clip's duration - let clip_duration = document - .get_clip_duration(&instance.clip_id) + // The clip's content duration in its OWN domain — seconds for audio/video/vector, beats for + // MIDI. All the content math below is trim-domain, so it has to be done in whichever domain + // this clip uses; a seconds duration would silently be added to a MIDI clip's beats trim. + let trim_duration = document + .clip_trim_duration(&instance.clip_id) .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; // Calculate the effective duration and timeline end (both in beats) - let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); + let effective_duration = instance.effective_duration(trim_duration, document.tempo_map()); let timeline_end = instance.timeline_start + effective_duration; // 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_timeline_duration = instance.timeline_duration; - // Check if this is a looping clip. `content_duration` is a trim-domain - // span (seconds), so `clip_duration` must be unwrapped as seconds. let is_looping = instance.timeline_duration.is_some(); - let content_duration = 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). let time_into_clip = self.split_time - instance.timeline_start; let left_duration = time_into_clip; let right_duration = effective_duration - left_duration; - // How far the split lands into the clip's *content* (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 time_into_clip_secs = (tempo_map.beats_to_seconds(self.split_time) - - tempo_map.beats_to_seconds(instance.timeline_start)).seconds_to_f64(); + let time_into_content = ContentTime(match trim_duration { + crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(), + crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time) + - tempo_map.beats_to_seconds(instance.timeline_start)) + .seconds_to_f64(), + }); - // Calculate content split time (seconds) - let content_split_time = if is_looping { + // Calculate the content split point (content domain). + let content_split_time = if is_looping && content_duration > ContentTime::ZERO { // For looping clips, wrap around content - instance.trim_start + (time_into_clip_secs % content_duration) + instance.trim_start + (time_into_content % content_duration) } else { - instance.trim_start + time_into_clip_secs + instance.trim_start + time_into_content }; // Clone the instance for the right side @@ -357,119 +363,63 @@ impl Action for SplitClipInstanceAction { .get_audio_clip(&new_instance.clip_id) .ok_or_else(|| "Audio clip not found".to_string())?; - // Look up backend track ID from layer mapping - let backend_track_id = backend + use crate::clip::ResolvedContent; + if matches!(original_instance.resolve(clip), ResolvedContent::Recording) { + return Err("Cannot split a clip that is currently recording".to_string()); + } + + // A split is: shorten the left half's backend clip, then add the right half as a new one. + // + // 1. Trim the left (original) instance. `trim_range` tags the bounds with the clip's own + // content domain, so a MIDI clip's beats trims can't be sent as seconds. + let left_trim = clip.trim_range( + original_instance.trim_start, + original_instance + .trim_end + .unwrap_or(ContentTime(clip.content_duration().native())), + ); + let new_instance = new_instance.clone(); + + let backend_track_id = *backend .layer_to_track_map .get(&self.layer_id) .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; + let left_backend_id = backend + .clip_instance_to_backend_map + .get(&self.instance_id) + .copied(); - // Get audio controller - let controller = backend - .audio_controller - .as_mut() - .ok_or_else(|| "Audio controller not available".to_string())?; - - // Handle different clip types - use crate::clip::AudioClipType; - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { - 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); + { + let controller = backend + .audio_controller + .as_mut() + .ok_or_else(|| "Audio controller not available".to_string())?; + match left_backend_id { + Some(crate::action::BackendClipInstanceId::Midi(id)) + | Some(crate::action::BackendClipInstanceId::Audio(id)) => { + controller.trim_clip(backend_track_id, id, left_trim); } - - // 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()) + None => {} } } + + // 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( @@ -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(clip) = document.get_audio_clip(&instance.clip_id) { let orig_internal_start = instance.trim_start; - let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native()); + let orig_internal_end = self + .original_trim_end + .unwrap_or(ContentTime(clip.content_duration().native())); // Restore based on clip type - use crate::clip::AudioClipType; - match &clip.clip_type { - AudioClipType::Midi { .. } => { + use crate::clip::ResolvedContent; + match &instance.resolve(clip) { + ResolvedContent::Midi { .. } => { if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = backend.clip_instance_to_backend_map.get(&self.instance_id) { - controller.trim_clip(track_id, *orig_backend_id, 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)) = 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 } } @@ -554,8 +506,8 @@ mod tests { // Create a clip instance at timeline 0, with trim 0-10 (10 seconds) let mut clip_instance = ClipInstance::new(clip_id); clip_instance.timeline_start = daw_backend::Beats::ZERO; - clip_instance.trim_start = 0.0; - clip_instance.trim_end = Some(10.0); + clip_instance.trim_start = ContentTime::ZERO; + clip_instance.trim_end = Some(ContentTime(10.0)); let instance_id = clip_instance.id; 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)); 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"); + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index d4d48bc..7e99d94 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -6,7 +6,7 @@ use crate::action::Action; use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; -use daw_backend::{Beats, Seconds}; +use daw_backend::{Beats, ContentTime, Seconds}; use std::collections::HashMap; use uuid::Uuid; @@ -32,15 +32,57 @@ pub struct TrimClipInstancesAction { pub struct TrimData { /// For TrimLeft: trim_start value /// For TrimRight: trim_end value (Option because it can be None) - pub trim_value: Option, + /// + /// 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, /// For TrimLeft: timeline_start value (where the clip appears on timeline, beats) /// For TrimRight: unused (None) pub timeline_start: Option, } +/// A wall-clock gap on the timeline, expressed in a clip's content domain. +/// +/// Trim validation clamps how far a clip may be dragged against the empty space next to it, and that +/// space is measured on the timeline (seconds) while the trim lives in the clip's content domain. For +/// wall-clock content they're the same number; for MIDI (beats content) the gap has to be converted +/// at the clip's position, or a seconds gap silently clamps a beats trim. +fn gap_to_content( + gap: Seconds, + clip_content: crate::clip::ClipDuration, + timeline_start: Beats, + tempo_map: &crate::tempo_map::TempoMap, +) -> ContentTime { + match clip_content { + crate::clip::ClipDuration::Seconds(_) => ContentTime(gap.seconds_to_f64()), + crate::clip::ClipDuration::Beats(_) => { + let beats = tempo_map + .seconds_to_beats(tempo_map.beats_to_seconds(timeline_start) + gap) + - timeline_start; + ContentTime(beats.beats_to_f64()) + } + } +} + +/// The inverse: a content-domain span as wall-clock seconds at the clip's position. +fn content_to_secs( + span: ContentTime, + clip_content: crate::clip::ClipDuration, + timeline_start: Beats, + tempo_map: &crate::tempo_map::TempoMap, +) -> Seconds { + match clip_content { + crate::clip::ClipDuration::Seconds(_) => Seconds(span.raw()), + crate::clip::ClipDuration::Beats(_) => { + tempo_map.beats_to_seconds(timeline_start + Beats(span.raw())) + - tempo_map.beats_to_seconds(timeline_start) + } + } +} + impl TrimData { /// Create TrimData for left trim - pub fn left(trim_start: f64, timeline_start: Beats) -> Self { + pub fn left(trim_start: ContentTime, timeline_start: Beats) -> Self { Self { trim_value: Some(trim_start), timeline_start: Some(timeline_start), @@ -48,7 +90,7 @@ impl TrimData { } /// Create TrimData for right trim - pub fn right(trim_end: Option) -> Self { + pub fn right(trim_end: Option) -> Self { Self { trim_value: trim_end, timeline_start: None, @@ -192,7 +234,8 @@ impl Action for TrimClipInstancesAction { .find(|ci| &ci.id == 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))?; let mut clamped_new = new.clone(); @@ -204,23 +247,34 @@ impl Action for TrimClipInstancesAction { { // If extending to the left (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(); + + // 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 - .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); clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start); @@ -228,36 +282,39 @@ impl Action for TrimClipInstancesAction { } } TrimType::TrimRight => { - let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64()); - let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64()); + let content_end = ContentTime(clip_content.native()); + let old_trim_end = old.trim_value.unwrap_or(content_end); + let new_trim_end = new.trim_value.unwrap_or(content_end); // If extending to the right (new_trim_end > old_trim_end) if should_validate && new_trim_end > old_trim_end { let tempo_map = document.tempo_map(); - // Current effective duration in beats (content seconds - // converted to beats at the clip's start). - let content_secs = Seconds(old_trim_end - instance.trim_start); - let current_effective_duration = tempo_map.seconds_to_beats( - tempo_map.beats_to_seconds(instance.timeline_start) + content_secs, - ) - instance.timeline_start; - // Max rightward extension as content seconds (the gap's wall-clock span). - let max_extend_secs = document.find_max_trim_extend_right( - layer_id, - instance_id, + // How long the clip currently occupies the timeline, in beats. Resolved + // in the clip's own domain, so a MIDI clip's beats content isn't run + // through the seconds→beats conversion a second time. + let current_effective_duration = instance + .effective_duration_beats(clip_content, tempo_map); + + // Max rightward extension: the gap's wall-clock span, in content domain. + let max_extend = gap_to_content( + document.find_max_trim_extend_right( + layer_id, + instance_id, + instance.timeline_start, + current_effective_duration, + ), + clip_content, instance.timeline_start, - current_effective_duration, - ).seconds_to_f64(); + tempo_map, + ); - // Calculate how much we want to extend (content seconds) let desired_extend = new_trim_end - old_trim_end; - - // Clamp to max allowed - let actual_extend = desired_extend.min(max_extend_secs); + let actual_extend = desired_extend.min(max_extend); let clamped_trim_end = old_trim_end + actual_extend; - // Don't exceed clip duration - let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64()); + // Don't exceed the clip's content. + let final_trim_end = clamped_trim_end.min(content_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> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller 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) { // Instance already has new values after execute() 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)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -424,27 +482,29 @@ impl Action for TrimClipInstancesAction { // Calculate new internal_start and internal_end for backend // Note: instance already has the new trim values after execute() let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); + let internal_end = instance + .trim_end + .unwrap_or(ContentTime(clip.content_duration().native())); // Handle trim based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &instance.resolve(clip) { + ResolvedContent::Midi { midi_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 let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; match backend_instance_id { crate::action::BackendClipInstanceId::Audio(audio_id) => { - controller.trim_clip(*track_id, *audio_id, 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()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // 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> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller 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) { // Instance already has old values after rollback() 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)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -512,34 +573,35 @@ impl Action for TrimClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Calculate old internal_start and internal_end for backend + let content_end = ContentTime(clip.content_duration().native()); let internal_start = match trim_type { - TrimType::TrimLeft => old.trim_value.unwrap_or(0.0), + TrimType::TrimLeft => old.trim_value.unwrap_or(ContentTime::ZERO), TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed }; let internal_end = match trim_type { - TrimType::TrimLeft => instance.trim_end.unwrap_or(clip.content_duration().native()), // trim_end wasn't changed - TrimType::TrimRight => old.trim_value.unwrap_or(clip.content_duration().native()), + TrimType::TrimLeft => instance.trim_end.unwrap_or(content_end), // trim_end wasn't changed + TrimType::TrimRight => old.trim_value.unwrap_or(content_end), }; // Handle trim based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &instance.resolve(clip) { + ResolvedContent::Midi { midi_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 let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; match backend_instance_id { crate::action::BackendClipInstanceId::Audio(audio_id) => { - controller.trim_clip(*track_id, *audio_id, 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()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be trimmed - skip } } @@ -569,7 +631,7 @@ mod tests { let mut clip_instance = ClipInstance::new(clip_id); clip_instance.timeline_start = Beats::ZERO; - clip_instance.trim_start = 0.0; + clip_instance.trim_start = ContentTime::ZERO; let instance_id = clip_instance.id; vector_layer.clip_instances.push(clip_instance); @@ -582,8 +644,8 @@ mod tests { vec![( instance_id, TrimType::TrimLeft, - TrimData::left(0.0, Beats::ZERO), - TrimData::left(2.0, Beats(2.0)), + TrimData::left(ContentTime::ZERO, Beats::ZERO), + TrimData::left(ContentTime(2.0), Beats(2.0)), )], ); @@ -599,7 +661,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_start, 2.0); + assert_eq!(instance.trim_start, ContentTime(2.0)); assert_eq!(instance.timeline_start, Beats(2.0)); } @@ -613,7 +675,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_start, 0.0); + assert_eq!(instance.trim_start, ContentTime::ZERO); assert_eq!(instance.timeline_start, Beats::ZERO); } } @@ -644,7 +706,7 @@ mod tests { instance_id, TrimType::TrimRight, TrimData::right(None), - TrimData::right(Some(8.0)), + TrimData::right(Some(ContentTime(8.0))), )], ); @@ -660,7 +722,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_end, Some(8.0)); + assert_eq!(instance.trim_end, Some(ContentTime(8.0))); } // Rollback diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index dd294c4..f39da7c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -14,7 +14,7 @@ use crate::layer::AnyLayer; use crate::layer_tree::LayerTree; use crate::object::Transform; -use daw_backend::{Beats, Seconds}; +use daw_backend::{Beats, ContentTime, Seconds}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use uuid::Uuid; @@ -130,10 +130,14 @@ impl VectorClip { let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration { ci.timeline_start + td_beats } else if let Some(te) = ci.trim_end { - 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)) } 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)) } else { continue; @@ -201,7 +205,9 @@ impl VectorClip { // Convert parent clip time (seconds) to nested clip local time (seconds). // timeline_start is in beats; convert to seconds using document BPM. let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - 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 let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) { @@ -468,6 +474,25 @@ pub enum AudioClipType { Recording, } +/// One take of a cycle recording — see [`ClipInstance::takes`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AudioTake { + /// Display name, e.g. "Take 1". User-editable. + pub name: String, + /// The recorded content this take points at. + pub content: TakeContent, +} + +/// What a take actually holds. An instance's takes are all the same kind — one cycle-record session +/// captures either audio or MIDI, never a mix. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum TakeContent { + /// Sampled audio: index into the audio pool. + Audio { audio_pool_index: usize }, + /// MIDI: backend MIDI clip ID. + Midi { midi_clip_id: u32 }, +} + /// A clip's content duration, tagged by its native unit. /// /// Sampled/recording audio and video measure content in wall-clock **seconds**; MIDI measures @@ -497,6 +522,17 @@ impl ClipDuration { ClipDuration::Beats(b) => b.beats_to_f64(), } } + + /// Tag a [`ContentTime`] with *this* duration's domain. + /// + /// Handy when you already hold a clip's content duration (so you know the domain) and need to + /// resolve one of its trim bounds, without going back to the clip. + pub fn same_domain(self, t: ContentTime) -> ClipDuration { + match self { + ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(t.raw())), + ClipDuration::Beats(_) => ClipDuration::Beats(Beats(t.raw())), + } + } } /// Audio clip @@ -532,11 +568,10 @@ impl AudioClip { /// The clip's content duration, tagged with its native domain (seconds for sampled/recording, /// beats for MIDI). This is the only sanctioned way to read the raw `duration` field. pub fn content_duration(&self) -> ClipDuration { - match self.clip_type { - AudioClipType::Midi { .. } => ClipDuration::Beats(Beats(self.duration)), - AudioClipType::Sampled { .. } | AudioClipType::Recording => { - ClipDuration::Seconds(Seconds(self.duration)) - } + if self.is_midi_domain() { + ClipDuration::Beats(Beats(self.duration)) + } else { + ClipDuration::Seconds(Seconds(self.duration)) } } @@ -545,15 +580,19 @@ impl AudioClip { pub fn set_content_duration(&mut self, duration: ClipDuration) { debug_assert!( matches!( - (&self.clip_type, duration), - (AudioClipType::Midi { .. }, ClipDuration::Beats(_)) - | (AudioClipType::Sampled { .. } | AudioClipType::Recording, ClipDuration::Seconds(_)) + (self.is_midi_domain(), duration), + (true, ClipDuration::Beats(_)) | (false, ClipDuration::Seconds(_)) ), "clip duration domain must match clip type", ); self.duration = duration.native(); } + /// Whether this clip's `duration` is measured in beats (MIDI) rather than seconds. + fn is_midi_domain(&self) -> bool { + matches!(self.clip_type, AudioClipType::Midi { .. }) + } + /// Create a new sampled audio clip /// /// # Arguments @@ -637,6 +676,73 @@ impl AudioClip { _ => None, } } + + /// The clip's own content, ignoring takes. + /// + /// Callers that are handing content to the backend want [`ClipInstance::resolve`] instead — an + /// instance with takes overrides the clip's content with whichever take is active. + pub fn resolve(&self) -> ResolvedContent { + match &self.clip_type { + AudioClipType::Sampled { audio_pool_index } => ResolvedContent::Audio { + audio_pool_index: *audio_pool_index, + }, + AudioClipType::Midi { midi_clip_id } => ResolvedContent::Midi { + midi_clip_id: *midi_clip_id, + }, + AudioClipType::Recording => ResolvedContent::Recording, + } + } + + /// Resolve a content time against this clip's domain. + /// + /// This is the ONLY sanctioned way to turn a [`ContentTime`] into a real duration — the type has + /// no `.to_seconds()` of its own precisely so that the clip, which is the one thing that knows + /// whether its content is measured in seconds or beats, has to be consulted. + pub fn resolve_content_time(&self, t: ContentTime) -> ClipDuration { + if self.is_midi_domain() { + ClipDuration::Beats(Beats(t.raw())) + } else { + ClipDuration::Seconds(Seconds(t.raw())) + } + } + + /// Tag a pair of trim bounds with this clip's content domain, ready for the backend. + /// + /// Building the [`TrimRange`] from the clip means a caller can't reach for the wrong variant: + /// the clip is the one thing that knows the domain. + pub fn trim_range(&self, start: ContentTime, end: ContentTime) -> daw_backend::command::TrimRange { + if self.is_midi_domain() { + daw_backend::command::TrimRange::Beats { + start: Beats(start.raw()), + end: Beats(end.raw()), + } + } else { + daw_backend::command::TrimRange::Seconds { + start: Seconds(start.raw()), + end: Seconds(end.raw()), + } + } + } + + /// Whether this clip's own content is the given audio pool index. + pub fn owns_audio_pool_index(&self, pool_index: usize) -> bool { + self.audio_pool_index() == Some(pool_index) + } + + /// Whether this clip's own content is the given backend MIDI clip ID. + pub fn owns_midi_clip_id(&self, id: u32) -> bool { + self.midi_clip_id() == Some(id) + } +} + +/// What a clip instance actually plays, once takes are resolved to the active one. +/// Produced by [`ClipInstance::resolve`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ResolvedContent { + Audio { audio_pool_index: usize }, + Midi { midi_clip_id: u32 }, + /// A recording in progress (or an empty take folder) — no backend content yet. + Recording, } /// Unified clip enum for polymorphic handling @@ -715,16 +821,17 @@ pub struct ClipInstance { /// Default: None (use trimmed clip duration, no looping) pub timeline_duration: Option, - /// Trim start: offset into the clip's internal content, in **seconds**. - /// - For audio: byte-offset into the audio file - /// - For video: seek position in the video file - /// - For vector: time offset into the animation + /// Trim start: offset into the clip's internal content. + /// + /// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled + /// audio/video/vector but BEATS for MIDI. Resolve it against the clip + /// ([`Document::resolve_content_time`]) before combining it with anything on the timeline. /// 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) - pub trim_end: Option, + pub trim_end: Option, /// Playback speed multiplier /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed @@ -741,6 +848,35 @@ pub struct ClipInstance { /// Default: None (no pre-loop) #[serde(default, skip_serializing_if = "Option::is_none")] pub loop_before: Option, + + /// 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, + + /// 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, + + /// 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, } /// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID. @@ -794,11 +930,14 @@ impl ClipInstance { name: None, timeline_start: Beats::ZERO, timeline_duration: None, - trim_start: 0.0, + trim_start: ContentTime::ZERO, trim_end: None, playback_speed: 1.0, gain: 1.0, loop_before: None, + takes: Vec::new(), + active_take: None, + recorded_loop_beats: None, } } @@ -812,11 +951,14 @@ impl ClipInstance { name: None, timeline_start: Beats::ZERO, timeline_duration: None, - trim_start: 0.0, + trim_start: ContentTime::ZERO, trim_end: None, playback_speed: 1.0, gain: 1.0, 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) - pub fn with_trimming(mut self, trim_start: f64, trim_end: Option) -> Self { + pub fn with_trimming(mut self, trim_start: ContentTime, trim_end: Option) -> Self { self.trim_start = trim_start; self.trim_end = trim_end; self @@ -876,24 +1018,89 @@ impl ClipInstance { 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 { + 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 { + 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. - pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds { - let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64()); - Seconds((end - self.trim_start).max(0.0)) + pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration { + let end = self.trim_end.map_or(clip_content.native(), |t| t.raw()); + let window = (end - self.trim_start.raw()).max(0.0); + match clip_content { + ClipDuration::Beats(_) => ClipDuration::Beats(Beats(window)), + ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(window)), + } } /// How long this instance appears on the timeline, in **beats**. /// - /// If `timeline_duration` is set, returns that (enabling content looping). - /// Otherwise converts the content window from seconds to beats using the tempo map. - pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + /// If `timeline_duration` is set, returns that (enabling content looping). Otherwise the clip + /// occupies its content window — converted to beats *in the clip's own domain*: + /// + /// - MIDI content is already beats and is tempo-invariant, so it carries over directly. + /// - Wall-clock content (audio/video/vector) is a seconds span, so it converts at the clip's + /// position on the timeline. + /// + /// Taking a `ClipDuration` rather than a bare `Seconds` is what keeps those apart: this used to + /// take seconds and subtract `trim_start` from it, which for a TRIMMED MIDI clip subtracted a + /// beats offset from a seconds duration and got the clip's length wrong. + pub fn effective_duration_beats(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { if let Some(td) = self.timeline_duration { return td; } - let window = self.content_window_secs(clip_duration_secs); - let start_secs = tempo_map.beats_to_seconds(self.timeline_start); - tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start + match self.content_window(clip_content) { + ClipDuration::Beats(b) => b, + ClipDuration::Seconds(s) => { + let start_secs = tempo_map.beats_to_seconds(self.timeline_start); + tempo_map.seconds_to_beats(start_secs + s) - self.timeline_start + } + } } /// 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**. - pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { - self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map) + pub fn total_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_content, tempo_map) } /// 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`. - pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option { + /// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats + /// trims are converted rather than read as seconds. Callers are the wall-clock consumers (video + /// seek, vector/raster rendering), which want seconds regardless of how the clip stores content. + /// + /// Returns `None` if the clip instance is not active at `time`. + pub fn remap_time_secs(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option { 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); if time < start_secs || time >= end_secs { return None; } + let trim_start_secs = clip_content.same_domain(self.trim_start).to_seconds(tempo_map); let content_time = (time - start_secs) * self.playback_speed; - let content_window = self.content_window_secs(clip_duration_secs); + let content_window = self.content_window(clip_content).to_seconds(tempo_map); if content_window == Seconds::ZERO { - return Some(Seconds(self.trim_start)); + return Some(trim_start_secs); } let looped = if content_time > content_window { @@ -931,19 +1143,19 @@ impl ClipInstance { content_time }; - Some(Seconds(self.trim_start) + looped) + Some(trim_start_secs + looped) } /// Alias for `remap_time_secs`. #[inline] - pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option { - self.remap_time_secs(time, clip_duration_secs, tempo_map) + pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option { + self.remap_time_secs(time, clip_content, tempo_map) } /// Alias for `effective_duration_beats`. #[inline] - pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { - self.effective_duration_beats(clip_duration_secs, tempo_map) + pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + self.effective_duration_beats(clip_content, tempo_map) } /// Convert to affine transform @@ -1020,7 +1232,7 @@ mod tests { assert_eq!(instance.clip_id, clip_id); assert_eq!(instance.opacity, 1.0); assert_eq!(instance.timeline_start, Beats::ZERO); - assert_eq!(instance.trim_start, 0.0); + assert_eq!(instance.trim_start, ContentTime::ZERO); assert_eq!(instance.trim_end, None); assert_eq!(instance.playback_speed, 1.0); assert_eq!(instance.gain, 1.0); @@ -1030,27 +1242,52 @@ mod tests { fn test_clip_instance_trimming() { let clip_id = Uuid::new_v4(); 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_end, Some(8.0)); + assert_eq!(instance.trim_start, ContentTime(2.0)); + assert_eq!(instance.trim_end, Some(ContentTime(8.0))); // At 60 BPM the tempo map is identity (1 beat == 1 second), so the // beats-domain effective duration equals the seconds content window. let tempo_map = crate::tempo_map::TempoMap::constant(60.0); - 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] fn test_clip_instance_no_end_trim() { let clip_id = Uuid::new_v4(); 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); // At 60 BPM the tempo map is identity (1 beat == 1 second). 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] @@ -1070,4 +1307,109 @@ mod tests { assert_eq!(instance.playback_speed, 2.0); assert_eq!(instance.gain, 0.8); } + + /// A clip whose own content is pool 10, plus an instance carrying takes over the given pools. + fn with_takes(pool_indices: &[usize]) -> (AudioClip, ClipInstance) { + let clip = AudioClip::new_sampled("Cycle rec", pool_indices[0], 2.0); + let mut instance = ClipInstance::new(clip.id); + instance.takes = pool_indices + .iter() + .enumerate() + .map(|(i, &audio_pool_index)| AudioTake { + name: format!("Take {}", i + 1), + content: TakeContent::Audio { audio_pool_index }, + }) + .collect(); + instance.recorded_loop_beats = Some(Beats(8.0)); + (clip, instance) + } + + #[test] + fn active_take_selects_the_pool_file() { + let (clip, mut instance) = with_takes(&[10, 11, 12]); + instance.active_take = Some(0); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10)); + instance.active_take = Some(2); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(12)); + // None means take 0. + instance.active_take = None; + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10)); + } + + #[test] + fn out_of_range_take_falls_back_to_the_first() { + // An index can go stale (an undo that shrank the list, an old .beam). Playing the first take + // beats playing nothing. + let (clip, mut instance) = with_takes(&[10, 11]); + instance.active_take = Some(99); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10)); + assert_eq!(instance.active_take_index(), 0); + } + + #[test] + fn an_instance_without_takes_plays_the_clips_own_content() { + let clip = AudioClip::new_sampled("Plain", 42, 2.0); + let instance = ClipInstance::new(clip.id); + assert!(instance.takes.is_empty()); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(42)); + } + + #[test] + fn midi_takes_resolve_to_midi_content() { + let clip = AudioClip::new_midi("Cycle rec", 7, Beats(4.0)); + let mut instance = ClipInstance::new(clip.id); + instance.takes = vec![AudioTake { + name: "Take 1".into(), + content: TakeContent::Midi { midi_clip_id: 7 }, + }]; + assert_eq!(clip.content_duration(), ClipDuration::Beats(Beats(4.0))); + assert_eq!(instance.resolved_midi_clip_id(&clip), Some(7)); + assert_eq!(instance.resolved_audio_pool_index(&clip), None); + } + + #[test] + fn splitting_gives_each_half_an_independent_take_list() { + // Takes live on the INSTANCE, so a split (which clones the instance) hands each half its own + // list. Two consequences, both wanted: the halves can select different takes (comping), and + // deleting a take from one leaves the other alone. + let (clip, left_src) = with_takes(&[10, 11, 12]); + let mut left = left_src.clone(); + let mut right = left_src.clone(); + right.id = Uuid::new_v4(); + + left.active_take = Some(0); + right.active_take = Some(2); + assert_eq!(left.resolved_audio_pool_index(&clip), Some(10)); + assert_eq!(right.resolved_audio_pool_index(&clip), Some(12)); + + // Delete take 2 (pool 11) from the left half only. + left.takes.remove(1); + assert_eq!(left.takes.len(), 2); + assert_eq!(right.takes.len(), 3, "the other half keeps its own takes"); + assert_eq!( + right.resolved_audio_pool_index(&clip), + Some(12), + "and its selection still points where it did", + ); + } + + #[test] + fn clip_instance_without_active_take_deserializes() { + // Back-compat: .beam files written before take folders have no `active_take` field. + let json = r#"{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "clip_id": "550e8400-e29b-41d4-a716-446655440001", + "transform": {"x": 0.0, "y": 0.0, "rotation": 0.0, "scale_x": 1.0, "scale_y": 1.0, "skew_x": 0.0, "skew_y": 0.0}, + "opacity": 1.0, + "name": null, + "timeline_start": 0.0, + "timeline_duration": null, + "trim_start": 0.0, + "trim_end": null, + "playback_speed": 1.0, + "gain": 1.0 + }"#; + let instance: ClipInstance = serde_json::from_str(json).expect("old instances must load"); + assert_eq!(instance.active_take, None); + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 19cb018..72bde66 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -464,8 +464,11 @@ impl Document { let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration { instance.timeline_start + timeline_duration } else { - let trim_end = instance.trim_end.unwrap_or(clip_duration); - let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0); + // `clip_duration` arrives as seconds (the recursive helper's signature), so this + // path is the wall-clock one; MIDI content would need resolving against its clip. + let trim_end = instance.trim_end.map_or(clip_duration, |t| t.raw()); + let trimmed_secs = + ((trim_end - instance.trim_start.raw()) / instance.playback_speed).max(0.0); let start_secs = tempo_map.beats_to_seconds(instance.timeline_start); 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. + /// A take folder owns one pool file per take, so any of them maps back to the folder. pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> { self.audio_clips.iter() - .find(|(_, c)| c.audio_pool_index() == Some(pool_index)) + .find(|(_, c)| c.owns_audio_pool_index(pool_index)) .map(|(&id, c)| (id, c)) } /// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID. + /// As above, a take folder owns one MIDI clip per take. pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> { self.audio_clips.iter() - .find(|(_, c)| c.midi_clip_id() == Some(midi_clip_id)) + .find(|(_, c)| c.owns_midi_clip_id(midi_clip_id)) .map(|(&id, c)| (id, c)) } @@ -911,6 +916,81 @@ impl Document { /// Searches through all clip libraries to find the clip and return its duration. /// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects /// have infinite internal duration. + /// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**. + /// + /// Content time is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for sampled + /// audio, video and vector, but BEATS for MIDI. Anything doing arithmetic against a trim value — + /// mapping a timeline position into the clip's content, say — has to work in that same domain, + /// and [`Self::get_clip_duration`] can't tell it which: that one always converts to seconds. + /// + /// Returns `None` for unknown clips. + pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option { + 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 { + 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 { + 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 { if let Some(clip) = self.vector_clips.get(clip_id) { if clip.is_group { @@ -964,9 +1044,9 @@ impl Document { }; let instance = instances.iter().find(|inst| &inst.id == instance_id)?; - let clip_duration = self.get_clip_duration(&instance.clip_id)?; - // End position on the timeline, in beats (convert the seconds content window via tempo map). - Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map())) + // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI. + let clip_content = self.clip_trim_duration(&instance.clip_id)?; + Some(instance.timeline_start + instance.effective_duration_beats(clip_content, self.tempo_map())) } /// Check if a time range overlaps with any existing clip on the layer @@ -1006,13 +1086,14 @@ impl Document { continue; } - // Calculate instance extent (accounting for loop_before) - let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { + // Calculate instance extent (accounting for loop_before). Content duration in the clip's + // own domain, so the trims resolve correctly for MIDI. + let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else { continue; }; 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 if start_time < instance_end && instance_start < end_time { @@ -1069,7 +1150,7 @@ impl Document { 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_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map()); occupied_ranges.push((inst_start, inst_end, instance.id)); @@ -1165,7 +1246,7 @@ impl Document { if group_ids.contains(&inst.id) { 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 end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map()); non_group.push((start, end)); @@ -1239,9 +1320,8 @@ impl Document { } // 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()); - // (clip_duration is Seconds via get_clip_duration; effective_duration converts.) // If this clip is to the left and closer than current nearest if other_end <= current_timeline_start && other_end > nearest_end { @@ -1339,7 +1419,7 @@ impl Document { 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()); if other_end <= current_effective_start && other_end > nearest_end { diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs index 3d52675..a6f762a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs @@ -152,7 +152,12 @@ impl EffectLayer { self.clip_instances .iter() .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 }) .collect() diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index 9cc7a92..e9d9f39 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -3,7 +3,7 @@ //! Provides functions for testing if points or rectangles intersect with //! vector graph elements and clip instances, taking into account transform hierarchies. -use crate::clip::ClipInstance; +use crate::clip::{ClipDuration, ClipInstance}; use crate::vector_graph::{VertexId, EdgeId, FillId}; use crate::layer::VectorLayer; use crate::shape::Shape; @@ -260,7 +260,10 @@ pub fn hit_test_clip_instances( for clip_instance in clip_instances.iter().rev() { // Check time bounds: skip clip instances not active at this time // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. - 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 timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time)); 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) 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) { 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 { // Check time bounds: skip clip instances not active at this time // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. - let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(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 timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time)); 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 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) { vector_clip.calculate_content_bounds(document, clip_time) diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 9c2cbd8..72df6cd 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -9,7 +9,7 @@ //! The compositing mode enables proper per-layer opacity, blend modes, and effects. use crate::animation::TransformProperty; -use crate::clip::{ClipInstance, ImageAsset}; +use crate::clip::{ClipDuration, ClipInstance, ImageAsset}; use crate::document::Document; use daw_backend::Seconds; use crate::gpu::BlendMode; @@ -568,7 +568,7 @@ pub fn render_layer_isolated( let tempo_map = document.tempo_map(); for clip_instance in &video_layer.clip_instances { let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; - let Some(clip_time) = clip_instance.remap_time(Seconds(time), 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 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 } 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; // Clip instance not active at this time }; @@ -1174,7 +1177,7 @@ fn render_video_layer( // Remap timeline time to clip's internal time let tempo_map = document.tempo_map(); - let Some(clip_time) = clip_instance.remap_time(Seconds(time), 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 }; let clip_time = clip_time.seconds_to_f64(); @@ -1910,7 +1913,10 @@ fn render_clip_instance_cpu( if time < start_secs || time >= end { return; } 0.0 } 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 }; t.seconds_to_f64() }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index 1801a9b..98ba307 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -35,6 +35,18 @@ pub struct AppConfig { #[serde(default = "defaults::audio_buffer_size")] pub audio_buffer_size: u32, + /// How a cycle MIDI recording treats its passes. + /// + /// `false` (default) = **merge**: every pass overdubs into one clip, and earlier passes play back + /// as you record so you can layer against them. `true` = **separate takes**: each pass becomes + /// its own take in a take folder, exactly as audio always does, and earlier passes stay silent + /// (they're alternatives, not layers). + /// + /// Only applies when the transport actually wraps; a recording that stops inside the first pass + /// is an ordinary single recording either way. + #[serde(default = "defaults::cycle_midi_separate_takes")] + pub cycle_midi_separate_takes: bool, + /// Reopen last session on startup #[serde(default = "defaults::reopen_last_session")] pub reopen_last_session: bool, @@ -131,6 +143,7 @@ impl Default for AppConfig { file_height: defaults::file_height(), scroll_speed: defaults::scroll_speed(), audio_buffer_size: defaults::audio_buffer_size(), + cycle_midi_separate_takes: defaults::cycle_midi_separate_takes(), reopen_last_session: defaults::reopen_last_session(), restore_layout_from_file: defaults::restore_layout_from_file(), debug: defaults::debug(), @@ -342,6 +355,7 @@ mod defaults { pub fn file_height() -> u32 { 600 } pub fn scroll_speed() -> f64 { 1.0 } pub fn audio_buffer_size() -> u32 { 256 } + pub fn cycle_midi_separate_takes() -> bool { false } pub fn reopen_last_session() -> bool { false } pub fn restore_layout_from_file() -> bool { true } pub fn debug() -> bool { false } diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index bea0759..cde1545 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -1064,7 +1064,12 @@ fn composite_document_to_hdr( } let tempo_map = document.tempo_map(); 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( effect_def, tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 2dcdfb5..9e0e2e5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -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, + loop_start: Beats, + loop_len: Beats, + takes: Vec, +} + struct EditorApp { layouts: Vec, current_layout_index: usize, @@ -1212,6 +1229,10 @@ struct EditorApp { metronome_enabled: bool, // Whether metronome clicks during recording count_in_enabled: bool, // Whether count-in fires before recording recording_clips: HashMap, // 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, recording_start_time: f64, // Playback time when recording started recording_layer_ids: Vec, // Layers being recorded to (for creating clips) // Asset drag-and-drop state @@ -1583,6 +1604,7 @@ impl EditorApp { metronome_enabled: false, // Metronome off by default count_in_enabled: false, // Count-in off by default recording_clips: HashMap::new(), // No active recording clips + pending_take_appends: Vec::new(), recording_start_time: 0.0, // Will be set when recording starts recording_layer_ids: Vec::new(), // Will be populated when recording starts dragging_asset: None, // No asset being dragged initially @@ -2038,6 +2060,91 @@ impl EditorApp { /// 2. For MIDI: Loads the default instrument /// 3. Stores the bidirectional mapping /// 4. Syncs any existing clips on the layer + /// Fold freshly-recorded cycle takes into an existing take folder covering the same region, if + /// there is one. Returns true if it did. + /// + /// Recording more takes over a region you've already recorded should extend that folder, not + /// stack a second clip on top of it — otherwise the new takes are stranded in an overlapping + /// clip and you can't audition them against the ones already there. + /// + /// The in-progress recording's own clip and instance are throwaway scaffolding in this case (the + /// takes themselves already live in the backend pools), so they're discarded. They were never + /// committed as an action, so there's nothing on the undo stack to unwind — the single + /// `AppendTakesAction` is the whole undoable step. + fn append_cycle_takes( + &mut self, + layer_id: uuid::Uuid, + recording_instance_id: uuid::Uuid, + recording_clip_id: uuid::Uuid, + recording_backend_id: Option, + loop_start: Beats, + loop_len: Beats, + takes: Vec, + ) -> bool { + let Some(target_instance_id) = self.action_executor.document().take_folder_at( + &layer_id, + loop_start, + loop_len, + &recording_instance_id, + ) else { + return false; + }; + + // Tear down the recording's own backend clip. Without this it keeps playing on top of the + // take folder we're appending to — two takes sounding at once. The target instance's clip is + // separately repointed at the new active take by the action's backend sync below. + let backend_id = recording_backend_id + .or_else(|| self.clip_instance_to_backend_map.remove(&recording_instance_id)); + self.clip_instance_to_backend_map.remove(&recording_instance_id); + let track_id = self.layer_to_track_map.get(&layer_id).copied(); + if let (Some(backend_id), Some(track_id), Some(controller_arc)) = + (backend_id, track_id, self.audio_controller.as_ref()) + { + let mut controller = controller_arc.lock().unwrap(); + match backend_id { + lightningbeam_core::action::BackendClipInstanceId::Audio(id) => { + controller.remove_audio_clip(track_id, id) + } + lightningbeam_core::action::BackendClipInstanceId::Midi(id) => { + controller.remove_midi_clip(track_id, id) + } + } + } + + // Discard the scaffolding clip + instance. + { + let doc = self.action_executor.document_mut(); + if let Some(AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) { + al.clip_instances.retain(|ci| ci.id != recording_instance_id); + } + doc.audio_clips.remove(&recording_clip_id); + } + + let action = lightningbeam_core::actions::AppendTakesAction::new( + layer_id, + target_instance_id, + takes, + ); + + if let Some(controller_arc) = self.audio_controller.clone() { + let mut controller = controller_arc.lock().unwrap(); + let mut backend_context = lightningbeam_core::action::BackendContext { + audio_controller: Some(&mut *controller), + layer_to_track_map: &self.layer_to_track_map, + clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + }; + if let Err(e) = self + .action_executor + .execute_with_backend(Box::new(action), &mut backend_context) + { + eprintln!("Failed to append cycle takes: {}", e); + } + } + + self.autosave.pending_event = true; + true + } + fn sync_audio_layers_to_backend(&mut self) { use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; @@ -2054,6 +2161,9 @@ impl EditorApp { let mut controller = controller_arc.lock().unwrap(); controller.set_loop_region(region); controller.set_loop_enabled(enabled); + // Cycle MIDI mode is a *preference*, not document state, so it rides along here + // rather than through an action. + controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes); } } @@ -2389,7 +2499,7 @@ impl EditorApp { ) -> Vec { let mut result = Vec::new(); 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 timeline_end = instance.timeline_start + effective_duration; @@ -3278,7 +3388,9 @@ impl EditorApp { let duplicates: Vec = clips_to_duplicate.iter().map(|original| { let mut duplicate = original.clone(); 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()); duplicate.timeline_start = original.timeline_start + effective_duration; 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). let (_dur, sample_rate, channels) = controller .get_pool_file_info(pool_index) - .unwrap_or((video_duration, 0, 0)); + .unwrap_or((Seconds(video_duration), 0, 0)); drop(controller); let audio_clip_name = format!("{} (Audio)", video_name); @@ -6386,7 +6498,9 @@ impl eframe::App for EditorApp { use daw_backend::AudioEvent; match event { 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 => { 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) 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) { @@ -6503,6 +6636,158 @@ impl eframe::App for EditorApp { } ctx.request_repaint(); } + AudioEvent::CycleRecordingStopped { clip_id: backend_clip_id, takes, loop_start, loop_len_beats, loop_len_seconds } => { + eprintln!("[STOP] CycleRecordingStopped: {} takes", takes.len()); + + // Clean up the live-recording waveform cache (keyed usize::MAX). + self.raw_audio_cache.remove(&usize::MAX); + self.waveform_gpu_dirty.remove(&usize::MAX); + + // Pull every take's samples in for waveform rendering — the user can + // switch to any of them, not just the active one. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for &(pool_index, _) in &takes { + match controller.get_pool_audio_samples(pool_index) { + Ok((samples, sr, ch)) => { + self.raw_audio_cache.insert(pool_index, (Arc::new(samples), sr, ch)); + self.waveform_gpu_dirty.insert(pool_index); + self.audio_pools_with_new_waveforms.insert(pool_index); + } + Err(e) => eprintln!("Failed to fetch take audio: {}", e), + } + self.audio_duration_cache.insert(pool_index, loop_len_seconds.seconds_to_f64()); + } + } + + let recording_layer = self.recording_clips.iter() + .find(|(_, &cid)| cid == backend_clip_id) + .map(|(&lid, _)| lid); + + if let (Some(layer_id), false) = (recording_layer, takes.is_empty()) { + let (clip_id, instance_id) = { + let document = self.action_executor.document(); + document.get_layer(&layer_id) + .and_then(|layer| { + if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { + audio_layer.clip_instances.last().map(|i| (i.clip_id, i.id)) + } else { + None + } + }) + .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil())) + }; + + if !clip_id.is_nil() { + self.autosave.pending_event = true; + let last_take = takes.len() - 1; + + let new_takes: Vec = takes + .iter() + .map(|&(pool_index, _)| lightningbeam_core::clip::AudioTake { + // Renumbered by the folder that ends up owning them. + name: String::new(), + content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index }, + }) + .collect(); + + // If this region already holds a take folder, extend it rather + // than dropping a second clip on top. Queued and run just after + // this loop, which holds a borrow on the event queue. + if self.action_executor.document() + .take_folder_at(&layer_id, loop_start, loop_len_beats, &instance_id) + .is_some() + { + self.pending_take_appends.push(PendingTakeAppend { + layer_id, + recording_instance_id: instance_id, + recording_clip_id: clip_id, + // The engine's recording clip. It isn't in + // clip_instance_to_backend_map yet (that only happens on + // promotion, which we're skipping), so hand it over + // directly or it'll keep sounding alongside the folder. + recording_backend_id: Some( + lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id), + ), + loop_start, + loop_len: loop_len_beats, + takes: new_takes, + }); + self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id); + ctx.request_repaint(); + continue; + } + + // Finalize the recording clip, and hang the takes off the INSTANCE. + { + let doc = self.action_executor.document_mut(); + if let Some(clip) = doc.audio_clips.get_mut(&clip_id) { + // The clip's own content is take 1; the instance's take + // list overrides it with whichever take is active. Every + // take is exactly one cycle region long, so the clip's + // duration is the region. + clip.finalize_recording( + takes[0].0, + loop_len_seconds.seconds_to_f64(), + ); + clip.name = format!("Cycle recording ({} takes)", takes.len()); + } + + // Anchor the instance to the region and select the most + // recent take, GarageBand-style. + // + // timeline_duration stays None on purpose: pinning it would + // make a later tempo change loop/repeat the take's content + // to fill the span instead of letting it drift naturally. + if let Some(lightningbeam_core::layer::AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) { + if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) { + inst.timeline_start = loop_start; + inst.timeline_duration = None; + // Audio take content is seconds, and each take spans + // exactly one cycle region. + inst.trim_start = daw_backend::ContentTime::ZERO; + inst.trim_end = + Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64())); + inst.takes = takes.iter().enumerate().map(|(i, &(pool_index, _))| { + lightningbeam_core::clip::AudioTake { + name: format!("Take {}", i + 1), + content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index }, + } + }).collect(); + inst.active_take = Some(last_take); + inst.recorded_loop_beats = Some(loop_len_beats); + } + } + } + + // The backend already has a clip for the active take (the engine + // pointed it at the last take on stop), so map to it rather than + // adding a duplicate. + let backend_id = lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id); + self.clip_instance_to_backend_map.insert(instance_id, backend_id); + + // Commit the whole cycle-record session as ONE undoable action. + let clip_instance = self.layer_to_track_map.get(&layer_id).copied().and_then(|track_id| { + self.action_executor.document() + .get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Audio(al) = l { + al.clip_instances.iter().find(|ci| ci.id == instance_id).cloned() + } else { None }) + .map(|ci| (track_id, ci)) + }); + if let Some((track_id, clip_instance)) = clip_instance { + let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied( + layer_id, clip_instance, track_id, backend_id, + ); + self.action_executor.push_applied(Box::new(action)); + } else { + self.media_modified = true; + } + } + } + + self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id); + } AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => { eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index); @@ -6528,6 +6813,7 @@ impl eframe::App for EditorApp { let mut controller = controller_arc.lock().unwrap(); match controller.get_pool_file_info(pool_index) { Ok((dur, _, _)) => { + let dur = dur.seconds_to_f64(); eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur); self.audio_duration_cache.insert(pool_index, dur); dur @@ -6561,7 +6847,7 @@ impl eframe::App for EditorApp { 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() { @@ -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) { clip.set_content_duration(ClipDuration::Beats(duration)); } @@ -6705,6 +6995,144 @@ impl eframe::App for EditorApp { } ctx.request_repaint(); } + AudioEvent::MidiCycleRecordingStopped { track_id, clip_ids, loop_start, loop_len_beats } => { + println!("🎹 MIDI cycle recording stopped: {} takes", clip_ids.len()); + + // Pull every take's events into the cache — the user can switch to any of + // them, not just the active one. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for &take_clip_id in &clip_ids { + if let Ok(data) = controller.query_midi_clip(track_id, take_clip_id) { + self.midi_event_cache.insert(take_clip_id, data.events); + } + } + } + + let layer_id = self.track_to_layer_map.get(&track_id).copied(); + if let (Some(layer_id), Some(&first_clip_id)) = (layer_id, clip_ids.first()) { + // The doc clip is the one the recording started on — which the backend + // reused as take 1. + let doc_clip_id = self.action_executor.document() + .audio_clip_by_midi_clip_id(first_clip_id) + .map(|(id, _)| id); + + if let Some(doc_clip_id) = doc_clip_id { + self.autosave.pending_event = true; + let last_take = clip_ids.len() - 1; + + let recording_instance_id = self.action_executor.document() + .get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Audio(al) = l { + al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).map(|ci| ci.id) + } else { None }); + + let new_takes: Vec = clip_ids + .iter() + .map(|&mid| lightningbeam_core::clip::AudioTake { + // Renumbered by the folder that ends up owning them. + name: String::new(), + content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid }, + }) + .collect(); + + // If this region already holds a take folder, extend it rather + // than dropping a second clip on top. Queued and run just after + // this loop, which holds a borrow on the event queue. + let existing_folder = recording_instance_id.filter(|rec_inst| { + self.action_executor.document() + .take_folder_at(&layer_id, loop_start, loop_len_beats, rec_inst) + .is_some() + }); + if let Some(rec_inst) = existing_folder { + self.pending_take_appends.push(PendingTakeAppend { + layer_id, + recording_instance_id: rec_inst, + recording_clip_id: doc_clip_id, + // The MIDI path maps its recording instance during + // MidiRecordingProgress, so the map has it. + recording_backend_id: None, + loop_start, + loop_len: loop_len_beats, + takes: new_takes, + }); + self.recording_layer_ids.retain(|id| *id != layer_id); + self.recording_clips.remove(&layer_id); + if self.recording_layer_ids.is_empty() { + self.is_recording = false; + self.recording_clips.clear(); + } + ctx.request_repaint(); + continue; + } + + { + let doc = self.action_executor.document_mut(); + if let Some(clip) = doc.audio_clips.get_mut(&doc_clip_id) { + // The clip's own content stays take 1 (the clip the + // recording started on); the instance's take list + // overrides it with whichever take is active. MIDI takes + // are beats-domain and each spans one cycle region. + clip.set_content_duration(ClipDuration::Beats(loop_len_beats)); + clip.name = format!("Cycle recording ({} takes)", clip_ids.len()); + } + + // Anchor to the region and select the most recent take. + // timeline_duration stays None on purpose — pinning it would + // make a later tempo change loop the take's content to fill + // the span instead of letting it drift naturally. + if let Some(AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) { + if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.clip_id == doc_clip_id) { + inst.timeline_start = loop_start; + inst.timeline_duration = None; + inst.trim_start = daw_backend::ContentTime::ZERO; + inst.trim_end = Some(daw_backend::ContentTime(loop_len_beats.beats_to_f64())); + inst.takes = clip_ids.iter().enumerate().map(|(i, &mid)| { + lightningbeam_core::clip::AudioTake { + name: format!("Take {}", i + 1), + content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid }, + } + }).collect(); + inst.active_take = Some(last_take); + inst.recorded_loop_beats = Some(loop_len_beats); + } + } + } + + // Commit the whole cycle-record session as ONE undoable action. + // The backend instance was mapped during MidiRecordingProgress and + // the engine already repointed it at the active take. + let instance = self.action_executor.document() + .get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Audio(al) = l { + al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).cloned() + } else { None }); + if let Some(instance) = instance { + match (self.layer_to_track_map.get(&layer_id).copied(), + self.clip_instance_to_backend_map.get(&instance.id).copied()) { + (Some(tid), Some(backend_id)) => { + let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied( + layer_id, instance, tid, backend_id, + ); + self.action_executor.push_applied(Box::new(action)); + } + _ => self.media_modified = true, + } + } + } + } + + // Clear recording state, same as the single-clip MIDI path. + if let Some(&layer_id) = self.track_to_layer_map.get(&track_id) { + self.recording_layer_ids.retain(|id| *id != layer_id); + self.recording_clips.remove(&layer_id); + } + if self.recording_layer_ids.is_empty() { + self.is_recording = false; + self.recording_clips.clear(); + } + ctx.request_repaint(); + } AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => { println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes", track_id, clip_id, note_count); @@ -6723,7 +7151,7 @@ impl eframe::App for EditorApp { .map(|(id, _)| id); if let Some(doc_clip_id) = doc_clip_id { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { - clip.set_content_duration(ClipDuration::Beats(Beats(midi_clip_data.duration))); + clip.set_content_duration(ClipDuration::Beats(midi_clip_data.duration)); 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) { 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 { println!("⚠️ Audio buffer size will be applied on next app restart"); } + // Cycle MIDI mode takes effect immediately — no restart needed, unlike the buffer size. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes); + } // Apply new keybindings if changed if let Some(new_keymap) = result.new_keymap { self.keymap = new_keymap; diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs index d15a71e..afafe0c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs @@ -43,6 +43,7 @@ pub const CHEVRONS_UP: &str = "\u{e074}"; pub const PLAY: &str = "\u{e13c}"; pub const PAUSE: &str = "\u{e12e}"; pub const REPEAT: &str = "\u{e146}"; // cycle / loop region toggle +pub const TRASH: &str = "\u{e18d}"; // delete a take pub const SETTINGS: &str = "\u{e154}"; pub const SEARCH: &str = "\u{e151}"; pub const PLUS: &str = "\u{e13d}"; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index d3d0079..3506beb 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -7,7 +7,7 @@ //! - Image Assets (static images) use eframe::egui; -use lightningbeam_core::clip::{AudioClipType, VectorClip}; +use lightningbeam_core::clip::{ResolvedContent, VectorClip}; use lightningbeam_core::document::Document; use lightningbeam_core::layer::AnyLayer; use std::collections::{HashMap, HashSet}; @@ -918,11 +918,11 @@ impl AssetLibraryPane { continue; } - let (extra_info, drag_clip_type) = match &clip.clip_type { - AudioClipType::Sampled { .. } => ("Sampled".to_string(), DragClipType::AudioSampled), - AudioClipType::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi), - AudioClipType::Recording => { - // Skip recording-in-progress clips from asset library + let (extra_info, drag_clip_type) = match &clip.resolve() { + ResolvedContent::Audio { .. } => ("Sampled".to_string(), DragClipType::AudioSampled), + ResolvedContent::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi), + ResolvedContent::Recording => { + // Skip recording-in-progress clips (and empty take folders) from asset library continue; } }; @@ -1118,15 +1118,15 @@ impl AssetLibraryPane { for (id, clip) in &document.audio_clips { if !linked_audio_ids.contains(id) && clip.folder_id == current_folder { - let (extra_info, drag_clip_type) = match &clip.clip_type { - AudioClipType::Sampled { .. } => { + let (extra_info, drag_clip_type) = match &clip.resolve() { + ResolvedContent::Audio { .. } => { ("Sampled".to_string(), DragClipType::AudioSampled) } - AudioClipType::Midi { .. } => { + ResolvedContent::Midi { .. } => { ("MIDI".to_string(), DragClipType::AudioMidi) } - AudioClipType::Recording => { - // Skip recording-in-progress clips + ResolvedContent::Recording => { + // Skip recording-in-progress clips (and empty take folders) continue; } }; @@ -1765,7 +1765,7 @@ impl AssetLibraryPane { let prefetched_waveform: Option> = if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&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) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)) } else { @@ -1790,8 +1790,8 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { .. } => { + match &clip.resolve() { + ResolvedContent::Audio { .. } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); if let Some(ref peaks) = prefetched_waveform { Some(generate_waveform_thumbnail(peaks, bg_color, wave_color)) @@ -1799,7 +1799,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let note_color = egui::Color32::from_rgb(100, 200, 100); if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color)) @@ -1807,7 +1807,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording in progress - show placeholder Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } @@ -2344,8 +2344,8 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { audio_pool_index } => { + match &clip.resolve() { + ResolvedContent::Audio { audio_pool_index } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); @@ -2355,7 +2355,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let note_color = egui::Color32::from_rgb(100, 200, 100); if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color)) @@ -2363,7 +2363,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -2481,8 +2481,8 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { audio_pool_index } => { + match &clip.resolve() { + ResolvedContent::Audio { audio_pool_index } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); @@ -2492,7 +2492,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let note_color = egui::Color32::from_rgb(100, 200, 100); if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color)) @@ -2500,7 +2500,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -2802,7 +2802,7 @@ impl AssetLibraryPane { let prefetched_waveform: Option> = if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&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> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); if waveform.is_some() { @@ -2842,8 +2842,8 @@ impl AssetLibraryPane { // Check if it's sampled or MIDI if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { .. } => { + match &clip.resolve() { + ResolvedContent::Audio { .. } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); if let Some(ref peaks) = prefetched_waveform { println!("✅ Generating waveform thumbnail with {} peaks for asset {}", peaks.len(), asset_id); @@ -2853,7 +2853,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let note_color = egui::Color32::from_rgb(100, 200, 100); @@ -2863,7 +2863,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -3187,7 +3187,7 @@ impl PaneRenderer for AssetLibraryPane { println!("🎨 [ASSET_LIB] Checking for thumbnails to invalidate (pools: {:?})", shared.audio_pools_with_new_waveforms); let mut invalidated_any = false; for (asset_id, clip) in &document_arc.audio_clips { - if let 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) { println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index); self.thumbnail_cache.invalidate(asset_id); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 7fae7bf..3722674 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -1590,8 +1590,12 @@ impl InfopanelPane { 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) - .unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); + let clip_dur = document.clip_trim_duration(&ci.clip_id) + .unwrap_or_else(|| lightningbeam_core::clip::ClipDuration::Seconds( + daw_backend::Seconds( + (ci.trim_end.unwrap_or(daw_backend::ContentTime(1.0)) - ci.trim_start).raw(), + ), + )); let total_dur = ci.total_duration(clip_dur, document.tempo_map()); let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur) - document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64(); @@ -1600,10 +1604,16 @@ impl InfopanelPane { 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.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)); }); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 2505ef3..7a81053 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -465,9 +465,13 @@ impl PianoRollPane { if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) { for instance in &audio_layer.clip_instances { if let Some(clip) = document.audio_clips.get(&instance.clip_id) { - if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { - let duration = instance.effective_duration(clip.content_duration().to_seconds(document.tempo_map()), document.tempo_map()); - clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id)); + // Resolve through the instance's active take, so a MIDI take folder edits + // whichever take it's actually playing. + if let Some(midi_clip_id) = instance.resolved_midi_clip_id(clip) { + let duration = instance.effective_duration(clip.content_duration(), document.tempo_map()); + // A MIDI clip's content time IS beats, which is what the piano roll's + // x-axis uses. + clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), instance.id)); } } } @@ -2463,7 +2467,8 @@ impl PianoRollPane { }); // Get sample rate from raw_audio_cache 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)); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 0f8598c..8d611ac 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -6,7 +6,7 @@ use eframe::egui; use daw_backend::Seconds; 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::layer::{AnyLayer, AudioLayer}; 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 let tempo_map = self.ctx.document.tempo_map(); 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( effect_def, 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 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) { 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) { @@ -2299,7 +2302,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { for &clip_id in self.ctx.selection.clip_instances() { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { // Skip clip instances not active at current time (compare in seconds). - let clip_dur = 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 start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let instance_end = tempo_map.beats_to_seconds( @@ -2310,7 +2315,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } // Calculate clip-local time - let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + 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 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 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 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(); @@ -10153,7 +10160,7 @@ impl StagePane { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { // Calculate clip-local time let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + 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 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) { // Calculate clip-local time let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + 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 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(); if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) { video_layer.clip_instances.iter().find(|inst| { - let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(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 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(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 42bd056..94bc05d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -7,8 +7,8 @@ /// - Basic layer visualization use eframe::egui; -use daw_backend::{Beats, Seconds}; -use lightningbeam_core::clip::ClipInstance; +use daw_backend::{Beats, ContentTime, Seconds}; +use lightningbeam_core::clip::{ClipDuration, ClipInstance}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait}; use crate::mobile::icons; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; @@ -116,7 +116,7 @@ fn compute_clip_stacking( let tempo_map = document.tempo_map(); // Stacking only needs relative overlap, so compare in the beats domain. 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 end = start + ci.total_duration(clip_dur, tempo_map); (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. /// 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. +/// 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( document: &lightningbeam_core::document::Document, layer: &AnyLayer, clip_instance: &ClipInstance, -) -> Option { +) -> Option { match layer { AnyLayer::Vector(vl) => { 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 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); - Some(Seconds((end - start_secs).max(0.0))) + Some(ClipDuration::Seconds(Seconds((end - start_secs).max(0.0)))) } else { // 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, - // not seconds) are converted correctly rather than read as raw seconds. - AnyLayer::Audio(_) => document.get_clip_duration(&clip_instance.clip_id), - AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)), - AnyLayer::Effect(_) => Some(Seconds(lightningbeam_core::effect::EFFECT_DURATION)), + // An audio layer can hold a sampled clip (seconds content) or a MIDI clip (beats content), + // so ask the clip which it is rather than flattening both to seconds. + AnyLayer::Audio(_) => document.clip_trim_duration(&clip_instance.clip_id), + AnyLayer::Video(_) => document + .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::Raster(_) => 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 /// playhead exactly to a keyframe when its diamond is clicked. 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 duration: f64, @@ -750,6 +769,11 @@ impl TimelinePane { viewport_start_time: 0.0, viewport_scroll_y: 0.0, 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 is_scrubbing: false, cycle_drag: None, @@ -874,7 +898,9 @@ impl TimelinePane { .unwrap_or_default() .iter() .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, interpolation: match k.interpolation.as_str() { "bezier" => crate::curve_editor::CurveInterpolation::Bezier, @@ -1076,7 +1102,41 @@ impl TimelinePane { 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. // 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. 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 = { + 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 for &(layer_id, ref cat, _) in &candidates { match cat { @@ -1143,7 +1225,7 @@ impl TimelinePane { } if let Some(controller_arc) = shared.audio_controller { 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); } shared.recording_layer_ids.push(layer_id); @@ -1156,7 +1238,7 @@ impl TimelinePane { if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); 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); println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}", track_id, start_time, clip_id); @@ -1422,8 +1504,10 @@ impl TimelinePane { let tempo_map = document.tempo_map(); for (_child_layer_id, ci) in &child_clips { - let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { - Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) + let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| { + ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + )) }); let start = ci.effective_start(); let end = start + ci.total_duration(clip_dur, tempo_map); @@ -1621,6 +1705,138 @@ impl TimelinePane { 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>, + ) { + 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 = 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 fn time_to_x(&self, time: f64) -> 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. /// /// `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 { - (tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) + 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_content, tempo_map)) - tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64() } - /// Returns the clip content start (trim_start) and duration in display seconds. - fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, _bpm: f64) -> (f64, f64) { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs.seconds_to_f64()); - (ci.trim_start, (trim_end - ci.trim_start).max(0.0)) + /// The clip's content start (trim_start) and window length, as raw magnitudes in the clip's own + /// content domain — seconds for audio/video/vector, beats for MIDI. The drag/preview math below + /// works in that domain throughout, converting to the timeline only at the edges. + 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) @@ -3011,6 +3230,7 @@ impl TimelinePane { let mut pending_lane_renders: Vec = Vec::new(); // Rebuilt each frame; consumed by handle_input (next frame) for click-to-seek. self.keyframe_diamond_hits.clear(); + self.take_badge_hits.clear(); // 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(); @@ -3228,8 +3448,10 @@ impl TimelinePane { let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move); let mut ranges: Vec<(Beats, Beats)> = Vec::new(); for (_child_layer_id, ci) in &child_clips { - let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { - Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) + let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| { + ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + )) }); let mut start = ci.effective_start(); 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 AnyLayer::Video(vl) = video_child { for ci in &vl.clip_instances { - let clip_dur = document.get_clip_duration(&ci.clip_id) - .unwrap_or_else(|| Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); + let clip_dur = document.clip_trim_duration(&ci.clip_id) + .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(); 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); @@ -3330,7 +3554,8 @@ impl TimelinePane { ); // 4th elem = clip's TRUE (unclamped) origin x, for correct // 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; if thumb_display_height > 8.0 { @@ -3343,7 +3568,7 @@ impl TimelinePane { &video_mgr, &mut self.video_thumbnail_textures, ci.clip_id, - ci.trim_start, + ci.trim_start.raw(), rect.min.x + sx, ex - sx, ci_rect, @@ -3392,7 +3617,7 @@ impl TimelinePane { }; 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(); 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); @@ -3443,7 +3668,8 @@ impl TimelinePane { audio_duration: audio_file_duration as f32, sample_rate: eff_sr, 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, total_frames: total_frames as f32, segment_start_frame: 0.0, @@ -3526,7 +3752,7 @@ impl TimelinePane { let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter() .filter(|ci| selection.contains_clip_instance(&ci.id)) .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()))) }) .collect(); @@ -3550,8 +3776,11 @@ impl TimelinePane { let shift_beats = |anchor: Beats, secs: f64| 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_secs = clip_dur.seconds_to_f64(); + let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO)); + // 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 duration = ci.total_duration(clip_dur, tmap); @@ -3573,25 +3802,25 @@ impl TimelinePane { } } 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 trim_offset_secs = new_trim - ci.trim_start; + 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; start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO); 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 { (clip_dur_secs - new_trim).max(0.0) }; duration = secs_to_beats_at(start, dur_secs); } ClipDragType::TrimRight => { - let old_trim_end = ci.trim_end.unwrap_or(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 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 dur_secs = (new_trim_end - ci_trim_start).max(0.0); duration = secs_to_beats_at(start, dur_secs); } ClipDragType::LoopExtendRight => { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let content_window_secs = (trim_end - ci.trim_start).max(0.0); + 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_to_beats_at(ci.timeline_start, content_window_secs); 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; @@ -3602,8 +3831,8 @@ impl TimelinePane { duration = loop_before + new_right; } ClipDragType::LoopExtendLeft => { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let content_window_secs = (trim_end - ci.trim_start).max(0.001); + 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_to_beats_at(ci.timeline_start, content_window_secs); let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO); // 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. // 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()); + // 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_clip_duration = base_clip_duration; @@ -3679,11 +3911,11 @@ impl TimelinePane { } ClipDragType::TrimLeft => { // 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) - .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. let max_extend_secs = document.find_max_trim_extend_left( &layer.id(), @@ -3691,25 +3923,25 @@ impl TimelinePane { clip_instance.effective_start(), ).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); - clip_instance.trim_start - actual_extend + ci_trim_start - actual_extend } else { // Shrinking - no snap needed 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. instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset) .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 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 @@ -3718,14 +3950,14 @@ impl TimelinePane { } ClipDragType::TrimRight => { // 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) - .max(clip_instance.trim_start) - .min(clip_duration.seconds_to_f64()); + .max(ci_trim_start) + .min(clip_duration.native()); let new_trim_end = if desired_trim_end > old_trim_end { // 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 current_duration = tmap.seconds_to_beats( tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs) @@ -3745,7 +3977,7 @@ impl TimelinePane { 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 // (the waveform system uses clip_duration to determine visible range) @@ -3753,8 +3985,8 @@ impl TimelinePane { } ClipDragType::LoopExtendRight => { // Loop extend right: extend clip beyond content window - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); + let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.0); let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; // content window and right-duration are beats-domain timeline spans. @@ -3789,8 +4021,8 @@ impl TimelinePane { ClipDragType::LoopExtendLeft => { // Loop extend left: extend loop_before (pre-loop region) // 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 content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); + let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.001); let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; // 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 if let lightningbeam_core::layer::AnyLayer::Audio(_) = layer { 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) - 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) { // Calculate content window for loop detection // preview_clip_duration accounts for TrimLeft/TrimRight drag previews @@ -3977,7 +4211,7 @@ impl TimelinePane { } } // 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) { // Min/max overview pools: 4 f32/texel at rate sr/B. let minmax_b = waveform_minmax_pools.get(audio_pool_index).copied(); @@ -4028,7 +4262,7 @@ impl TimelinePane { // Calculate content window for loop detection // 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 is_looping = instance_duration > content_window + 0.001; @@ -4106,7 +4340,7 @@ impl TimelinePane { } } // Recording in progress: show live waveform - lightningbeam_core::clip::AudioClipType::Recording => { + lightningbeam_core::clip::ResolvedContent::Recording => { let rec_pool_idx = usize::MAX; if let Some((samples, sr, ch)) = raw_audio_cache.get(&rec_pool_idx) { 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), ); + // 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 { let instance_id = clip_instance.id.as_u128() as u64; let callback = crate::waveform_gpu::WaveformCallback { @@ -4153,7 +4417,7 @@ impl TimelinePane { audio_duration: audio_file_duration as f32, sample_rate: *sr as f32, 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, total_frames: total_frames as f32, segment_start_frame: 0.0, @@ -4195,7 +4459,7 @@ impl TimelinePane { &video_mgr, &mut self.video_thumbnail_textures, clip_instance.clip_id, - clip_instance.trim_start, + clip_instance.trim_start.raw(), rect.min.x + start_x, end_x - start_x, clip_rect, @@ -4210,7 +4474,7 @@ impl TimelinePane { // clip's TRUE (unclamped) origin x so the hover content time is // correct even when the clip is scrolled partly off the left. 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) @@ -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 response.drag_started() { // 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( mousedown_pos, document, @@ -4883,18 +5212,23 @@ impl TimelinePane { for clip_instance in clip_instances { if selection.contains_clip_instance(&clip_instance.id) { 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 { match drag_type { 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 = clip_instance.timeline_start; // New trim_start is snapped then clamped to valid range 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, - ).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). let new_trim_start = if desired_trim_start < old_trim_start { @@ -4924,11 +5258,11 @@ impl TimelinePane { clip_instance.id, lightningbeam_core::actions::TrimType::TrimLeft, lightningbeam_core::actions::TrimData::left( - old_trim_start, + ContentTime(old_trim_start), old_timeline_start, ), lightningbeam_core::actions::TrimData::left( - new_trim_start, + ContentTime(new_trim_start), new_timeline_start, ), )); @@ -4939,10 +5273,10 @@ impl TimelinePane { // Calculate new trim_end based on current duration let current_duration = 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( 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). let new_trim_end_val = if desired_trim_end > old_trim_end_val { @@ -4959,13 +5293,15 @@ impl TimelinePane { 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 - 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 } 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 @@ -5017,8 +5353,9 @@ impl TimelinePane { if let Some(clip_duration) = clip_duration { let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); + let ci_trim_start = clip_instance.trim_start.raw(); + 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 current_right = clip_instance.timeline_duration.unwrap_or(content_window); // Snap the right edge in the seconds/pixel domain. @@ -5091,8 +5428,9 @@ impl TimelinePane { if let Some(clip_duration) = clip_duration { let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); + let ci_trim_start = clip_instance.trim_start.raw(); + 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 cw = content_window.beats_to_f64().max(1e-9); 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(), ); + self.render_take_menu(ui, document, shared.pending_actions); + // 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. // 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); for inst in instances { 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 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(); @@ -6374,7 +6714,7 @@ impl PaneRenderer for TimelinePane { enabled = instances.iter() .filter(|ci| shared.selection.contains_clip_instance(&ci.id)) .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()); // Room to duplicate = seconds gap to the right ≥ this clip's own length. let max_extend_secs = document.find_max_trim_extend_right( @@ -6419,7 +6759,7 @@ impl PaneRenderer for TimelinePane { enabled = instances.iter().all(|ci| { 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()); document .find_nearest_valid_position( @@ -6448,6 +6788,22 @@ impl PaneRenderer for TimelinePane { 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 mut item_clicked = false; 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); 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; + } + } }); }); diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs index a0bfc9f..bbb4167 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs @@ -55,6 +55,7 @@ struct PreferencesState { file_height: u32, scroll_speed: f64, audio_buffer_size: u32, + cycle_midi_separate_takes: bool, reopen_last_session: bool, restore_layout_from_file: bool, debug: bool, @@ -74,6 +75,7 @@ impl From<(&AppConfig, &Theme)> for PreferencesState { file_height: config.file_height, scroll_speed: config.scroll_speed, audio_buffer_size: config.audio_buffer_size, + cycle_midi_separate_takes: config.cycle_midi_separate_takes, reopen_last_session: config.reopen_last_session, restore_layout_from_file: config.restore_layout_from_file, debug: config.debug, @@ -95,6 +97,7 @@ impl Default for PreferencesState { file_height: 600, scroll_speed: 1.0, audio_buffer_size: 256, + cycle_midi_separate_takes: false, reopen_last_session: false, restore_layout_from_file: true, debug: false, @@ -551,6 +554,39 @@ impl PreferencesDialog { }); ui.label("Requires app restart to take effect"); + + ui.separator(); + + ui.horizontal(|ui| { + ui.label("Cycle MIDI recording:"); + + egui::ComboBox::from_id_salt("cycle_midi_mode") + .selected_text(if self.working_prefs.cycle_midi_separate_takes { + "Separate takes" + } else { + "Merge" + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.working_prefs.cycle_midi_separate_takes, + false, + "Merge", + ) + .on_hover_text( + "Every pass overdubs into one clip, and earlier passes play back as \ + you record so you can layer against them.", + ); + ui.selectable_value( + &mut self.working_prefs.cycle_midi_separate_takes, + true, + "Separate takes", + ) + .on_hover_text( + "Each pass becomes its own take in a take folder, as audio always \ + does. Earlier passes stay silent — they're alternatives, not layers.", + ); + }); + }); }); } @@ -684,6 +720,7 @@ impl PreferencesDialog { temp_config.file_height = self.working_prefs.file_height; temp_config.scroll_speed = self.working_prefs.scroll_speed; temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size; + temp_config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes; temp_config.reopen_last_session = self.working_prefs.reopen_last_session; temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; temp_config.debug = self.working_prefs.debug; @@ -720,6 +757,7 @@ impl PreferencesDialog { config.file_height = self.working_prefs.file_height; config.scroll_speed = self.working_prefs.scroll_speed; config.audio_buffer_size = self.working_prefs.audio_buffer_size; + config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes; config.reopen_last_session = self.working_prefs.reopen_last_session; config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; config.debug = self.working_prefs.debug;