Cycle recording: multi-take capture, take folders, comping

Recording into a cycle region now produces one take per pass. Pick a take
from a badge on the clip; split the clip and pick different takes on the
halves, and you've comped.

Data model (phase 2):
- AudioClipType::TakeFolder { takes, recorded_loop_beats } holds the take
  list on the CLIP; ClipInstance::active_take holds the selection on the
  INSTANCE. That split is what makes comping fall out of the existing
  split action for free — split clones the instance, so the two halves
  share one take list but choose independently. recorded_loop_beats lets
  a future time-stretch/conform pass reconcile audio takes if the tempo
  moves under them.
- AudioClip::resolve(active_take) -> ResolvedContent{Audio|Midi|Recording}
  collapses a take folder to what an instance actually plays. A folder is
  not a distinct *case* at call sites — it's an audio or MIDI clip whose
  identity depends on which take is live — so every backend-sync site now
  resolves through this instead of matching clip_type raw. Reverse lookups
  go through owns_audio_pool_index/owns_midi_clip_id, since a folder owns
  one pool file per take, not just the active one.
- BackendContext::add_clip_instance/remove_clip_instance: switching takes
  is a remove + re-add (there's no in-place pool-swap command), and that's
  the same work AddClipInstanceAction does. One implementation, on the
  context that already owns the controller and both ID maps, so the
  seconds-vs-beats conversions can't drift between copies.

Capture (phase 3):
- Takes are cut GEOMETRICALLY at stop, in exact loop-length multiples. The
  playhead advances before the capture block in process(), so the wrap
  instant isn't sample-exact against the buffer just captured — but the
  geometry is. wrap_count only decides *whether* the recording is
  multi-take, never where the cuts land.
- Partial passes are padded with silence: punch in mid-region and take 1
  gets silence prepended back to the region start; stop mid-pass and the
  last take gets silence appended. Every take is the same length, which is
  the invariant comping depends on. A final take under 50ms of real audio
  is dropped as a stop artifact (but a take that FILLED the region never
  is, however short the region).
- MIDI merges, and it falls out for free: anchoring the recording at
  loop_start rather than the punch-in point means the transport always
  wraps back INTO the region, so every note's offset already lands inside
  [0, loop_len) and passes overdub with no folding logic at all.
- The whole session commits as ONE undoable action via push_applied.

Fixes found on the way:
- Split was seconds/beats confused on MIDI. trim_start/trim_end are
  domain-polymorphic exactly like AudioClip::duration was — SECONDS for
  audio/video/vector, BEATS for MIDI — and split mapped the split point
  into clip content in seconds unconditionally. Now it works in the clip's
  own domain via Document::clip_trim_duration(). Regression test included.
- TrimClip took raw f64s whose meaning flipped by track type, and the
  engine set an AUDIO clip's external_duration = Beats(end - start) where
  those bounds were SECONDS — so a 1-second split played back as half a
  second at 120 BPM. Replaced with a domain-tagged TrimRange, built from
  the clip (clip.trim_range()) so the wrong unit isn't expressible, and
  the span is now converted at the clip's position on the timeline.
- The live preview grew past the loop end while the playhead wrapped. It
  now grows through the first pass then pins at the region length, and the
  waveform inside restarts at the region start on each pass. The pass
  offset is derived from the captured buffer, not the playhead — those
  advance on different clocks, and differencing them made the waveform
  jitter horizontally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-07-13 14:40:10 -04:00
parent f957b01dcf
commit 16e3d676d6
19 changed files with 1518 additions and 240 deletions

View File

@ -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<f32>,
sample_rate: u32,
channels: u32,
clip_id: ClipId,
take_index: usize,
) -> Result<usize, std::io::Error> {
use crate::io::WavWriter;
let path = std::env::temp_dir()
.join(format!("daw_take_{}_{}.wav", clip_id, take_index));
let mut writer = WavWriter::create(&path, sample_rate, channels)?;
writer.write_samples(&samples)?;
writer.finalize()?;
let pool_file = crate::audio::pool::AudioFile::with_format(
path.clone(),
samples,
channels,
sample_rate,
Some("wav".to_string()),
);
let pool_index = self.audio_pool.add_file(pool_file);
let _ = std::fs::remove_file(&path);
Ok(pool_index)
}
/// Handle stopping a recording
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<crate::io::WaveformPeak>)> = Vec::new();
for (i, take) in takes.into_iter().enumerate() {
let peaks = crate::audio::recording::compute_peaks(
&take,
channels,
frames_per_peak,
);
match self.write_take_to_pool(take, sample_rate, channels, clip_id, i) {
Ok(pool_index) => pool_takes.push((pool_index, peaks)),
Err(e) => {
let _ = self.event_tx.push(AudioEvent::RecordingError(
format!("Failed to store take {}: {}", i + 1, e),
));
return;
}
}
}
// Point the engine's clip at the take the editor will make active (the last
// one, GarageBand-style) and stretch it to cover the whole cycle region —
// the clip was created at the punch-in point with zero length.
let loop_len_secs =
Seconds(cycle.loop_len_frames as f64 / sample_rate as f64);
if let Some(&(last_pool_index, _)) = pool_takes.last() {
if let Some(crate::audio::track::TrackNode::Audio(track)) =
self.project.get_track_mut(track_id)
{
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id)
{
clip.audio_pool_index = last_pool_index;
clip.internal_start = Seconds(0.0);
clip.internal_end = loop_len_secs;
clip.external_start = cycle.loop_start;
clip.external_duration = cycle.loop_len_beats;
}
}
self.refresh_clip_snapshot();
}
let _ = self.event_tx.push(AudioEvent::CycleRecordingStopped {
clip_id,
takes: pool_takes,
loop_start: cycle.loop_start,
loop_len_beats: cycle.loop_len_beats,
loop_len_seconds: loop_len_secs,
});
return;
}
eprintln!("[STOP_RECORDING] Finalize succeeded: {} frames written to {:?}, {} waveform peaks generated, {} samples in memory",
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)

