diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index b782cd2..74288be 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -543,6 +543,17 @@ impl Engine { rec.wrap_at_cycle(le_beats, ls_beats); } + // 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, @@ -944,36 +955,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(); @@ -3212,8 +3243,28 @@ 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, + } + }) + } 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 +3289,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 +3346,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()); @@ -3357,7 +3505,13 @@ impl Engine { let track_id = recording.track_id; let notes = recording.get_notes().to_vec(); let note_count = notes.len(); - let recording_duration = end_time - recording.start_time; + // A cycle MIDI recording 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. + let recording_duration = match recording.cycle_loop_len { + 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); @@ -3522,17 +3676,17 @@ 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) diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index f1b3cbb..0a292d0 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -5,6 +5,50 @@ 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 means the user stopped + /// before completing a pass, which stays an ordinary single recording. + pub wrap_count: usize, +} + +/// 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 +79,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 +115,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.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 +295,13 @@ 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, when recording into a cycle. + /// + /// 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. Set only if the transport actually wrapped. + pub cycle_loop_len: Option, } impl MidiRecordingState { @@ -199,6 +312,7 @@ impl MidiRecordingState { start_time, active_notes: HashMap::new(), completed_notes: Vec::new(), + cycle_loop_len: None, } } @@ -283,5 +397,108 @@ impl MidiRecordingState { for (note, velocity) in held { self.note_on(note, velocity, region_start); } + + // The transport wrapped, so this is a cycle recording: the clip spans the whole region + // rather than however long the user happened to hold the record button. + self.cycle_loop_len = Some(region_end - region_start); + } +} + +#[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(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, + }); + 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 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]); } } 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..29a6934 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 { @@ -31,10 +46,9 @@ 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), @@ -299,6 +313,22 @@ pub enum AudioEvent { RecordingProgress(ClipId, Seconds), /// Recording stopped (clip_id, pool_index, waveform) RecordingStopped(ClipId, usize, Vec), + /// 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) diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 226ddfb..4909e85 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -47,6 +47,123 @@ 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 = clip.resolve(instance.active_take); + let content_duration = clip.content_duration().native(); + let internal_start = instance.trim_start; + let internal_end = instance.trim_end.unwrap_or(content_duration); + let start_time = instance.timeline_start; + + 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 trims are in the 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, // assigned by the backend + midi_clip_id, + daw_backend::Beats(internal_start), + daw_backend::Beats(internal_end), + start_time, + external_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 } => { + // `trim_*` and the clip's content duration are SECONDS (audio content time); the + // backend's start/duration are BEATS. + // + // When `timeline_duration` is set it's already beats; 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 at anything but 60 BPM). + 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 id = controller.add_audio_clip( + track_id, + audio_pool_index, + start_time, + effective_duration, + daw_backend::Seconds(internal_start), + ); + 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..fe88754 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -196,113 +196,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/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index 4e05db1..e8e3099 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, @@ -145,9 +145,9 @@ impl LoopClipInstancesAction { 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 &clip.resolve(instance.active_take) { + 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 +155,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/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 83d426f..153b065 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -15,6 +15,7 @@ pub mod reorder_clip_instances; pub mod paint_bucket; pub mod remove_effect; pub mod set_cycle_region; +pub mod set_active_take; pub mod set_document_properties; pub mod set_instance_properties; pub mod set_layer_properties; @@ -52,6 +53,7 @@ pub mod resize_text_box; pub use add_clip_instance::AddClipInstanceAction; pub use set_cycle_region::SetCycleRegionAction; +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..ceb3777 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -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() { @@ -246,12 +246,12 @@ impl Action for MoveClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Handle move based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &clip.resolve(instance.active_take) { + 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 +263,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 +275,7 @@ impl Action for MoveClipInstancesAction { fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -330,12 +330,12 @@ impl Action for MoveClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Handle move based on clip type (restore old position) - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &clip.resolve(instance.active_take) { + 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 +347,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..4415ef6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -138,7 +138,7 @@ impl Action for RemoveClipInstancesAction { backend: &mut BackendContext, document: &Document, ) -> Result<(), String> { - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; let controller = match backend.audio_controller.as_mut() { Some(c) => c, @@ -165,8 +165,8 @@ impl Action for RemoveClipInstancesAction { None => continue, }; - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &clip.resolve(instance.active_take) { + ResolvedContent::Midi { midi_clip_id } => { use daw_backend::command::{Query, QueryResponse}; let internal_start = instance.trim_start; @@ -196,7 +196,7 @@ impl Action for RemoveClipInstancesAction { ); } } - AudioClipType::Sampled { audio_pool_index } => { + ResolvedContent::Audio { 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; @@ -221,7 +221,7 @@ impl Action for RemoveClipInstancesAction { BackendClipInstanceId::Audio(new_id), ); } - AudioClipType::Recording => {} + ResolvedContent::Recording => {} } } 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..b071d47 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -146,27 +146,37 @@ 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. + // The clip's content duration in the SAME domain as its trims — 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; `clip_duration` above is always seconds and would + // silently add a seconds delta 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))?; + 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 = instance.trim_end.unwrap_or(trim_duration.native()) - instance.trim_start; // Timeline split point (beats). let time_into_clip = self.split_time - instance.timeline_start; let left_duration = time_into_clip; let right_duration = effective_duration - left_duration; - // How far the split lands into the clip's *content* (seconds, trim domain). + // How far the split lands into the clip's *content*, expressed in the trim domain. 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 = 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 (trim domain). + let content_split_time = if is_looping && content_duration > 0.0 { // 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 @@ -370,9 +380,9 @@ impl Action for SplitClipInstanceAction { .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 crate::clip::ResolvedContent; + match &clip.resolve(original_instance.active_take) { + ResolvedContent::Midi { midi_clip_id } => { use daw_backend::command::{Query, QueryResponse}; // 1. Trim the original (left) instance @@ -383,7 +393,7 @@ impl Action for SplitClipInstanceAction { 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); + controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); } // 2. Add the new (right) instance @@ -422,7 +432,7 @@ impl Action for SplitClipInstanceAction { _ => Err("Unexpected query response".to_string()), } } - AudioClipType::Sampled { audio_pool_index } => { + ResolvedContent::Audio { 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()); @@ -431,7 +441,7 @@ impl Action for SplitClipInstanceAction { 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); + controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); } // 2. Add the new (right) instance @@ -465,7 +475,7 @@ impl Action for SplitClipInstanceAction { Ok(()) } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be split Err("Cannot split a clip that is currently recording".to_string()) } @@ -502,23 +512,23 @@ impl Action for SplitClipInstanceAction { let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native()); // Restore based on clip type - use crate::clip::AudioClipType; - match &clip.clip_type { - AudioClipType::Midi { .. } => { + use crate::clip::ResolvedContent; + match &clip.resolve(instance.active_take) { + 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 } } @@ -575,4 +585,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 = 0.0; + instance.trim_end = Some(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, 4.0, "right half must start 4 BEATS into the content"); + assert_eq!(left.trim_end, Some(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..3e3d1be 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -366,7 +366,7 @@ impl Action for TrimClipInstancesAction { fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -427,24 +427,24 @@ impl Action for TrimClipInstancesAction { let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); // Handle trim based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &clip.resolve(instance.active_take) { + 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 +456,7 @@ impl Action for TrimClipInstancesAction { fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -522,24 +522,24 @@ impl Action for TrimClipInstancesAction { }; // Handle trim based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &clip.resolve(instance.active_take) { + 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 } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index dd294c4..45459e3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -466,6 +466,44 @@ pub enum AudioClipType { /// Placeholder for a clip that is currently being recorded. /// The audio_pool_index will be assigned when recording stops. Recording, + /// A folder of alternate takes, produced by cycle recording. + /// + /// Each pass of the transport around the cycle region becomes one take. Every take spans the + /// **full** cycle region (partial passes are padded with silence at capture time), so all takes + /// are the same length and share this clip's `duration` — which in turn means switching takes + /// never changes the clip's geometry, and splitting a take-folder instance yields two halves + /// whose takes still line up. Which take actually sounds is per-*instance* + /// ([`ClipInstance::active_take`]), not per-clip, so a split can play take 1 on the left and + /// take 3 on the right. That's comping. + TakeFolder { + /// The takes, in the order they were recorded. Never empty in practice. + takes: Vec, + /// The cycle region's length in beats at the time of recording. + /// + /// Audio takes are segmented geometrically (by sample count), so they're only meaningful + /// against the tempo they were cut at. Keeping the recorded length lets a future + /// time-stretch/conform feature reconcile the takes if the tempo changes underneath them. + recorded_loop_beats: Beats, + }, +} + +/// One take in a [`AudioClipType::TakeFolder`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AudioTake { + /// Display name, e.g. "Take 1". + pub name: String, + /// The recorded content this take points at. + pub content: TakeContent, +} + +/// What a take actually holds. A folder's takes are all the same kind — one cycle-record session +/// captures either audio or MIDI, never a mix. +#[derive(Clone, Debug, 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. @@ -532,11 +570,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 +582,29 @@ 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. + /// + /// A take folder inherits the domain of its takes, which are all the same kind — one + /// cycle-record session captures either audio or MIDI, never a mix. An empty folder can't + /// happen in practice; call it seconds so the fallback is the common case. + fn is_midi_domain(&self) -> bool { + match &self.clip_type { + AudioClipType::Midi { .. } => true, + AudioClipType::Sampled { .. } | AudioClipType::Recording => false, + AudioClipType::TakeFolder { takes, .. } => { + matches!(takes.first().map(|t| &t.content), Some(TakeContent::Midi { .. })) + } + } + } + /// Create a new sampled audio clip /// /// # Arguments @@ -637,6 +688,125 @@ impl AudioClip { _ => None, } } + + /// The clip's takes, if it's a take folder. + pub fn takes(&self) -> Option<&[AudioTake]> { + match &self.clip_type { + AudioClipType::TakeFolder { takes, .. } => Some(takes), + _ => None, + } + } + + /// The take an instance's `active_take` actually selects. + /// + /// `None` means take 0, which is also what an out-of-range index falls back to — an index can + /// go stale (an old `.beam`, an undo that shrank the folder), and silently playing the first + /// take beats refusing to play anything. + fn take_for(&self, active_take: Option) -> Option<&AudioTake> { + let takes = self.takes()?; + takes + .get(active_take.unwrap_or(0)) + .or_else(|| takes.first()) + } + + /// What this clip plays *for a given instance*, with take folders collapsed to the instance's + /// active take. + /// + /// This is the sanctioned way to ask "what content do I hand the backend for this instance?". + /// Matching on `clip_type` directly will see a `TakeFolder` and have to handle it separately; + /// matching on this won't, because a folder is never a distinct case here — it's just an audio + /// or MIDI clip whose identity depends on which take is active. + pub fn resolve(&self, active_take: Option) -> 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, + AudioClipType::TakeFolder { .. } => match self.take_for(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, + }, + // An empty folder has nothing to play. Treat it like a recording placeholder: + // the backend gets nothing, rather than a bogus pool index. + None => ResolvedContent::Recording, + }, + } + } + + /// Tag a pair of raw trim bounds with this clip's content domain, ready for the backend. + /// + /// `ClipInstance::trim_start`/`trim_end` are bare `f64`s whose unit depends on the clip — + /// SECONDS for sampled audio, BEATS for MIDI. Building the [`TrimRange`] from the clip means a + /// caller can't reach for the wrong variant: the clip is the one thing that knows. + pub fn trim_range(&self, start: f64, end: f64) -> daw_backend::command::TrimRange { + if self.is_midi_domain() { + daw_backend::command::TrimRange::Beats { + start: Beats(start), + end: Beats(end), + } + } else { + daw_backend::command::TrimRange::Seconds { + start: Seconds(start), + end: Seconds(end), + } + } + } + + /// Whether this clip owns the given audio pool index — either as a plain sampled clip, or as + /// *any* take of a take folder. Reverse lookups (backend resource → document clip) must use + /// this: a folder owns one pool file per take, not just the active one. + pub fn owns_audio_pool_index(&self, pool_index: usize) -> bool { + match &self.clip_type { + AudioClipType::Sampled { audio_pool_index } => *audio_pool_index == pool_index, + AudioClipType::TakeFolder { takes, .. } => takes.iter().any(|t| { + matches!(t.content, TakeContent::Audio { audio_pool_index } if audio_pool_index == pool_index) + }), + _ => false, + } + } + + /// Whether this clip owns the given backend MIDI clip ID. See [`Self::owns_audio_pool_index`]. + pub fn owns_midi_clip_id(&self, id: u32) -> bool { + match &self.clip_type { + AudioClipType::Midi { midi_clip_id } => *midi_clip_id == id, + AudioClipType::TakeFolder { takes, .. } => takes.iter().any(|t| { + matches!(t.content, TakeContent::Midi { midi_clip_id } if midi_clip_id == id) + }), + _ => false, + } + } + + /// The audio pool index this *instance* should play. See [`Self::resolve`]. + pub fn resolved_audio_pool_index(&self, active_take: Option) -> Option { + match self.resolve(active_take) { + ResolvedContent::Audio { audio_pool_index } => Some(audio_pool_index), + _ => None, + } + } + + /// The backend MIDI clip ID this *instance* should play. See [`Self::resolve`]. + pub fn resolved_midi_clip_id(&self, active_take: Option) -> Option { + match self.resolve(active_take) { + ResolvedContent::Midi { midi_clip_id } => Some(midi_clip_id), + _ => None, + } + } +} + +/// What a clip instance actually plays, once take folders are resolved to their active take. +/// Produced by [`AudioClip::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 @@ -741,6 +911,15 @@ pub struct ClipInstance { /// Default: None (no pre-loop) #[serde(default, skip_serializing_if = "Option::is_none")] pub loop_before: Option, + + /// Which take of a [`AudioClipType::TakeFolder`] clip this instance plays. + /// + /// Per-instance rather than per-clip so two instances of the same folder — e.g. the two halves + /// of a split — can play different takes. That's how comping works. `None` means take 0; + /// meaningless (and ignored) on non-folder clips. + /// Default: None + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_take: Option, } /// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID. @@ -799,6 +978,7 @@ impl ClipInstance { playback_speed: 1.0, gain: 1.0, loop_before: None, + active_take: None, } } @@ -817,6 +997,7 @@ impl ClipInstance { playback_speed: 1.0, gain: 1.0, loop_before: None, + active_take: None, } } @@ -1070,4 +1251,99 @@ mod tests { assert_eq!(instance.playback_speed, 2.0); assert_eq!(instance.gain, 0.8); } + + /// Build a take folder of `n` audio takes with the given pool indices. + fn take_folder(pool_indices: &[usize]) -> AudioClip { + let mut clip = AudioClip::new_sampled("Cycle rec", 0, 2.0); + clip.clip_type = AudioClipType::TakeFolder { + takes: pool_indices + .iter() + .enumerate() + .map(|(i, &audio_pool_index)| AudioTake { + name: format!("Take {}", i + 1), + content: TakeContent::Audio { audio_pool_index }, + }) + .collect(), + recorded_loop_beats: Beats(8.0), + }; + clip + } + + #[test] + fn active_take_selects_the_pool_file() { + let clip = take_folder(&[10, 11, 12]); + assert_eq!(clip.resolved_audio_pool_index(Some(0)), Some(10)); + assert_eq!(clip.resolved_audio_pool_index(Some(2)), Some(12)); + // None means take 0. + assert_eq!(clip.resolved_audio_pool_index(None), Some(10)); + } + + #[test] + fn out_of_range_take_falls_back_to_the_first() { + // An index can go stale (an old .beam, an undo that shrank the folder). Playing the first + // take beats playing nothing. + let clip = take_folder(&[10, 11]); + assert_eq!(clip.resolved_audio_pool_index(Some(99)), Some(10)); + } + + #[test] + fn take_folder_owns_every_takes_pool_file() { + // Reverse lookups (backend resource -> document clip) must find the folder via ANY take, + // not just the active one. + let clip = take_folder(&[10, 11, 12]); + assert!(clip.owns_audio_pool_index(10)); + assert!(clip.owns_audio_pool_index(12)); + assert!(!clip.owns_audio_pool_index(13)); + } + + #[test] + fn midi_take_folder_measures_duration_in_beats() { + // A folder inherits its takes' domain: MIDI takes mean the duration is beats, not seconds. + let mut clip = AudioClip::new_sampled("Cycle rec", 0, 4.0); + clip.clip_type = AudioClipType::TakeFolder { + takes: vec![AudioTake { + name: "Take 1".into(), + content: TakeContent::Midi { midi_clip_id: 7 }, + }], + recorded_loop_beats: Beats(4.0), + }; + assert_eq!(clip.content_duration(), ClipDuration::Beats(Beats(4.0))); + assert_eq!(clip.resolved_midi_clip_id(Some(0)), Some(7)); + assert_eq!(clip.resolved_audio_pool_index(Some(0)), None); + } + + #[test] + fn takes_are_per_instance_so_a_split_can_comp() { + // The whole point of putting active_take on the instance: two instances of the same folder + // (which is what a split produces) can play different takes. + let clip = take_folder(&[10, 11, 12]); + let mut left = ClipInstance::new(clip.id); + let mut right = left.clone(); + right.id = Uuid::new_v4(); + left.active_take = Some(0); + right.active_take = Some(2); + + assert_eq!(clip.resolved_audio_pool_index(left.active_take), Some(10)); + assert_eq!(clip.resolved_audio_pool_index(right.active_take), Some(12)); + } + + #[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..0029a75 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -780,16 +780,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 +913,23 @@ 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**. + /// + /// `ClipInstance::trim_*` is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for + /// sampled audio, video and vector, but BEATS for MIDI (the backend takes MIDI trims as + /// `Beats`). 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) + } + pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option { if let Some(clip) = self.vector_clips.get(clip_id) { if clip.is_group { diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index a2f1e88..6a35973 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -6490,6 +6490,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) { @@ -6501,6 +6520,116 @@ 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; + + // Promote the in-progress recording clip to a take folder. + { + let doc = self.action_executor.document_mut(); + if let Some(clip) = doc.audio_clips.get_mut(&clip_id) { + clip.clip_type = lightningbeam_core::clip::AudioClipType::TakeFolder { + 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(), + recorded_loop_beats: loop_len_beats, + }; + // Audio takes are seconds-domain, and every take is + // exactly one cycle region long. + clip.set_content_duration(ClipDuration::Seconds(loop_len_seconds)); + 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; + inst.trim_start = 0.0; + inst.trim_end = Some(loop_len_seconds.seconds_to_f64()); + inst.active_take = Some(last_take); + } + } + } + + // 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); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index d3d0079..5cfc40c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -7,7 +7,17 @@ //! - Image Assets (static images) use eframe::egui; -use lightningbeam_core::clip::{AudioClipType, VectorClip}; +use lightningbeam_core::clip::{AudioClip, ResolvedContent, VectorClip}; + +/// Library label for an audio clip: a take folder advertises how many takes it holds, anything else +/// just names its kind. The library lists *clips*, not placements, so there's no active take here — +/// a folder is previewed by its first take. +fn take_label(clip: &AudioClip, kind: &str) -> String { + match clip.takes() { + Some(takes) => format!("{} ({} takes)", kind, takes.len()), + None => kind.to_string(), + } +} use lightningbeam_core::document::Document; use lightningbeam_core::layer::AnyLayer; use std::collections::{HashMap, HashSet}; @@ -918,11 +928,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(None) { + ResolvedContent::Audio { .. } => (take_label(clip, "Sampled"), DragClipType::AudioSampled), + ResolvedContent::Midi { .. } => (take_label(clip, "MIDI"), DragClipType::AudioMidi), + ResolvedContent::Recording => { + // Skip recording-in-progress clips (and empty take folders) from asset library continue; } }; @@ -1118,15 +1128,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 { .. } => { - ("Sampled".to_string(), DragClipType::AudioSampled) + let (extra_info, drag_clip_type) = match &clip.resolve(None) { + ResolvedContent::Audio { .. } => { + (take_label(clip, "Sampled"), DragClipType::AudioSampled) } - AudioClipType::Midi { .. } => { - ("MIDI".to_string(), DragClipType::AudioMidi) + ResolvedContent::Midi { .. } => { + (take_label(clip, "MIDI"), DragClipType::AudioMidi) } - AudioClipType::Recording => { - // Skip recording-in-progress clips + ResolvedContent::Recording => { + // Skip recording-in-progress clips (and empty take folders) continue; } }; @@ -1765,7 +1775,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.resolved_audio_pool_index(None).as_ref() { shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)) } else { @@ -1790,8 +1800,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(None) { + 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 +1809,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 +1817,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 +2354,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(None) { + 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 +2365,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 +2373,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -2481,8 +2491,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(None) { + 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 +2502,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 +2510,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -2802,7 +2812,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.resolved_audio_pool_index(None).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 +2852,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(None) { + 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 +2863,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 +2873,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -3187,7 +3197,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.resolved_audio_pool_index(None).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 1522f5b..74f0d78 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -1797,10 +1797,16 @@ impl InfopanelPane { ui.label("Name:"); ui.label(&clip.name); }); + let take_count = clip.takes().map(|t| t.len()).unwrap_or(0); + let take_folder_label; let type_name = match &clip.clip_type { lightningbeam_core::clip::AudioClipType::Sampled { .. } => "Audio (Sampled)", lightningbeam_core::clip::AudioClipType::Midi { .. } => "Audio (MIDI)", lightningbeam_core::clip::AudioClipType::Recording => "Audio (Recording)", + lightningbeam_core::clip::AudioClipType::TakeFolder { .. } => { + take_folder_label = format!("Audio ({} takes)", take_count); + &take_folder_label + } }; ui.horizontal(|ui| { ui.label("Type:"); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 4ec6c21..03eb16d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -286,6 +286,15 @@ 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)>, + /// 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, @@ -723,6 +732,9 @@ 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, + cycle_record_lead_secs: 0.0, duration: 10.0, // Default 10 seconds is_scrubbing: false, cycle_drag: None, @@ -1049,7 +1061,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. @@ -1594,6 +1640,101 @@ 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; + }; + + // The instance's *stored* selection, which is what rollback must restore — not `active`, + // which is that value clamped for display. + let old_take = 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, + }) + .and_then(|ci| ci.active_take); + + 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; + if ui + .selectable_label(is_active, format!("Take {}", i + 1)) + .clicked() + { + if !is_active { + pending_actions.push(Box::new( + lightningbeam_core::actions::SetActiveTakeAction::new( + layer_id, + instance_id, + i, + old_take, + ), + )); + } + close = true; + } + } + }); + }); + + // 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; + } + } + /// 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 @@ -2896,6 +3037,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(); @@ -3801,9 +3943,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.resolve(clip_instance.active_take) { // 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 @@ -3862,7 +4006,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(); @@ -3991,7 +4135,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; @@ -4026,6 +4170,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 { @@ -4038,7 +4212,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, @@ -4152,6 +4326,68 @@ 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. + if let Some(take_count) = document + .get_audio_clip(&clip_instance.clip_id) + .and_then(|c| c.takes().map(|t| t.len())) + .filter(|n| *n > 0) + { + let active = clip_instance.active_take.unwrap_or(0).min(take_count - 1); + 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, + )); + } + } } } } @@ -4631,7 +4867,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, @@ -6051,6 +6292,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.