View File

@ -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<WaveformPeak> {
let samples_per_peak = (frames_per_peak * channels.max(1) as usize).max(1);
samples
.chunks(samples_per_peak)
.map(|chunk| {
let mut min = 0.0f32;
let mut max = 0.0f32;
for s in chunk {
min = min.min(*s);
max = max.max(*s);
}
WaveformPeak { min, max }
})
.collect()
}
/// State of an active recording session
pub struct RecordingState {
/// Track being recorded to
@ -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<f32>,
/// Cycle-recording bookkeeping, when a cycle region was armed at record start.
pub cycle: Option<CycleRecordInfo>,
}
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<Vec<Vec<f32>>> {
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<f32>> = Vec::with_capacity(take_count);
for i in 0..take_count {
let mut take = vec![0.0f32; take_len];
let take_begin = i * take_len;
for slot in 0..take_len {
// Position in the virtual (lead-padded) buffer.
let v = take_begin + slot;
if v < lead {
continue; // still in the punch-in silence
}
match self.audio_data.get(v - lead) {
Some(s) => take[slot] = *s,
None => break, // past the end of capture; the rest stays silent
}
}
takes.push(take);
}
// A final take holding only a sliver of real audio is a stop artifact (the user hit stop a
// moment after the wrap), not a performance. Drop it — but only if it's actually a PARTIAL
// pass, and never the only take. A pass that filled the region is a real take no matter how
// short the region is.
const MIN_TAKE_SECONDS: f64 = 0.05;
if takes.len() > 1 {
let last_real_samples = virtual_len - (takes.len() - 1) * take_len;
let last_real_seconds = (last_real_samples / ch) as f64 / self.sample_rate as f64;
if last_real_samples < take_len && last_real_seconds < MIN_TAKE_SECONDS {
takes.pop();
}
}
Some(takes)
}
/// Add samples to the accumulation buffer
/// Returns true if a flush occurred
pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> {
@ -189,6 +295,13 @@ pub struct MidiRecordingState {
active_notes: HashMap<u8, ActiveMidiNote>,
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats
pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
/// The cycle region's length in beats, 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<Beats>,
}
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<f32>, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState {
let mut r = RecordingState::new(
0,
0,
PathBuf::from("/dev/null"),
WavWriter::create(&PathBuf::from("/dev/null"), 100, 1).expect("wav writer"),
100,
1,
Beats(0.0),
1.0,
);
r.audio_data = audio;
r.cycle = Some(CycleRecordInfo {
loop_start: Beats(0.0),
loop_len_beats: Beats(4.0),
loop_len_frames,
lead_pad_frames,
wrap_count: wraps,
});
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<f32> = (1..=12).map(|i| i as f32).collect();
let takes = rec(audio, 4, 0, 2).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 3);
assert_eq!(takes[0], vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]);
assert_eq!(takes[2], vec![9.0, 10.0, 11.0, 12.0]);
}
#[test]
fn punch_in_pads_the_head_of_take_one() {
// Punched in 2 frames into the region: take 1 gets 2 frames of silence at the FRONT so it
// still spans the whole region and lines up with every other take.
let audio: Vec<f32> = (1..=10).map(|i| i as f32).collect();
let takes = rec(audio, 4, 2, 2).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 3);
assert_eq!(takes[0], vec![0.0, 0.0, 1.0, 2.0]);
assert_eq!(takes[1], vec![3.0, 4.0, 5.0, 6.0]);
assert_eq!(takes[2], vec![7.0, 8.0, 9.0, 10.0]);
}
#[test]
fn stopping_mid_pass_pads_the_tail_of_the_last_take() {
// 13 frames, 8-frame loop => the second take holds 5 real frames (50 ms at 100 Hz, right at
// the keep threshold) and 3 of silence.
let audio: Vec<f32> = (1..=13).map(|i| i as f32).collect();
let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 2);
assert_eq!(takes[1], vec![9.0, 10.0, 11.0, 12.0, 13.0, 0.0, 0.0, 0.0]);
}
#[test]
fn every_take_is_the_same_length() {
// Uniform length is the invariant comping-via-split depends on.
let audio: Vec<f32> = (1..=23).map(|i| i as f32).collect();
let takes = rec(audio, 8, 3, 3).slice_takes().expect("cycle takes");
assert!(takes.iter().all(|t| t.len() == 8), "takes must be uniform");
}
#[test]
fn a_sliver_of_a_final_take_is_dropped() {
// Stopped 1 frame (10 ms at 100 Hz) after the wrap — below the 50 ms floor, so that stub of
// a take is a stop artifact and goes.
let audio: Vec<f32> = (1..=9).map(|i| i as f32).collect();
let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 1, "a 10ms tail take should be dropped");
assert_eq!(takes[0].len(), 8);
}
#[test]
fn a_full_final_take_is_never_dropped() {
// Regression: the sliver rule must only fire on a PARTIAL pass. A pass that filled the
// region is a real take however short the region is — an earlier version compared a full
// take's duration to the floor and silently ate it.
let audio: Vec<f32> = (1..=8).map(|i| i as f32).collect();
let takes = rec(audio, 4, 0, 1).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 2, "both passes filled the region");
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]);
}
}

View File

@ -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};

View File

@ -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<WaveformPeak>),
/// A recording that wrapped the cycle region at least once, and so became multi-take.
///
/// Each take spans the full region and they're all the same length (partial passes are padded
/// with silence), so the editor can promote the recording clip straight to a take folder.
CycleRecordingStopped {
clip_id: ClipId,
/// One entry per pass: (audio pool index, waveform peaks), in recording order.
takes: Vec<(usize, Vec<WaveformPeak>)>,
/// Where the takes sit on the timeline — the cycle region's start, not the punch-in point.
loop_start: Beats,
/// The region's length in beats (what the take folder stores as `recorded_loop_beats`).
loop_len_beats: Beats,
/// The same length in seconds — the take folder's content duration, which is seconds-domain
/// for audio.
loop_len_seconds: Seconds,
},
/// Recording error (error_message)
RecordingError(String),
/// MIDI recording stopped (track_id, clip_id, note_count)

View File

@ -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<Option<(daw_backend::TrackId, BackendClipInstanceId)>, String> {
use crate::clip::ResolvedContent;
let clip = document
.get_audio_clip(&instance.clip_id)
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
let track_id = *self
.layer_to_track_map
.get(layer_id)
.ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?;
let resolved = 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).

View File

@ -196,114 +196,24 @@ 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)
);
// 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)
}
}
}
Ok(())
}
QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e),
_ => Err("Unexpected query response".to_string()),
}
}
AudioClipType::Sampled { audio_pool_index } => {
// `trim_*` / `clip.duration` are in SECONDS (audio content time),
// while `timeline_*` and the backend's `duration` are in BEATS.
let internal_start = self.clip_instance.trim_start;
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native());
let start_time = self.clip_instance.timeline_start;
// `effective_duration` is in BEATS. When `timeline_duration` is set
// it already is; otherwise the clip occupies its natural content
// length, so convert that seconds-span to beats at the clip's start
// (NOT `internal_end - internal_start`, which is seconds — that was
// the seconds-as-beats bug that made clips stop early off 60 BPM).
let effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| {
let tempo_map = document.tempo_map();
let content_secs = daw_backend::Seconds(internal_end - internal_start);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
- start_time
});
let instance_id = controller.add_audio_clip(
*backend_track_id,
*audio_pool_index,
start_time,
effective_duration,
daw_backend::Seconds(internal_start),
);
self.backend_track_id = Some(*backend_track_id);
self.backend_audio_instance_id = Some(instance_id);
// Add to global clip instance mapping
backend.clip_instance_to_backend_map.insert(
self.clip_instance.id,
crate::action::BackendClipInstanceId::Audio(instance_id)
);
Ok(())
}
AudioClipType::Recording => {
// Recording clips are not synced to backend until finalized
Ok(())
}
}
}
fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> {
// Remove clip from backend if it was added

View File

@ -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<u32, String> {
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()),
}
};

View File

@ -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;

View File

@ -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
}
}

View File

@ -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 => {}
}
}

View File

@ -0,0 +1,117 @@
//! Choose which take of a take-folder clip an instance plays.
//!
//! The take list lives on the *clip* but the selection lives on the *instance*, so two instances of
//! the same folder can play different takes. Splitting a take-folder instance clones it, which is
//! what makes comping work: set take 1 on the left half and take 3 on the right, and you've comped.
//!
//! There's no in-place pool-swap command in the backend, so switching a take means removing the
//! instance's backend clip and re-adding it against the new take's audio/MIDI resource. Both halves
//! of that go through `BackendContext`, which is also what `AddClipInstanceAction` uses.
use crate::action::{Action, BackendClipInstanceId, BackendContext};
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
/// Action that points a clip instance at a different take of its take folder.
#[derive(Clone)]
pub struct SetActiveTakeAction {
layer_id: Uuid,
instance_id: Uuid,
new_take: Option<usize>,
old_take: Option<usize>,
/// The backend track/clip the instance was on before we swapped, so rollback can undo it.
backend_track_id: Option<daw_backend::TrackId>,
}
impl SetActiveTakeAction {
pub fn new(layer_id: Uuid, instance_id: Uuid, new_take: usize, old_take: Option<usize>) -> Self {
Self {
layer_id,
instance_id,
new_take: Some(new_take),
old_take,
backend_track_id: None,
}
}
/// Point the instance at `take`, mutating the document. Shared by execute and rollback.
fn apply(&self, document: &mut Document, take: Option<usize>) -> Result<(), String> {
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let AnyLayer::Audio(audio_layer) = layer else {
return Err("Take folders only exist on audio layers".to_string());
};
let instance = audio_layer
.clip_instances
.iter_mut()
.find(|ci| ci.id == self.instance_id)
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
instance.active_take = take;
Ok(())
}
/// Swap the instance's backend clip to whatever take the document now says is active.
///
/// Called after the document has already been mutated, so re-adding just re-resolves the
/// instance — `BackendContext::add_clip_instance` reads `active_take` itself.
fn resync(&mut self, backend: &mut BackendContext, document: &Document) -> Result<(), String> {
let instance = document
.get_layer(&self.layer_id)
.and_then(|l| match l {
AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == self.instance_id),
_ => None,
})
.cloned()
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
// Drop the old backend clip first. Its track comes from the map we're about to overwrite,
// so read it before add_clip_instance replaces the entry.
let existing: Option<BackendClipInstanceId> = backend
.clip_instance_to_backend_map
.get(&self.instance_id)
.copied();
let track_id = backend.layer_to_track_map.get(&self.layer_id).copied();
if let (Some(backend_id), Some(track_id)) = (existing, track_id) {
backend.remove_clip_instance(track_id, backend_id, self.instance_id);
}
let added = backend.add_clip_instance(document, &self.layer_id, &instance)?;
self.backend_track_id = added.map(|(track_id, _)| track_id);
Ok(())
}
}
impl Action for SetActiveTakeAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
self.apply(document, self.new_take)
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
self.apply(document, self.old_take)
}
fn description(&self) -> String {
match self.new_take {
Some(i) => format!("Select take {}", i + 1),
None => "Select take".to_string(),
}
}
fn execute_backend(
&mut self,
backend: &mut BackendContext,
document: &Document,
) -> Result<(), String> {
self.resync(backend, document)
}
fn rollback_backend(
&mut self,
backend: &mut BackendContext,
document: &Document,
) -> Result<(), String> {
self.resync(backend, document)
}
}

View File

@ -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");
}
}

View File

@ -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
}
}

View File

@ -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<AudioTake>,
/// 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,28 +570,41 @@ 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 => {
if self.is_midi_domain() {
ClipDuration::Beats(Beats(self.duration))
} else {
ClipDuration::Seconds(Seconds(self.duration))
}
}
}
/// Set the content duration. Debug-asserts the value's domain matches the clip type so a
/// beats duration can't be stored on a seconds clip (or vice-versa).
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<usize>) -> 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<usize>) -> 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<usize>) -> Option<usize> {
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<usize>) -> Option<u32> {
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<Beats>,
/// 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<usize>,
}
/// 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);
}
}

View File

@ -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<crate::clip::ClipDuration> {
if let Some(clip) = self.audio_clips.get(clip_id) {
return Some(clip.content_duration());
}
// Everything else measures its content in wall-clock seconds.
self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds)
}
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
if let Some(clip) = self.vector_clips.get(clip_id) {
if clip.is_group {

View File

@ -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);

View File

@ -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<Vec<(f32, f32)>> =
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
if let Some(clip) = document.audio_clips.get(&asset_id) {
if let 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<Vec<(f32, f32)>> = 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<Vec<(f32, f32)>> = 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<Vec<(f32, f32)>> =
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
if let Some(clip) = document.audio_clips.get(&asset_id) {
if let 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<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
if waveform.is_some() {
@ -2842,8 +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);

View File

@ -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:");

View File

@ -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<Box<dyn lightningbeam_core::action::Action>>,
) {
let click = ui.input(|i| {
i.pointer
.primary_pressed()
.then(|| i.pointer.interact_pos())
.flatten()
});
if let Some(pos) = click {
if let Some((_, layer_id, instance_id, _, _)) =
self.take_badge_hits.iter().find(|(r, ..)| r.contains(pos))
{
let key = (*layer_id, *instance_id);
// Clicking the badge of the open menu closes it again.
self.open_take_menu = (self.open_take_menu != Some(key)).then_some(key);
}
}
let Some((layer_id, instance_id)) = self.open_take_menu else {
return;
};
// The badge is only in the hit list while it's on screen; if the clip scrolled away, the
// menu has nothing to hang off, so drop it.
let Some((badge, _, _, active, count)) = self
.take_badge_hits
.iter()
.find(|(_, l, i, _, _)| *l == layer_id && *i == instance_id)
.copied()
else {
self.open_take_menu = None;
return;
};
// 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<AutomationLaneRender> = 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.