Compare commits

...

6 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 6924fc0ffe Take management: move takes onto the clip instance
Take management should be per clip instance — deleting a take from one
half of a comped split shouldn't pull it out from under the other half.
That's cleanest if the takes themselves live on the instance rather than
the clip, so they do now.

AudioClipType::TakeFolder is gone entirely. A clip is plain Sampled/Midi
content again, and an instance with a non-empty `takes` list simply
OVERRIDES it with whichever take is active. Splitting already clones the
instance, so each half gets its own take list for free — no index
remapping across instances, no copy-on-write, no shared-state surprise —
and comping still works, because the halves can still each select a
different take. It collapsed machinery too: resolve() moved from the clip
to the instance, and owns_audio_pool_index went back to a one-liner.

Management (DeleteTakeAction, DeleteUnusedTakesAction, RenameTakeAction):
- Right-click a clip with more than one take: `Delete "<active take>"`
  and "Delete Unused Takes". Deletion is named after the take that's
  PLAYING rather than being a generic entry, so you pick the victim by
  selecting it — one clear act instead of hunting a small trash icon in a
  list (which is where this started, and it was fiddly).
- Double-click a take in the dropdown to rename it in place.
- What happens to the selection on delete is the subtle part, and there's
  a test per case: deleting a take BELOW the active one shifts the
  selection down so you keep hearing the same take; deleting the ACTIVE
  take lands on whatever slid into its place (not silently back to take
  1); deleting the LAST take steps back one. The only take can't be
  deleted at all — the menu item isn't offered.
- Deleted takes' audio stays in the pool: undo has to put it back, and
  the other half of a split may still be playing it.

Fixes:
- A recording that stopped before the loop came round wasn't joining an
  existing take list — it landed as a separate overlapping clip. Trigger-
  on-wrap is right for the FIRST recording, but once takes exist there,
  a further run is plainly another take however short. The engine can't
  know that (it's document state), so the editor passes `force_takes`
  with the start-recording command and the run is cut and padded to the
  region even with zero wraps. This forced cycle_loop_len and `wrapped`
  apart on the MIDI side: the region length has to be known from the
  start, but the clip should only pin to full-region length AFTER a pass
  completes, or the bar jumps to full width the moment you hit record.
- Recording a second take left BOTH sounding. append_cycle_takes tore
  down the recording's backend clip by looking it up in
  clip_instance_to_backend_map — but on the audio path the recording
  instance isn't in that map yet; it's only added during promotion, which
  the append path skips. The event already carries the engine's clip id,
  so it's handed over explicitly now.
- The take badge is hidden when there's only one take — no choice to make.
2026-07-14 12:58:33 -04:00
Skyler Lehmkuhl c62164c365 Cycle recording: MIDI separate-takes mode, and append to existing folders
Completes the cycle-recording spec. Three related pieces:

MIDI separate takes (Preferences > Audio > "Cycle MIDI recording"):
- Each pass becomes its own MIDI clip, folded into a take folder — the same
  shape audio always gets — instead of merging into one clip. Merge stays
  the default.
- Notes are bucketed by the pass they were played in. The pass counter bumps
  BETWEEN close_active_notes and the re-note_on at a wrap, so a key held
  across the boundary has its sounding half filed under the pass that's
  ending and its re-opened half under the pass that's beginning. Put the bump
  on either side of that pair and the whole note lands in one pass; there's a
  test named for exactly that.
- A silent INTERIOR pass still yields an empty take, so take N is always pass
  N — otherwise the numbering silently shifts and "take 3" stops meaning "the
  third time round". A TRAILING empty pass is dropped: that's what hitting
  stop shortly after a wrap gives you, a stop artifact rather than a take you
  played. (Audio already behaved this way via its short-final-take rule.)
- Still triggers on the wrap: stop inside the first pass and it's an ordinary
  single recording, whatever the preference says.

Append to an existing take folder (AppendTakesAction):
- Cycle-recording over a region that already holds a take folder now ADDS to
  that folder rather than dropping a second clip on top of it, which stranded
  the new takes in an overlapping clip you couldn't audition against the old.
- "Same region" means same start AND same loop length: resize the cycle region
  and you get a fresh folder, rather than takes of a different length appended
  to an existing one, which would break the uniform-take invariant that
  comping-via-split depends on.
- The recording's own clip/instance are throwaway scaffolding here (the takes
  already live in the backend pools), so they're discarded; the single
  AppendTakesAction is the whole undoable step.

Don't play the region you're recording over:
- While recording into a MIDI track, every clip on that track is silenced
  EXCEPT the one being recorded into. A take folder already sitting in the
  cycle region was otherwise playing its active take underneath you on every
  pass, fighting the part you were trying to record.
- The recording clip itself is exempt, because in merge mode that's precisely
  what you want to hear: the overdub you've been building up. Other tracks are
  untouched.
2026-07-14 12:10:08 -04:00
Skyler Lehmkuhl 6629adc7d2 Cycle recording: monitor the MIDI overdub on later passes
In merge mode every pass layers into the same clip, so a later pass has to
PLAY BACK what earlier passes laid down — otherwise you overdub against
silence, which defeats the point of merging (you can't put a hi-hat on a
kick you can't hear).

Two things stood in the way, and they turned out to be the same bug:

- The captured notes only reached the backend's MIDI pool clip at STOP, so
  during the session the sequencer had nothing to schedule. The wrap now
  folds the notes captured so far into the pool clip. Their offsets drop
  straight in: a cycle MIDI recording is anchored at loop_start, so they're
  already region-relative.

- The recording-progress block resizes the clip instance every audio buffer
  from `playhead - start_time`. The playhead jumps BACKWARDS at a wrap, so
  that duration collapsed to zero and grew again on every pass. It reset the
  clip bar to zero each pass (visible), and it shrank the clip instance back
  to nothing at each wrap (invisible) — so even once the notes were in the
  pool, the sequencer saw a zero-length instance and scheduled none of them.

Fixed at the root: once the transport has wrapped, the recording spans the
whole cycle region and STAYS there — it doesn't track the playhead at all.
`cycle_loop_len` (set at the first wrap) pins it, which both holds the clip
bar at full region length after pass one and keeps the instance stretched
across the region so the merged notes get scheduled.

Writing the events reuses the clip's existing Vec, so it's allocation-free
after the first wrap; mutating the pool from the audio thread is what
Command::UpdateMidiClipNotes already does.
2026-07-14 10:39:12 -04:00
Skyler Lehmkuhl a5cdbfd0fd Type the time domains: no raw f64 in any time-carrying API
Three bugs in a row came from the same root: a time value crossing an API
boundary as a bare f64, with the caller and the callee disagreeing about
whether it meant seconds or beats. Recording landed at the wrong time,
MIDI clips grew too fast, and a 1-second split played back as half a
second. Each was "obviously" one domain at the call site and read as the
other on the far side. This makes the mismatch a compile error.

Backend API — every time-carrying f64 is gone:
- Commands: Seek/SetOffset/SetTrimStart/SetTrimEnd -> Seconds; MoveClip/
  ExtendClip/CreateMidiClip/AddMidiNote/AddLoadedMidiClip/
  UpdateMidiClipNotes/AddMidiClipSync and all four automation commands ->
  Beats; TrimClip -> TrimRange.
- Events/queries: PlaybackPosition, WaveformChunksReady's time range,
  AudioFileReady::duration, PoolFileInfo, get_playhead_seconds -> Seconds.
- Serialized: MidiClipData::duration and AutomationKeyframeData::time ->
  Beats. Both newtypes are #[serde(transparent)], so the .beam on-disk
  format is unchanged.
- Several controller methods ALREADY took Beats and unwrapped it to shove
  into the command — the newtype was being discarded at the very boundary
  it existed to protect.

TrimRange, for the domain-polymorphic case: a clip's content time is
SECONDS for sampled audio but BEATS for MIDI, so a single newtype can't
express it (there was even a comment in engine.rs saying so, and that
rationalization is what let the bug through). A domain-tagged enum can.
The engine rejects a range whose domain doesn't match the track, and the
range is built from the clip (clip.trim_range()) so callers can't pick
the wrong variant.

ContentTime, for the trim fields: ClipInstance::trim_start/trim_end are
content times, and were the last untyped f64 — the actual root of the
split bug. ContentTime is deliberately a DEAD END: no .to_seconds(), no
.to_beats(), no arithmetic with Seconds or Beats. Content times combine
freely with each other (same clip, same domain — safe), so the ~100
passthrough sites cost nothing; the only exit is resolving against the
clip that knows the domain (AudioClip::resolve_content_time /
Document::resolve_content_time / ClipDuration::same_domain). Mixing
domains no longer compiles.

Two more live bugs the types surfaced:
- ClipInstance::effective_duration_beats took a SECONDS clip duration and
  subtracted trim_start from it. For a TRIMMED MIDI clip that subtracted
  a beats offset from a seconds duration, so the clip's timeline length
  was wrong at any tempo but 60 BPM. Untrimmed clips happened to work,
  which is why it hid. It now takes a ClipDuration and resolves in the
  clip's own domain: beats content carries over directly (tempo-
  invariant), wall-clock content converts at the clip's position.
  Regression test asserts a clip trimmed to beats 2..6 is 4 beats long at
  60/90/120 BPM.
- Trim validation clamped a content-domain trim against a wall-clock gap.
  gap_to_content/content_to_secs now convert at the clip's position.

Also folds two more copies of the backend add-logic into
BackendContext::add_clip_instance (split and remove_clip_instances both
re-add clips), so the trim/duration conversions live in exactly one place
instead of four.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:31:04 -04:00
Skyler Lehmkuhl 16e3d676d6 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>
2026-07-13 14:40:10 -04:00
Skyler Lehmkuhl f957b01dcf Cycle recording phase 1: transport loop region
Adds a cycle (loop) region: a range on the timeline ruler that the
transport wraps at during playback. This is the substrate for
GarageBand-style multi-take cycle recording (phases 2 and 3).

The region is authored in BEATS so it stays put musically across tempo
changes. It lives on the Document (saved in the .beam, serde-defaulted
so old files load) and is edited through SetCycleRegionAction, so it is
undoable and marks the document modified like any other edit.

Backend:
- Engine gains loop_region/loop_enabled plus a wrap at the single
  playhead-advance point in process(). The wrap is phase-preserving
  (modulo, so an overshoot larger than the loop can't strand the
  playhead outside the region) and gated on playhead >= 0 so a count-in
  pre-roll never wraps.
- Sounding voices are deliberately NOT reset at the wrap the way
  Command::Seek does, since that would chop sustain and reverb tails at
  every pass.
- MidiRecordingState::wrap_at_cycle writes note-offs for held notes at
  the region end and re-opens them at the region start, so a key held
  across the boundary can't end up with a negative duration or hang.
- loop_bounds_frozen freezes the region's sample bounds for the duration
  of an *audio* recording. Phrased positively on audio so future
  multi-track recording inherits it: MIDI is beat-segmented and
  tempo-invariant, but audio is segmented geometrically and we have no
  time-stretch, so cross-tempo audio takes wouldn't be compable anyway.
- Command::Play jumps to loop_start when starting from outside the
  region; starting inside it plays from where you are.

Editor:
- Cycle lane along the bottom of the ruler (bottom, so it doesn't cover
  the bar numbers), painted inside render_ruler under the ticks. Only
  exists while looping is armed; with cycle off the ruler is entirely
  the playhead scrubber, as before.
- Drag to create/move/resize with a three-zone hit test, previewed
  locally and committed as ONE action on release. Driven off raw pointer
  state rather than an egui Response: the lane sits inside the timeline's
  content response, and a second widget on the same pixels just contests
  hover every frame.
- snap_to_grid/quantize_grid_size take a min_grid_px "visual coarseness"
  parameter instead of a hardcoded constant, with two named profiles:
  SNAP_PX_FINE for the playhead and clip edges, SNAP_PX_CYCLE (coarser)
  for the cycle region, so loops land on bars rather than odd
  subdivisions.
- Cycle toggle button using the Lucide repeat glyph.

Cargo.lock picks up the 1.0.9-alpha version bump it missed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:13:06 -04:00
38 changed files with 4328 additions and 795 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -5,6 +5,61 @@ use crate::time::{Beats, Seconds};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
/// Cycle-recording bookkeeping attached to a recording that started with a cycle region armed.
///
/// Takes are sliced **geometrically** at stop, in exact `loop_len_frames` multiples — not at the
/// instant the wrap was detected. The playhead advances before the capture block in `process()`, so
/// the wrap instant isn't sample-exact against the buffer that was just captured, but the geometry
/// is. `wrap_count` therefore only decides *whether* this is a multi-take recording, never where the
/// cuts land.
#[derive(Debug, Clone, Copy)]
pub struct CycleRecordInfo {
/// Where the cycle region starts, in beats. Takes are laid down here, not at the punch-in point.
pub loop_start: Beats,
/// The cycle region's length in beats — what the take folder records as `recorded_loop_beats`.
pub loop_len_beats: Beats,
/// One cycle pass, in frames. The take size.
pub loop_len_frames: usize,
/// Frames between the region start and where capture actually began. Non-zero only for a
/// punch-in (record while already rolling); take 1 gets this much silence prepended so it still
/// spans the whole region.
pub lead_pad_frames: usize,
/// How many times the transport wrapped during this recording. Zero normally means the user
/// stopped before completing a pass, which stays an ordinary single recording — unless
/// `force_takes` says otherwise.
pub wrap_count: usize,
/// Cut takes even if the transport never wrapped.
///
/// Set when the region already holds takes: a further recording there is another take, however
/// short, and it gets padded out to the region like any partial pass. Without this a run that
/// stopped before the loop came round would land as a separate overlapping clip instead of
/// joining the take list.
///
/// The editor decides this at record start, because whether takes already exist is document
/// state the engine can't see.
pub force_takes: bool,
}
/// Min/max waveform peaks for a finished buffer of interleaved samples.
///
/// The live recording path builds its peaks incrementally as samples arrive; cycle takes don't
/// exist until the recording is sliced at stop, so they get theirs in one pass here.
pub fn compute_peaks(samples: &[f32], channels: u32, frames_per_peak: usize) -> Vec<WaveformPeak> {
let samples_per_peak = (frames_per_peak * channels.max(1) as usize).max(1);
samples
.chunks(samples_per_peak)
.map(|chunk| {
let mut min = 0.0f32;
let mut max = 0.0f32;
for s in chunk {
min = min.min(*s);
max = max.max(*s);
}
WaveformPeak { min, max }
})
.collect()
}
/// State of an active recording session /// State of an active recording session
pub struct RecordingState { pub struct RecordingState {
/// Track being recorded to /// Track being recorded to
@ -35,6 +90,8 @@ pub struct RecordingState {
pub frames_per_peak: usize, pub frames_per_peak: usize,
/// All recorded audio data accumulated in memory (written to disk at finalization) /// All recorded audio data accumulated in memory (written to disk at finalization)
pub audio_data: Vec<f32>, pub audio_data: Vec<f32>,
/// Cycle-recording bookkeeping, when a cycle region was armed at record start.
pub cycle: Option<CycleRecordInfo>,
} }
impl RecordingState { impl RecordingState {
@ -69,9 +126,69 @@ impl RecordingState {
waveform_buffer: Vec::new(), waveform_buffer: Vec::new(),
frames_per_peak, frames_per_peak,
audio_data: Vec::new(), audio_data: Vec::new(),
cycle: None,
} }
} }
/// Slice the recording into cycle takes: one per pass, each spanning the FULL cycle region.
///
/// Partial passes are padded with silence — the head of take 1 for a punch-in, the tail of the
/// last take when the user stops mid-pass — so every take is the same length and aligned to the
/// region. That uniformity is what makes comping-via-split work: take 1 on the left half and
/// take 3 on the right always line up.
///
/// Returns `None` if this wasn't a cycle recording or the transport never wrapped (an ordinary
/// single recording, which keeps the existing path untouched).
pub fn slice_takes(&self) -> Option<Vec<Vec<f32>>> {
let cycle = self.cycle?;
if (cycle.wrap_count == 0 && !cycle.force_takes) || cycle.loop_len_frames == 0 {
return None;
}
let ch = self.channels.max(1) as usize;
let take_len = cycle.loop_len_frames * ch;
let lead = cycle.lead_pad_frames * ch;
// The recording as positioned *within the region*: silence for the gap between the region
// start and the punch-in, then the captured audio. Slicing this at whole-take boundaries is
// the whole trick — take 1 comes out short-by-`lead` at the front, already padded.
let virtual_len = lead + self.audio_data.len();
let take_count = virtual_len.div_ceil(take_len);
let mut takes: Vec<Vec<f32>> = Vec::with_capacity(take_count);
for i in 0..take_count {
let mut take = vec![0.0f32; take_len];
let take_begin = i * take_len;
for slot in 0..take_len {
// Position in the virtual (lead-padded) buffer.
let v = take_begin + slot;
if v < lead {
continue; // still in the punch-in silence
}
match self.audio_data.get(v - lead) {
Some(s) => take[slot] = *s,
None => break, // past the end of capture; the rest stays silent
}
}
takes.push(take);
}
// A final take holding only a sliver of real audio is a stop artifact (the user hit stop a
// moment after the wrap), not a performance. Drop it — but only if it's actually a PARTIAL
// pass, and never the only take. A pass that filled the region is a real take no matter how
// short the region is.
const MIN_TAKE_SECONDS: f64 = 0.05;
if takes.len() > 1 {
let last_real_samples = virtual_len - (takes.len() - 1) * take_len;
let last_real_seconds = (last_real_samples / ch) as f64 / self.sample_rate as f64;
if last_real_samples < take_len && last_real_seconds < MIN_TAKE_SECONDS {
takes.pop();
}
}
Some(takes)
}
/// Add samples to the accumulation buffer /// Add samples to the accumulation buffer
/// Returns true if a flush occurred /// Returns true if a flush occurred
pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> { pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> {
@ -189,6 +306,26 @@ pub struct MidiRecordingState {
active_notes: HashMap<u8, ActiveMidiNote>, active_notes: HashMap<u8, ActiveMidiNote>,
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats /// Completed notes: (time_offset, note, velocity, duration) — all times in beats
pub completed_notes: Vec<(Beats, u8, u8, Beats)>, pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
/// The cycle region's length in beats, if one was armed at record start.
///
/// A cycle MIDI recording is anchored at the region start (`start_time == loop_start`), which is
/// what makes MERGE fall out for free: the transport always wraps back into the region, so every
/// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each
/// other with no folding needed.
pub cycle_loop_len: Option<Beats>,
/// Whether the transport actually came round. Distinct from `cycle_loop_len`, which only says a
/// region was armed: the clip only pins to the full region once a pass has completed, so until
/// then the bar still grows with the playhead.
pub wrapped: bool,
/// Cut takes even if the transport never wrapped — see [`CycleRecordInfo::force_takes`].
pub force_takes: bool,
/// Which cycle pass is currently being recorded (0-based). Bumped at each wrap.
current_pass: usize,
/// The pass each completed note belongs to, parallel to `completed_notes`.
///
/// Only meaningful in "separate takes" mode, where each pass becomes its own MIDI clip. Merge
/// mode ignores it — all passes fold into one clip, which is the whole point.
note_pass: Vec<usize>,
} }
impl MidiRecordingState { impl MidiRecordingState {
@ -199,9 +336,28 @@ impl MidiRecordingState {
start_time, start_time,
active_notes: HashMap::new(), active_notes: HashMap::new(),
completed_notes: Vec::new(), completed_notes: Vec::new(),
cycle_loop_len: None,
wrapped: false,
force_takes: false,
current_pass: 0,
note_pass: Vec::new(),
} }
} }
/// Record a finished note, tagging it with the pass it was played in.
///
/// Every completion goes through here so `completed_notes` and `note_pass` can't drift apart.
fn push_completed(&mut self, note: &ActiveMidiNote, end_time: Beats) {
let note_start = note.start_time.max(self.start_time);
self.completed_notes.push((
note_start - self.start_time,
note.note,
note.velocity,
end_time - note_start,
));
self.note_pass.push(self.current_pass);
}
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) { pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) {
self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time }); self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time });
} }
@ -211,16 +367,38 @@ impl MidiRecordingState {
if absolute_time <= self.start_time { if absolute_time <= self.start_time {
return; return;
} }
let note_start = active_note.start_time.max(self.start_time); self.push_completed(&active_note, absolute_time);
self.completed_notes.push((
note_start - self.start_time,
active_note.note,
active_note.velocity,
absolute_time - note_start,
));
} }
} }
/// Completed notes grouped by cycle pass — one bucket per pass, in recording order.
///
/// Used by "separate takes" mode, where each pass becomes its own MIDI clip.
///
/// An *interior* pass in which nothing was played still yields an empty take, so take N in the
/// folder is always pass N on the transport — otherwise the numbering would silently shift and
/// "take 3" would stop meaning "the third time round". A *trailing* empty pass is dropped
/// though: that's what you get by hitting stop shortly after a wrap, and it's a stop artifact
/// rather than a take you played. (Same reasoning as the audio path's short-final-take rule.)
pub fn notes_by_pass(&self, passes: usize) -> Vec<Vec<(Beats, u8, u8, Beats)>> {
let mut buckets = vec![Vec::new(); passes.max(1)];
for (note, &pass) in self.completed_notes.iter().zip(self.note_pass.iter()) {
if let Some(bucket) = buckets.get_mut(pass) {
bucket.push(*note);
}
}
// Never drop the only take.
while buckets.len() > 1 && buckets.last().is_some_and(|b| b.is_empty()) {
buckets.pop();
}
buckets
}
/// How many cycle passes this recording covered (1 if the transport never wrapped).
pub fn pass_count(&self) -> usize {
self.current_pass + 1
}
pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] { pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] {
&self.completed_notes &self.completed_notes
} }
@ -229,18 +407,28 @@ impl MidiRecordingState {
self.completed_notes.len() self.completed_notes.len()
} }
/// The still-held notes, given a provisional duration running to `current_time`.
///
/// These belong to whatever pass is in progress, so a per-pass view can append them as-is.
pub fn active_notes_with_provisional_end(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
self.active_notes
.values()
.map(|active| {
let note_start = active.start_time.max(self.start_time);
(
note_start - self.start_time,
active.note,
active.velocity,
(current_time - note_start).max(Beats::ZERO),
)
})
.collect()
}
/// Get all completed notes plus currently-held notes with a provisional duration. /// Get all completed notes plus currently-held notes with a provisional duration.
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
let mut notes = self.completed_notes.clone(); let mut notes = self.completed_notes.clone();
for active in self.active_notes.values() { notes.extend(self.active_notes_with_provisional_end(current_time));
let note_start = active.start_time.max(self.start_time);
notes.push((
note_start - self.start_time,
active.note,
active.velocity,
(current_time - note_start).max(Beats::ZERO),
));
}
notes notes
} }
@ -252,13 +440,259 @@ impl MidiRecordingState {
let active_notes: Vec<_> = self.active_notes.drain().collect(); let active_notes: Vec<_> = self.active_notes.drain().collect();
for (_note_num, active_note) in active_notes { for (_note_num, active_note) in active_notes {
let note_start = active_note.start_time.max(self.start_time); self.push_completed(&active_note, end_time);
self.completed_notes.push((
note_start - self.start_time,
active_note.note,
active_note.velocity,
end_time - note_start,
));
} }
} }
/// Handle a transport cycle wrap during MIDI recording.
///
/// Note times are stored as offsets from `start_time`, and the playhead jumps *backwards* at a
/// wrap — so a note still held across the boundary would otherwise get a nonsensical (negative)
/// duration, or never be closed at all. Write its note-off at `region_end` (exactly as
/// `close_active_notes` does when recording stops), then re-open it at `region_start` so a key
/// the player is still physically holding keeps being captured in the next pass. Mirrors the
/// way `handle_start_midi_recording` re-injects already-held notes at the recording start.
pub fn wrap_at_cycle(&mut self, region_end: Beats, region_start: Beats) {
// Snapshot the held notes (close_active_notes drains them and loses the velocities).
let held: Vec<(u8, u8)> = self
.active_notes
.values()
.map(|n| (n.note, n.velocity))
.collect();
// Close first, so a note held across the boundary has its tail attributed to the pass that's
// ending; then advance, so the re-opened half belongs to the pass that's beginning.
self.close_active_notes(region_end);
self.current_pass += 1;
for (note, velocity) in held {
self.note_on(note, velocity, region_start);
}
// A pass has completed, so from here the clip spans the whole region rather than however long
// the user happens to hold the record button.
self.cycle_loop_len = Some(region_end - region_start);
self.wrapped = true;
}
}
#[cfg(test)]
mod cycle_tests {
use super::*;
/// A recording state holding `audio_data`, armed for cycle recording. Mono, 100 Hz, so a frame
/// is a sample and 5 frames is 50 ms (exactly the min-take threshold).
fn rec_forced(audio: Vec<f32>, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState {
let mut r = rec(audio, loop_len_frames, lead_pad_frames, wraps);
if let Some(c) = r.cycle.as_mut() {
c.force_takes = true;
}
r
}
fn rec(audio: Vec<f32>, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState {
let mut r = RecordingState::new(
0,
0,
PathBuf::from("/dev/null"),
WavWriter::create(&PathBuf::from("/dev/null"), 100, 1).expect("wav writer"),
100,
1,
Beats(0.0),
1.0,
);
r.audio_data = audio;
r.cycle = Some(CycleRecordInfo {
loop_start: Beats(0.0),
loop_len_beats: Beats(4.0),
loop_len_frames,
lead_pad_frames,
wrap_count: wraps,
force_takes: false,
});
r
}
#[test]
fn no_wrap_is_not_a_cycle_recording() {
// Stopping before the transport ever wraps stays an ordinary single recording — the whole
// point of triggering on the wrap rather than on the cycle region merely existing.
let r = rec(vec![1.0; 10], 4, 0, 0);
assert!(r.slice_takes().is_none());
}
#[test]
fn force_takes_makes_a_partial_pass_a_take() {
// Recording over a region that already holds takes: this run is another take however short
// it ran, so it's cut and padded like any partial pass rather than landing as a separate
// overlapping clip. Two real frames of an 8-frame region -> one take, silence for the rest.
let takes = rec_forced(vec![1.0, 2.0], 8, 0, 0)
.slice_takes()
.expect("forced takes");
assert_eq!(takes.len(), 1);
assert_eq!(takes[0], vec![1.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
}
#[test]
fn takes_are_cut_at_exact_loop_multiples() {
// 12 frames of audio, 4-frame loop, started at the region start => 3 clean takes.
let audio: Vec<f32> = (1..=12).map(|i| i as f32).collect();
let takes = rec(audio, 4, 0, 2).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 3);
assert_eq!(takes[0], vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]);
assert_eq!(takes[2], vec![9.0, 10.0, 11.0, 12.0]);
}
#[test]
fn punch_in_pads_the_head_of_take_one() {
// Punched in 2 frames into the region: take 1 gets 2 frames of silence at the FRONT so it
// still spans the whole region and lines up with every other take.
let audio: Vec<f32> = (1..=10).map(|i| i as f32).collect();
let takes = rec(audio, 4, 2, 2).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 3);
assert_eq!(takes[0], vec![0.0, 0.0, 1.0, 2.0]);
assert_eq!(takes[1], vec![3.0, 4.0, 5.0, 6.0]);
assert_eq!(takes[2], vec![7.0, 8.0, 9.0, 10.0]);
}
#[test]
fn stopping_mid_pass_pads_the_tail_of_the_last_take() {
// 13 frames, 8-frame loop => the second take holds 5 real frames (50 ms at 100 Hz, right at
// the keep threshold) and 3 of silence.
let audio: Vec<f32> = (1..=13).map(|i| i as f32).collect();
let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 2);
assert_eq!(takes[1], vec![9.0, 10.0, 11.0, 12.0, 13.0, 0.0, 0.0, 0.0]);
}
#[test]
fn every_take_is_the_same_length() {
// Uniform length is the invariant comping-via-split depends on.
let audio: Vec<f32> = (1..=23).map(|i| i as f32).collect();
let takes = rec(audio, 8, 3, 3).slice_takes().expect("cycle takes");
assert!(takes.iter().all(|t| t.len() == 8), "takes must be uniform");
}
#[test]
fn a_sliver_of_a_final_take_is_dropped() {
// Stopped 1 frame (10 ms at 100 Hz) after the wrap — below the 50 ms floor, so that stub of
// a take is a stop artifact and goes.
let audio: Vec<f32> = (1..=9).map(|i| i as f32).collect();
let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 1, "a 10ms tail take should be dropped");
assert_eq!(takes[0].len(), 8);
}
#[test]
fn a_full_final_take_is_never_dropped() {
// Regression: the sliver rule must only fire on a PARTIAL pass. A pass that filled the
// region is a real take however short the region is — an earlier version compared a full
// take's duration to the floor and silently ate it.
let audio: Vec<f32> = (1..=8).map(|i| i as f32).collect();
let takes = rec(audio, 4, 0, 1).slice_takes().expect("cycle takes");
assert_eq!(takes.len(), 2, "both passes filled the region");
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]);
}
}
#[cfg(test)]
mod midi_cycle_tests {
use super::*;
/// A MIDI recording anchored at the region start (beat 0), region 4 beats long.
fn rec() -> MidiRecordingState {
MidiRecordingState::new(0, 0, Beats(0.0))
}
/// One pass of the transport around a 4-beat region.
fn wrap(r: &mut MidiRecordingState) {
r.wrap_at_cycle(Beats(4.0), Beats(0.0));
}
#[test]
fn notes_are_bucketed_by_the_pass_they_were_played_in() {
let mut r = rec();
r.note_on(60, 100, Beats(1.0));
r.note_off(60, Beats(2.0)); // pass 0
wrap(&mut r);
r.note_on(62, 100, Beats(1.0));
r.note_off(62, Beats(2.0)); // pass 1
wrap(&mut r);
r.note_on(64, 100, Beats(1.0));
r.note_off(64, Beats(2.0)); // pass 2
assert_eq!(r.pass_count(), 3);
let by_pass = r.notes_by_pass(r.pass_count());
let pitches: Vec<Vec<u8>> = by_pass
.iter()
.map(|p| p.iter().map(|n| n.1).collect())
.collect();
assert_eq!(pitches, vec![vec![60], vec![62], vec![64]]);
}
#[test]
fn a_note_held_across_a_wrap_splits_between_the_two_passes() {
// The key is still down at the boundary: the sounding half belongs to the pass that's
// ending, and the re-opened half to the pass that's beginning. Getting the pass bump on the
// wrong side of close_active_notes would file the whole note under one pass.
let mut r = rec();
r.note_on(60, 100, Beats(3.0));
wrap(&mut r); // still held
r.note_off(60, Beats(1.0)); // released 1 beat into the next pass
assert_eq!(r.pass_count(), 2);
let by_pass = r.notes_by_pass(r.pass_count());
assert_eq!(by_pass[0].len(), 1, "the held half lands in the pass that ended");
assert_eq!(by_pass[1].len(), 1, "the re-opened half lands in the next pass");
// Pass 0's half runs from beat 3 to the region end at 4.
assert_eq!(by_pass[0][0].0, Beats(3.0));
assert_eq!(by_pass[0][0].3, Beats(1.0));
// Pass 1's half starts at the region start and runs to the release.
assert_eq!(by_pass[1][0].0, Beats(0.0));
assert_eq!(by_pass[1][0].3, Beats(1.0));
}
#[test]
fn a_silent_interior_pass_still_yields_an_empty_take() {
// Take N in the folder must be pass N on the transport, even if nothing was played — else
// the take numbering silently shifts under the user.
let mut r = rec();
r.note_on(60, 100, Beats(1.0));
r.note_off(60, Beats(2.0)); // pass 0
wrap(&mut r);
wrap(&mut r); // pass 1: played nothing
r.note_on(64, 100, Beats(1.0));
r.note_off(64, Beats(2.0)); // pass 2
let by_pass = r.notes_by_pass(r.pass_count());
assert_eq!(by_pass.len(), 3);
assert_eq!(by_pass[1].len(), 0, "the silent pass is still take 2");
assert_eq!(by_pass[2][0].1, 64);
}
#[test]
fn a_trailing_empty_pass_is_dropped() {
// Hitting stop shortly after a wrap leaves a pass you never played into. That's a stop
// artifact, not a take — unlike a silent pass in the middle, which was a deliberate rest.
let mut r = rec();
r.note_on(60, 100, Beats(1.0));
r.note_off(60, Beats(2.0)); // pass 0
wrap(&mut r);
r.note_on(62, 100, Beats(1.0));
r.note_off(62, Beats(2.0)); // pass 1
wrap(&mut r); // pass 2 begins... and the user hits stop
assert_eq!(r.pass_count(), 3);
let by_pass = r.notes_by_pass(r.pass_count());
assert_eq!(by_pass.len(), 2, "the empty trailing pass is not a take");
}
#[test]
fn an_empty_recording_still_yields_one_take() {
let mut r = rec();
wrap(&mut r);
wrap(&mut r);
assert_eq!(r.notes_by_pass(r.pass_count()).len(), 1);
}
} }

View File

@ -1,6 +1,6 @@
use super::automation::{AutomationLane, AutomationLaneId, ParameterId}; use super::automation::{AutomationLane, AutomationLaneId, ParameterId};
use super::clip::{AudioClipInstance, AudioClipInstanceId}; use super::clip::{AudioClipInstance, AudioClipInstanceId};
use super::midi::{MidiClipInstance, MidiClipInstanceId, MidiEvent}; use super::midi::{MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent};
use super::midi_pool::MidiClipPool; use super::midi_pool::MidiClipPool;
use super::node_graph::AudioGraph; use super::node_graph::AudioGraph;
use super::node_graph::nodes::{AudioInputNode, AudioOutputNode}; use super::node_graph::nodes::{AudioInputNode, AudioOutputNode};
@ -43,6 +43,13 @@ pub struct RenderContext<'a> {
/// Used after pause/stop to route note-off tails through the normal group hierarchy /// Used after pause/stop to route note-off tails through the normal group hierarchy
/// without re-triggering notes from clips at the paused position. /// without re-triggering notes from clips at the paused position.
pub live_only: bool, pub live_only: bool,
/// The MIDI recording in progress, if any: (track being recorded to, clip being recorded into).
///
/// On that track, every OTHER clip is silenced for the duration of the recording. You're playing
/// a part into this region — hearing what's already there (a previous take, say) fighting with
/// what you're playing now is just noise. The clip being recorded into is exempt, because in
/// merge mode that's exactly what you DO want to hear: the overdub you've been building up.
pub recording_midi: Option<(TrackId, MidiClipId)>,
} }
impl<'a> RenderContext<'a> { impl<'a> RenderContext<'a> {
@ -61,6 +68,7 @@ impl<'a> RenderContext<'a> {
buffer_size, buffer_size,
time_stretch: 1.0, time_stretch: 1.0,
live_only: false, live_only: false,
recording_midi: None,
} }
} }
@ -864,9 +872,21 @@ impl MidiTrack {
let playhead_beats = ctx.playhead_beats(); let playhead_beats = ctx.playhead_beats();
let buffer_end_beats = ctx.buffer_end_beats(); let buffer_end_beats = ctx.buffer_end_beats();
// While recording into this track, every clip EXCEPT the one being recorded into is
// silenced. Otherwise a take folder already sitting in the cycle region would play its
// active take underneath you on every pass, fighting the part you're trying to record.
// The recording clip itself is exempt: in merge mode that's the overdub monitoring.
let muted_clip = match ctx.recording_midi {
Some((track_id, clip_id)) if track_id == self.id => Some(clip_id),
_ => None,
};
// Collect MIDI events from all clip instances that overlap with current beat range // Collect MIDI events from all clip instances that overlap with current beat range
let mut currently_active = HashSet::new(); let mut currently_active = HashSet::new();
for instance in &self.clip_instances { for instance in &self.clip_instances {
if muted_clip.is_some_and(|recording| instance.clip_id != recording) {
continue;
}
if instance.overlaps_range(playhead_beats, buffer_end_beats) { if instance.overlaps_range(playhead_beats, buffer_end_beats) {
currently_active.insert(instance.id); currently_active.insert(instance.id);
} }

View File

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

View File

@ -8,6 +8,21 @@ use crate::audio::node_graph::nodes::LoopMode;
use crate::io::WaveformPeak; use crate::io::WaveformPeak;
use crate::time::{Beats, Seconds}; use crate::time::{Beats, Seconds};
/// A clip's internal (content) boundaries, tagged with the domain they're measured in.
///
/// A clip's content time is SECONDS for sampled audio but BEATS for MIDI — the same polymorphism
/// `ClipInstance::trim_start`/`trim_end` carry. Passing these as bare `f64`s meant the caller and
/// the engine could disagree about the unit with nothing to catch it: an audio trim of "1.0" was
/// once stored as `Beats(1.0)` for the clip's external duration, so a 1-second split played back as
/// half a second at 120 BPM. Tagging the domain makes that a type error instead of a bug report.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TrimRange {
/// Sampled-audio content time.
Seconds { start: Seconds, end: Seconds },
/// MIDI content time.
Beats { start: Beats, end: Beats },
}
/// Commands sent from UI/control thread to audio thread /// Commands sent from UI/control thread to audio thread
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Command { pub enum Command {
@ -19,7 +34,7 @@ pub enum Command {
/// Pause playback (maintains position) /// Pause playback (maintains position)
Pause, Pause,
/// Seek to a specific position in seconds /// Seek to a specific position in seconds
Seek(f64), Seek(Seconds),
// Track management commands // Track management commands
/// Set track volume (0.0 = silence, 1.0 = unity gain) /// Set track volume (0.0 = silence, 1.0 = unity gain)
@ -31,13 +46,12 @@ pub enum Command {
// Clip management commands // Clip management commands
/// Move a clip to a new timeline position (track_id, clip_id, new_external_start) /// Move a clip to a new timeline position (track_id, clip_id, new_external_start)
MoveClip(TrackId, ClipId, f64), MoveClip(TrackId, ClipId, Beats),
/// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end) /// Trim a clip's internal boundaries — which portion of the source content is used.
/// This changes which portion of the source content is used TrimClip(TrackId, ClipId, TrimRange),
TrimClip(TrackId, ClipId, f64, f64),
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
/// If duration > internal duration, the clip will loop /// If duration > internal duration, the clip will loop
ExtendClip(TrackId, ClipId, f64), ExtendClip(TrackId, ClipId, Beats),
// Metatrack management commands // Metatrack management commands
/// Create a new metatrack with a name and optional parent group /// Create a new metatrack with a name and optional parent group
@ -53,15 +67,15 @@ pub enum Command {
SetTimeStretch(TrackId, f32), SetTimeStretch(TrackId, f32),
/// Set metatrack time offset in seconds (track_id, offset) /// Set metatrack time offset in seconds (track_id, offset)
/// Positive = shift content later, negative = shift earlier /// Positive = shift content later, negative = shift earlier
SetOffset(TrackId, f64), SetOffset(TrackId, Seconds),
/// Set metatrack pitch shift in semitones (track_id, semitones) - for future use /// Set metatrack pitch shift in semitones (track_id, semitones) - for future use
SetPitchShift(TrackId, f32), SetPitchShift(TrackId, f32),
/// Set metatrack trim start in seconds (track_id, trim_start) /// Set metatrack trim start in seconds (track_id, trim_start)
/// Children won't hear content before this point /// Children won't hear content before this point
SetTrimStart(TrackId, f64), SetTrimStart(TrackId, Seconds),
/// Set metatrack trim end in seconds (track_id, trim_end) /// Set metatrack trim end in seconds (track_id, trim_end)
/// None means no end trim /// None means no end trim
SetTrimEnd(TrackId, Option<f64>), SetTrimEnd(TrackId, Option<Seconds>),
// Audio track commands // Audio track commands
/// Create a new audio track with a name and optional parent group /// Create a new audio track with a name and optional parent group
@ -80,14 +94,14 @@ pub enum Command {
/// Add a MIDI clip to the pool without placing it on a track /// Add a MIDI clip to the pool without placing it on a track
AddMidiClipToPool(MidiClip), AddMidiClipToPool(MidiClip),
/// Create a new MIDI clip on a track (track_id, start_time, duration) /// Create a new MIDI clip on a track (track_id, start_time, duration)
CreateMidiClip(TrackId, f64, f64), CreateMidiClip(TrackId, Beats, Beats),
/// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration) /// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration)
AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64), AddMidiNote(TrackId, MidiClipId, Beats, u8, u8, Beats),
/// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time) /// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time)
AddLoadedMidiClip(TrackId, MidiClip, f64), AddLoadedMidiClip(TrackId, MidiClip, Beats),
/// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>) /// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>)
/// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes /// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes
UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>), UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(Beats, u8, u8, Beats)>),
/// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing. /// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing.
UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>), UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>),
/// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support /// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support
@ -103,9 +117,9 @@ pub enum Command {
/// Create a new automation lane on a track (track_id, parameter_id) /// Create a new automation lane on a track (track_id, parameter_id)
CreateAutomationLane(TrackId, ParameterId), CreateAutomationLane(TrackId, ParameterId),
/// Add an automation point to a lane (track_id, lane_id, time, value, curve) /// Add an automation point to a lane (track_id, lane_id, time, value, curve)
AddAutomationPoint(TrackId, AutomationLaneId, f64, f32, CurveType), AddAutomationPoint(TrackId, AutomationLaneId, Beats, f32, CurveType),
/// Remove an automation point at a specific time (track_id, lane_id, time, tolerance) /// Remove an automation point at a specific time (track_id, lane_id, time, tolerance)
RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64), RemoveAutomationPoint(TrackId, AutomationLaneId, Beats, Beats),
/// Clear all automation points from a lane (track_id, lane_id) /// Clear all automation points from a lane (track_id, lane_id)
ClearAutomationLane(TrackId, AutomationLaneId), ClearAutomationLane(TrackId, AutomationLaneId),
/// Remove an automation lane (track_id, lane_id) /// Remove an automation lane (track_id, lane_id)
@ -113,9 +127,24 @@ pub enum Command {
/// Enable/disable an automation lane (track_id, lane_id, enabled) /// Enable/disable an automation lane (track_id, lane_id, enabled)
SetAutomationLaneEnabled(TrackId, AutomationLaneId, bool), SetAutomationLaneEnabled(TrackId, AutomationLaneId, bool),
// Transport cycle (loop) region
/// Set the cycle region the transport loops over, in beats (None clears it).
/// Authored in beats so it survives tempo changes.
SetLoopRegion(Option<(Beats, Beats)>),
/// Enable/disable wrapping at the cycle region's end.
SetLoopEnabled(bool),
/// How a cycle MIDI recording treats its passes.
///
/// `false` (default) = MERGE: every pass overdubs into one clip. `true` = SEPARATE TAKES: each
/// pass becomes its own MIDI clip, and the editor folds them into a take folder — the same shape
/// audio always gets.
SetCycleMidiSeparateTakes(bool),
// Recording commands // Recording commands
/// Start recording on a track (track_id, start_time) /// Start recording on a track (track_id, start_time)
StartRecording(TrackId, Beats), /// (track, start_time, force_takes — cut takes even if the transport never wraps, because the
/// region already holds takes and this is another one)
StartRecording(TrackId, Beats, bool),
/// Stop the current recording /// Stop the current recording
StopRecording, StopRecording,
/// Pause the current recording /// Pause the current recording
@ -125,7 +154,8 @@ pub enum Command {
// MIDI Recording commands // MIDI Recording commands
/// Start MIDI recording on a track (track_id, clip_id, start_time) /// Start MIDI recording on a track (track_id, clip_id, start_time)
StartMidiRecording(TrackId, MidiClipId, Beats), /// (track, clip, start_time, force_takes — see [`Command::StartRecording`])
StartMidiRecording(TrackId, MidiClipId, Beats, bool),
/// Stop the current MIDI recording /// Stop the current MIDI recording
StopMidiRecording, StopMidiRecording,
@ -237,9 +267,9 @@ pub enum Command {
// Automation Input Node commands // Automation Input Node commands
/// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in) /// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in)
AutomationAddKeyframe(TrackId, u32, f64, f32, String, (f32, f32), (f32, f32)), AutomationAddKeyframe(TrackId, u32, Beats, f32, String, (f32, f32), (f32, f32)),
/// Remove a keyframe from an AutomationInput node (track_id, node_id, time) /// Remove a keyframe from an AutomationInput node (track_id, node_id, time)
AutomationRemoveKeyframe(TrackId, u32, f64), AutomationRemoveKeyframe(TrackId, u32, Beats),
/// Set the display name of an AutomationInput node (track_id, node_id, name) /// Set the display name of an AutomationInput node (track_id, node_id, name)
AutomationSetName(TrackId, u32, String), AutomationSetName(TrackId, u32, String),
@ -271,7 +301,7 @@ pub enum Command {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AudioEvent { pub enum AudioEvent {
/// Current playback position in seconds /// Current playback position in seconds
PlaybackPosition(f64), PlaybackPosition(Seconds),
/// Playback has stopped (reached end of audio) /// Playback has stopped (reached end of audio)
PlaybackStopped, PlaybackStopped,
/// Audio buffer underrun detected /// Audio buffer underrun detected
@ -292,6 +322,35 @@ pub enum AudioEvent {
RecordingProgress(ClipId, Seconds), RecordingProgress(ClipId, Seconds),
/// Recording stopped (clip_id, pool_index, waveform) /// Recording stopped (clip_id, pool_index, waveform)
RecordingStopped(ClipId, usize, Vec<WaveformPeak>), RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
/// A MIDI recording that wrapped the cycle region at least once, in SEPARATE TAKES mode.
///
/// One MIDI clip per pass, in recording order. (Merge mode emits the ordinary
/// `MidiRecordingStopped` instead — all passes are already folded into the one clip.)
MidiCycleRecordingStopped {
track_id: TrackId,
/// One pool MIDI clip per pass. The first is the clip the recording started on.
clip_ids: Vec<MidiClipId>,
/// Where the takes sit on the timeline — the cycle region's start.
loop_start: Beats,
/// The region's length in beats: every take spans exactly this.
loop_len_beats: Beats,
},
/// A recording that wrapped the cycle region at least once, and so became multi-take.
///
/// Each take spans the full region and they're all the same length (partial passes are padded
/// with silence), so the editor can promote the recording clip straight to a take folder.
CycleRecordingStopped {
clip_id: ClipId,
/// One entry per pass: (audio pool index, waveform peaks), in recording order.
takes: Vec<(usize, Vec<WaveformPeak>)>,
/// Where the takes sit on the timeline — the cycle region's start, not the punch-in point.
loop_start: Beats,
/// The region's length in beats (what the take folder stores as `recorded_loop_beats`).
loop_len_beats: Beats,
/// The same length in seconds — the take folder's content duration, which is seconds-domain
/// for audio.
loop_len_seconds: Seconds,
},
/// Recording error (error_message) /// Recording error (error_message)
RecordingError(String), RecordingError(String),
/// MIDI recording stopped (track_id, clip_id, note_count) /// MIDI recording stopped (track_id, clip_id, note_count)
@ -342,7 +401,7 @@ pub enum AudioEvent {
WaveformChunksReady { WaveformChunksReady {
pool_index: usize, pool_index: usize,
detail_level: u8, detail_level: u8,
chunks: Vec<(u32, (f64, f64), Vec<WaveformPeak>)>, chunks: Vec<(u32, (Seconds, Seconds), Vec<WaveformPeak>)>,
}, },
/// An audio file has been imported and is ready for playback. /// An audio file has been imported and is ready for playback.
@ -353,7 +412,7 @@ pub enum AudioEvent {
path: String, path: String,
channels: u32, channels: u32,
sample_rate: u32, sample_rate: u32,
duration: f64, duration: Seconds,
format: crate::io::audio_file::AudioFormat, format: crate::io::audio_file::AudioFormat,
}, },
@ -427,7 +486,7 @@ pub enum Query {
/// Export audio to file (settings, output_path) /// Export audio to file (settings, output_path)
ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), ExportAudio(crate::audio::ExportSettings, std::path::PathBuf),
/// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID /// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID
AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64), AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, Beats),
/// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID /// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID
/// The clip must already exist in the MidiClipPool /// The clip must already exist in the MidiClipPool
AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance),
@ -472,16 +531,21 @@ pub struct OscilloscopeData {
} }
/// MIDI clip data for serialization /// MIDI clip data for serialization
///
/// `Beats`/`Seconds` are `#[serde(transparent)]`, so naming the domain here costs nothing on disk —
/// the `.beam` still holds a plain number.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClipData { pub struct MidiClipData {
pub duration: f64, /// MIDI content length is musical, so beats.
pub duration: Beats,
pub events: Vec<crate::audio::midi::MidiEvent>, pub events: Vec<crate::audio::midi::MidiEvent>,
} }
/// Automation keyframe data for serialization /// Automation keyframe data for serialization
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AutomationKeyframeData { pub struct AutomationKeyframeData {
pub time: f64, /// Automation x-axes are all beats.
pub time: Beats,
pub value: f32, pub value: f32,
pub interpolation: String, pub interpolation: String,
pub ease_out: (f32, f32), pub ease_out: (f32, f32),
@ -518,7 +582,7 @@ pub enum QueryResponse {
/// Pool waveform data /// Pool waveform data
PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>), PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>),
/// Pool file info (duration, sample_rate, channels) /// Pool file info (duration, sample_rate, channels)
PoolFileInfo(Result<(f64, u32, u32), String>), PoolFileInfo(Result<(Seconds, u32, u32), String>),
/// Audio exported /// Audio exported
AudioExported(Result<(), String>), AudioExported(Result<(), String>),
/// MIDI clip instance added (returns instance ID) /// MIDI clip instance added (returns instance ID)

View File

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

View File

@ -16,6 +16,50 @@ pub struct Beats(pub f64);
#[serde(transparent)] #[serde(transparent)]
pub struct Seconds(pub f64); pub struct Seconds(pub f64);
/// A time *inside a clip's own content*, in whatever unit that clip measures content in.
///
/// Clip content time is domain-polymorphic: SECONDS for sampled audio, video and vector, but BEATS
/// for MIDI (musical, so it survives tempo changes). `ClipInstance::trim_start`/`trim_end` are
/// content times, and storing them as bare `f64`s is what let a seconds delta get added to a MIDI
/// clip's beats trim — splitting a MIDI clip at beat 4 landed at beat 2 at 120 BPM.
///
/// This type is deliberately a **dead end**: it has no `.to_seconds()`, no `.to_beats()`, and no
/// arithmetic with `Seconds` or `Beats`. Content times can be compared and combined with each other
/// (that's domain-safe — both operands are in the same clip's domain), but the only way to get a
/// real timeline duration out is to resolve it against the clip that knows the domain, via
/// `AudioClip::resolve_content_time` / `Document::resolve_content_time`. So a passthrough costs
/// nothing, and mixing domains won't compile.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ContentTime(pub f64);
impl ContentTime {
pub const ZERO: Self = Self(0.0);
pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) }
pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) }
/// The raw magnitude, with the domain discarded.
///
/// Only for code that is *already* working in this clip's content domain (trim arithmetic,
/// serialization, drawing a waveform whose x-axis is the clip's own content). If you are about
/// to combine this with a timeline position, resolve it against the clip instead.
pub fn raw(self) -> f64 { self.0 }
}
impl Add for ContentTime {
type Output = Self;
fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) }
}
impl Sub for ContentTime {
type Output = Self;
fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) }
}
impl Rem for ContentTime {
type Output = Self;
fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) }
}
impl Beats { impl Beats {
pub const ZERO: Self = Self(0.0); pub const ZERO: Self = Self(0.0);

View File

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

View File

@ -3628,7 +3628,7 @@ dependencies = [
[[package]] [[package]]
name = "lightningbeam-editor" name = "lightningbeam-editor"
version = "1.0.8-alpha" version = "1.0.9-alpha"
dependencies = [ dependencies = [
"beamdsp", "beamdsp",
"bytemuck", "bytemuck",

View File

@ -47,6 +47,113 @@ pub struct BackendContext<'a> {
// Future: pub video_controller: Option<&'a mut VideoController>, // Future: pub video_controller: Option<&'a mut VideoController>,
} }
impl BackendContext<'_> {
/// Hand a clip instance to the audio engine and record it in the instance→backend map.
///
/// Take folders are resolved through the instance's `active_take`, so the backend gets whichever
/// take is selected. Returns the backend track and instance IDs, or `None` when there's nothing
/// to sync yet (a recording in progress, or an empty take folder).
///
/// Lives here rather than in any one action because more than one action needs it: adding an
/// instance, and switching a take folder's active take (which is a remove + re-add, there being
/// no in-place pool-swap command). Keeping one implementation keeps the trim/duration
/// conversions — the easy thing to get subtly wrong, since `trim_*` is SECONDS while
/// `timeline_*` is BEATS — from drifting between copies.
pub fn add_clip_instance(
&mut self,
document: &Document,
layer_id: &Uuid,
instance: &crate::clip::ClipInstance,
) -> Result<Option<(daw_backend::TrackId, BackendClipInstanceId)>, String> {
use crate::clip::ResolvedContent;
let clip = document
.get_audio_clip(&instance.clip_id)
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
let track_id = *self
.layer_to_track_map
.get(layer_id)
.ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?;
let resolved = instance.resolve(clip);
let content = clip.content_duration();
let internal_start = instance.trim_start;
let internal_end = instance
.trim_end
.unwrap_or(daw_backend::ContentTime(content.native()));
let start_time = instance.timeline_start;
// How long the clip occupies the timeline, in BEATS. `effective_duration_beats` resolves the
// content window in the clip's own domain — beats content carries over directly, wall-clock
// content converts at the clip's position — so neither kind can be read as the other here.
let effective_duration = instance.effective_duration_beats(content, document.tempo_map());
let controller = self
.audio_controller
.as_mut()
.ok_or_else(|| "Audio controller not available".to_string())?;
let backend_id = match resolved {
ResolvedContent::Midi { midi_clip_id } => {
use daw_backend::command::{Query, QueryResponse};
// MIDI content time IS beats, so the trims carry straight over.
let midi_instance = daw_backend::MidiClipInstance::new(
0, // assigned by the backend
midi_clip_id,
daw_backend::Beats(internal_start.raw()),
daw_backend::Beats(internal_end.raw()),
start_time,
effective_duration,
);
match controller
.send_query(Query::AddMidiClipInstanceSync(track_id, midi_instance))?
{
QueryResponse::MidiClipInstanceAdded(Ok(id)) => BackendClipInstanceId::Midi(id),
QueryResponse::MidiClipInstanceAdded(Err(e)) => return Err(e),
_ => return Err("Unexpected query response".to_string()),
}
}
ResolvedContent::Audio { audio_pool_index } => {
// Sampled-audio content time is SECONDS; the backend's start/duration are BEATS.
let id = controller.add_audio_clip(
track_id,
audio_pool_index,
start_time,
effective_duration,
daw_backend::Seconds(internal_start.raw()),
);
BackendClipInstanceId::Audio(id)
}
// Nothing to sync until it has content.
ResolvedContent::Recording => return Ok(None),
};
self.clip_instance_to_backend_map
.insert(instance.id, backend_id);
Ok(Some((track_id, backend_id)))
}
/// Remove a clip instance's backend clip and drop it from the instance→backend map.
pub fn remove_clip_instance(
&mut self,
track_id: daw_backend::TrackId,
backend_id: BackendClipInstanceId,
instance_id: Uuid,
) {
if let Some(controller) = self.audio_controller.as_mut() {
match backend_id {
BackendClipInstanceId::Midi(id) => controller.remove_midi_clip(track_id, id),
BackendClipInstanceId::Audio(id) => controller.remove_audio_clip(track_id, id),
}
}
self.clip_instance_to_backend_map.remove(&instance_id);
}
}
/// Action trait for undo/redo operations /// Action trait for undo/redo operations
/// ///
/// Each action must be able to execute (apply changes) and rollback (undo changes). /// Each action must be able to execute (apply changes) and rollback (undo changes).

View File

@ -89,10 +89,11 @@ impl Action for AddClipInstanceAction {
// `get_clip_duration` is the content length in seconds; the placement span // `get_clip_duration` is the content length in seconds; the placement span
// must be beats (the timeline is beats-domain), so convert via the clip's // must be beats (the timeline is beats-domain), so convert via the clip's
// typed helper rather than treating the seconds span as beats. // typed helper rather than treating the seconds span as beats.
let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id) // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
let clip_content = document.clip_trim_duration(&self.clip_instance.clip_id)
.ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?; .ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?;
let effective_duration = self.clip_instance let effective_duration = self.clip_instance
.effective_duration_beats(clip_duration, document.tempo_map()); .effective_duration_beats(clip_content, document.tempo_map());
// Auto-adjust position for audio/video layers to avoid overlaps // Auto-adjust position for audio/video layers to avoid overlaps
let adjusted_start = document.find_nearest_valid_position( let adjusted_start = document.find_nearest_valid_position(
@ -196,113 +197,23 @@ impl Action for AddClipInstanceAction {
return Ok(()); return Ok(());
} }
// Look up the clip from the document // Add via the shared BackendContext helper — the same one SetActiveTakeAction uses, so
let clip = document // the trim/duration conversions (and take-folder resolution) live in exactly one place.
.get_audio_clip(&self.clip_instance.clip_id) if let Some((track_id, backend_id)) =
.ok_or_else(|| "Audio clip not found".to_string())?; backend.add_clip_instance(document, &self.layer_id, &self.clip_instance)?
{
// Look up backend track ID from layer mapping self.backend_track_id = Some(track_id);
let backend_track_id = backend match backend_id {
.layer_to_track_map crate::action::BackendClipInstanceId::Midi(id) => {
.get(&self.layer_id) self.backend_midi_instance_id = Some(id)
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; }
crate::action::BackendClipInstanceId::Audio(id) => {
// Get audio controller self.backend_audio_instance_id = Some(id)
let controller = backend
.audio_controller
.as_mut()
.ok_or_else(|| "Audio controller not available".to_string())?;
// Handle different clip types
use crate::clip::AudioClipType;
match &clip.clip_type {
AudioClipType::Midi { midi_clip_id } => {
// Create a MIDI clip instance referencing the existing clip in the backend pool
// No need to add to pool again - it was added during MIDI import
use daw_backend::command::{Query, QueryResponse};
// Calculate internal start/end from trim parameters
let internal_start = self.clip_instance.trim_start;
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native());
let external_start = self.clip_instance.timeline_start;
// Calculate external duration (for looping if timeline_duration is set).
// MIDI trims are beats-domain, so the fallback span is beats too.
let external_duration = self.clip_instance.timeline_duration
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
// Create MidiClipInstance
let instance = daw_backend::MidiClipInstance::new(
0, // Instance ID will be assigned by backend
*midi_clip_id,
daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end),
external_start,
external_duration,
);
// Send query to add instance and get instance ID
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
match controller.send_query(query)? {
QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => {
self.backend_track_id = Some(*backend_track_id);
self.backend_midi_instance_id = Some(instance_id);
// Add to global clip instance mapping
backend.clip_instance_to_backend_map.insert(
self.clip_instance.id,
crate::action::BackendClipInstanceId::Midi(instance_id)
);
Ok(())
}
QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e),
_ => Err("Unexpected query response".to_string()),
} }
} }
AudioClipType::Sampled { audio_pool_index } => {
// `trim_*` / `clip.duration` are in SECONDS (audio content time),
// while `timeline_*` and the backend's `duration` are in BEATS.
let internal_start = self.clip_instance.trim_start;
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native());
let start_time = self.clip_instance.timeline_start;
// `effective_duration` is in BEATS. When `timeline_duration` is set
// it already is; otherwise the clip occupies its natural content
// length, so convert that seconds-span to beats at the clip's start
// (NOT `internal_end - internal_start`, which is seconds — that was
// the seconds-as-beats bug that made clips stop early off 60 BPM).
let effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| {
let tempo_map = document.tempo_map();
let content_secs = daw_backend::Seconds(internal_end - internal_start);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
- start_time
});
let instance_id = controller.add_audio_clip(
*backend_track_id,
*audio_pool_index,
start_time,
effective_duration,
daw_backend::Seconds(internal_start),
);
self.backend_track_id = Some(*backend_track_id);
self.backend_audio_instance_id = Some(instance_id);
// Add to global clip instance mapping
backend.clip_instance_to_backend_map.insert(
self.clip_instance.id,
crate::action::BackendClipInstanceId::Audio(instance_id)
);
Ok(())
}
AudioClipType::Recording => {
// Recording clips are not synced to backend until finalized
Ok(())
}
} }
Ok(())
} }
fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> { fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> {

View File

@ -0,0 +1,141 @@
//! Append freshly-recorded takes to an existing take folder.
//!
//! Cycle-recording over a region that already holds a take folder should *add* to that folder, not
//! drop a second clip on top of it. Otherwise the takes from your second attempt are stranded in a
//! separate, overlapping clip and you can't audition them against the first.
//!
//! The recorded content already exists in the backend pools by the time this runs (the engine put it
//! there at stop), so this action only touches the document — plus the one backend clip the instance
//! plays, which has to be repointed at the newly-active take.
use crate::action::{Action, BackendClipInstanceId, BackendContext};
use crate::clip::AudioTake;
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
/// Action that appends takes to an instance's take list and selects the last of them.
pub struct AppendTakesAction {
layer_id: Uuid,
/// The instance whose take list is being extended (and whose active take changes).
instance_id: Uuid,
/// The takes to add, in recording order.
new_takes: Vec<AudioTake>,
// Stored during execute for rollback.
old_take_count: usize,
old_active_take: Option<usize>,
executed: bool,
}
impl AppendTakesAction {
pub fn new(layer_id: Uuid, instance_id: Uuid, new_takes: Vec<AudioTake>) -> Self {
Self {
layer_id,
instance_id,
new_takes,
old_take_count: 0,
old_active_take: None,
executed: false,
}
}
/// Swap the instance's backend clip to whatever take the document now says is active.
///
/// Same remove + re-add as `SetActiveTakeAction` — there's no in-place pool-swap command.
fn resync(&self, backend: &mut BackendContext, document: &Document) -> Result<(), String> {
let instance = document
.get_layer(&self.layer_id)
.and_then(|l| match l {
AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == self.instance_id),
_ => None,
})
.cloned()
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
let existing: Option<BackendClipInstanceId> = backend
.clip_instance_to_backend_map
.get(&self.instance_id)
.copied();
let track_id = backend.layer_to_track_map.get(&self.layer_id).copied();
if let (Some(backend_id), Some(track_id)) = (existing, track_id) {
backend.remove_clip_instance(track_id, backend_id, self.instance_id);
}
backend.add_clip_instance(document, &self.layer_id, &instance)?;
Ok(())
}
}
impl Action for AppendTakesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let AnyLayer::Audio(audio_layer) = layer else {
return Err("Takes only exist on audio layers".to_string());
};
let instance = audio_layer
.clip_instances
.iter_mut()
.find(|ci| ci.id == self.instance_id)
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
// Only record the pre-state on the first execute; a redo must not overwrite it with the
// post-state left behind by the previous run.
if !self.executed {
self.old_take_count = instance.takes.len();
self.old_active_take = instance.active_take;
}
// Number the new takes on from what's already there. Existing names are left alone — the
// user may well have renamed them, and renumbering would clobber that.
let base = instance.takes.len();
for (i, take) in self.new_takes.iter().enumerate() {
let mut take = take.clone();
if take.name.is_empty() {
take.name = format!("Take {}", base + i + 1);
}
instance.takes.push(take);
}
// Land on the take just recorded, GarageBand-style.
instance.active_take = Some(instance.takes.len() - 1);
self.executed = true;
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer_mut(&self.layer_id) {
if let Some(instance) = audio_layer
.clip_instances
.iter_mut()
.find(|ci| ci.id == self.instance_id)
{
instance.takes.truncate(self.old_take_count);
instance.active_take = self.old_active_take;
}
}
Ok(())
}
fn description(&self) -> String {
format!("Record {} take(s)", self.new_takes.len())
}
fn execute_backend(
&mut self,
backend: &mut BackendContext,
document: &Document,
) -> Result<(), String> {
self.resync(backend, document)
}
fn rollback_backend(
&mut self,
backend: &mut BackendContext,
document: &Document,
) -> Result<(), String> {
self.resync(backend, document)
}
}

View File

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

View File

@ -0,0 +1,419 @@
//! Take management: delete and rename the takes on a clip instance.
//!
//! Takes live on the INSTANCE, so both of these are naturally scoped to the one the user clicked —
//! deleting a take from one half of a comped split leaves the other half's list alone.
use crate::action::{Action, BackendClipInstanceId, BackendContext};
use crate::clip::{AudioTake, ClipInstance};
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
/// The instance a take action targets, looked up mutably.
fn instance_mut<'a>(
document: &'a mut Document,
layer_id: &Uuid,
instance_id: &Uuid,
) -> Result<&'a mut ClipInstance, String> {
let layer = document
.get_layer_mut(layer_id)
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
let AnyLayer::Audio(audio_layer) = layer else {
return Err("Takes only exist on audio layers".to_string());
};
audio_layer
.clip_instances
.iter_mut()
.find(|ci| ci.id == *instance_id)
.ok_or_else(|| format!("Clip instance {} not found", instance_id))
}
/// Swap an instance's backend clip to whatever take the document now says is active.
///
/// The same remove + re-add as `SetActiveTakeAction` — there's no in-place pool-swap command.
fn resync(
backend: &mut BackendContext,
document: &Document,
layer_id: &Uuid,
instance_id: &Uuid,
) -> Result<(), String> {
let instance = document
.get_layer(layer_id)
.and_then(|l| match l {
AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == *instance_id),
_ => None,
})
.cloned()
.ok_or_else(|| format!("Clip instance {} not found", instance_id))?;
let existing: Option<BackendClipInstanceId> = backend
.clip_instance_to_backend_map
.get(instance_id)
.copied();
let track_id = backend.layer_to_track_map.get(layer_id).copied();
if let (Some(backend_id), Some(track_id)) = (existing, track_id) {
backend.remove_clip_instance(track_id, backend_id, *instance_id);
}
backend.add_clip_instance(document, layer_id, &instance)?;
Ok(())
}
/// Remove a take from an instance's take list.
///
/// The take's recorded audio/MIDI stays in the backend pool — undo has to be able to put it back,
/// and other instances (the other half of a split, say) may still be playing it.
pub struct DeleteTakeAction {
layer_id: Uuid,
instance_id: Uuid,
take_index: usize,
// Stored during execute for rollback.
removed: Option<AudioTake>,
old_active_take: Option<usize>,
}
impl DeleteTakeAction {
pub fn new(layer_id: Uuid, instance_id: Uuid, take_index: usize) -> Self {
Self {
layer_id,
instance_id,
take_index,
removed: None,
old_active_take: None,
}
}
}
impl Action for DeleteTakeAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
if self.take_index >= instance.takes.len() {
return Err(format!("Take {} does not exist", self.take_index + 1));
}
// An instance with no takes at all would fall back to the clip's own content, which for a
// cycle recording is a take we may just have deleted. Refuse rather than strand it.
if instance.takes.len() == 1 {
return Err("Can't delete the only take".to_string());
}
self.old_active_take = instance.active_take;
self.removed = Some(instance.takes.remove(self.take_index));
// Everything above the removed take shifts down one, so the selection has to move with it.
// Deleting the *active* take lands on the one that took its place (or the new last take, if
// it was at the end) — that keeps the clip sounding rather than silently picking take 1.
let active = instance.active_take.unwrap_or(0);
instance.active_take = Some(if active > self.take_index {
active - 1
} else if active == self.take_index {
self.take_index.min(instance.takes.len() - 1)
} else {
active
});
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let Some(take) = self.removed.take() else {
return Ok(());
};
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
let at = self.take_index.min(instance.takes.len());
instance.takes.insert(at, take);
instance.active_take = self.old_active_take;
Ok(())
}
fn description(&self) -> String {
format!("Delete take {}", self.take_index + 1)
}
fn execute_backend(
&mut self,
backend: &mut BackendContext,
document: &Document,
) -> Result<(), String> {
resync(backend, document, &self.layer_id, &self.instance_id)
}
fn rollback_backend(
&mut self,
backend: &mut BackendContext,
document: &Document,
) -> Result<(), String> {
resync(backend, document, &self.layer_id, &self.instance_id)
}
}
/// Throw away every take except the one that's playing.
///
/// The tidy-up once you've picked your keeper. Scoped to this instance, so on a comped split it
/// prunes the half you clicked and leaves the other half's alternatives intact.
pub struct DeleteUnusedTakesAction {
layer_id: Uuid,
instance_id: Uuid,
// Stored during execute for rollback.
old_takes: Vec<AudioTake>,
old_active_take: Option<usize>,
}
impl DeleteUnusedTakesAction {
pub fn new(layer_id: Uuid, instance_id: Uuid) -> Self {
Self {
layer_id,
instance_id,
old_takes: Vec::new(),
old_active_take: None,
}
}
}
impl Action for DeleteUnusedTakesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
if instance.takes.len() < 2 {
return Err("Nothing to delete".to_string());
}
let keep = instance.active_take_index();
self.old_takes = instance.takes.clone();
self.old_active_take = instance.active_take;
let kept = instance.takes.remove(keep);
instance.takes.clear();
instance.takes.push(kept);
instance.active_take = Some(0);
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
instance.takes = std::mem::take(&mut self.old_takes);
instance.active_take = self.old_active_take;
Ok(())
}
fn description(&self) -> String {
"Delete unused takes".to_string()
}
// The take that plays doesn't change, so the backend clip is already correct.
}
/// Rename a take. Document-only — which take *plays* doesn't change, so the backend is untouched.
pub struct RenameTakeAction {
layer_id: Uuid,
instance_id: Uuid,
take_index: usize,
new_name: String,
old_name: String,
}
impl RenameTakeAction {
pub fn new(layer_id: Uuid, instance_id: Uuid, take_index: usize, new_name: String) -> Self {
Self {
layer_id,
instance_id,
take_index,
new_name,
old_name: String::new(),
}
}
}
impl Action for RenameTakeAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
let take = instance
.takes
.get_mut(self.take_index)
.ok_or_else(|| format!("Take {} does not exist", self.take_index + 1))?;
self.old_name = std::mem::replace(&mut take.name, self.new_name.clone());
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
if let Some(take) = instance.takes.get_mut(self.take_index) {
take.name = self.old_name.clone();
}
Ok(())
}
fn description(&self) -> String {
format!("Rename take to \"{}\"", self.new_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clip::TakeContent;
use crate::layer::AudioLayer;
/// A document with one audio layer holding one instance with 4 takes (pools 10..13).
fn doc_with_takes() -> (Document, Uuid, Uuid) {
let mut document = Document::new("Test");
let clip = crate::clip::AudioClip::new_sampled("Cycle rec", 10, 2.0);
let clip_id = document.add_audio_clip(clip);
let mut instance = ClipInstance::new(clip_id);
instance.takes = (10..14)
.map(|pool| AudioTake {
name: format!("Take {}", pool - 9),
content: TakeContent::Audio { audio_pool_index: pool },
})
.collect();
let instance_id = instance.id;
let mut layer = AudioLayer::new("Layer");
layer.clip_instances.push(instance);
let layer_id = document.root.add_child(AnyLayer::Audio(layer));
(document, layer_id, instance_id)
}
fn takes_of(document: &Document, layer_id: &Uuid, instance_id: &Uuid) -> ClipInstance {
let AnyLayer::Audio(al) = document.get_layer(layer_id).unwrap() else { panic!() };
al.clip_instances.iter().find(|ci| ci.id == *instance_id).unwrap().clone()
}
#[test]
fn deleting_a_take_below_the_active_one_shifts_the_selection_down() {
// Everything above the removed take shifts down one, so a selection above it has to move
// with it — otherwise the instance silently starts playing a different take.
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].active_take = Some(3); // playing pool 13
}
DeleteTakeAction::new(layer_id, instance_id, 1)
.execute(&mut document)
.expect("delete");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.takes.len(), 3);
assert_eq!(inst.active_take, Some(2), "index shifted down with the take");
assert_eq!(
inst.takes[inst.active_take_index()].content,
TakeContent::Audio { audio_pool_index: 13 },
"still playing the same take it was",
);
}
#[test]
fn deleting_the_active_take_lands_on_its_replacement() {
// Deleting what you're listening to should hand you the take that took its place, not
// silently jump you back to take 1.
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].active_take = Some(1); // playing pool 11
}
DeleteTakeAction::new(layer_id, instance_id, 1)
.execute(&mut document)
.expect("delete");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.active_take, Some(1));
assert_eq!(
inst.takes[1].content,
TakeContent::Audio { audio_pool_index: 12 },
"the take that slid into the deleted one's place",
);
}
#[test]
fn deleting_the_last_take_in_the_list_steps_back() {
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].active_take = Some(3);
}
DeleteTakeAction::new(layer_id, instance_id, 3)
.execute(&mut document)
.expect("delete");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.active_take, Some(2), "there is no take 4 to land on");
}
#[test]
fn the_only_take_cannot_be_deleted() {
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].takes.truncate(1);
}
assert!(DeleteTakeAction::new(layer_id, instance_id, 0)
.execute(&mut document)
.is_err());
}
#[test]
fn undoing_a_delete_puts_the_take_back_where_it_was() {
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].active_take = Some(2);
}
let mut action = DeleteTakeAction::new(layer_id, instance_id, 1);
action.execute(&mut document).expect("delete");
action.rollback(&mut document).expect("undo");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.takes.len(), 4);
assert_eq!(
inst.takes[1].content,
TakeContent::Audio { audio_pool_index: 11 },
"restored at its original index",
);
assert_eq!(inst.active_take, Some(2), "and the selection with it");
}
#[test]
fn deleting_unused_takes_keeps_the_one_thats_playing() {
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].active_take = Some(2); // pool 12 — the keeper
}
let mut action = DeleteUnusedTakesAction::new(layer_id, instance_id);
action.execute(&mut document).expect("prune");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.takes.len(), 1);
assert_eq!(inst.active_take, Some(0));
assert_eq!(
inst.takes[0].content,
TakeContent::Audio { audio_pool_index: 12 },
"the take that was playing survives, and nothing else",
);
action.rollback(&mut document).expect("undo");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.takes.len(), 4);
assert_eq!(inst.active_take, Some(2), "back to what was playing before");
}
#[test]
fn renaming_a_take_round_trips() {
let (mut document, layer_id, instance_id) = doc_with_takes();
let mut action =
RenameTakeAction::new(layer_id, instance_id, 2, "The good one".to_string());
action.execute(&mut document).expect("rename");
assert_eq!(takes_of(&document, &layer_id, &instance_id).takes[2].name, "The good one");
action.rollback(&mut document).expect("undo");
assert_eq!(takes_of(&document, &layer_id, &instance_id).takes[2].name, "Take 3");
}
}

View File

@ -14,6 +14,10 @@ pub mod move_clip_instances;
pub mod reorder_clip_instances; pub mod reorder_clip_instances;
pub mod paint_bucket; pub mod paint_bucket;
pub mod remove_effect; pub mod remove_effect;
pub mod set_cycle_region;
pub mod append_takes;
pub mod manage_takes;
pub mod set_active_take;
pub mod set_document_properties; pub mod set_document_properties;
pub mod set_instance_properties; pub mod set_instance_properties;
pub mod set_layer_properties; pub mod set_layer_properties;
@ -50,6 +54,10 @@ pub mod set_text_content;
pub mod resize_text_box; pub mod resize_text_box;
pub use add_clip_instance::AddClipInstanceAction; pub use add_clip_instance::AddClipInstanceAction;
pub use set_cycle_region::SetCycleRegionAction;
pub use append_takes::AppendTakesAction;
pub use manage_takes::{DeleteTakeAction, DeleteUnusedTakesAction, RenameTakeAction};
pub use set_active_take::SetActiveTakeAction;
pub use add_effect::AddEffectAction; pub use add_effect::AddEffectAction;
pub use add_layer::AddLayerAction; pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction; pub use add_shape::AddShapeAction;

View File

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

View File

@ -138,92 +138,23 @@ impl Action for RemoveClipInstancesAction {
backend: &mut BackendContext, backend: &mut BackendContext,
document: &Document, document: &Document,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::clip::AudioClipType; if backend.audio_controller.is_none() {
return Ok(());
}
let controller = match backend.audio_controller.as_mut() { // Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same
Some(c) => c, // helper the add and split actions use, so the trim/duration conversions (and take-folder
None => return Ok(()), // resolution) stay in exactly one place instead of being copied into every action that has
}; // to put a clip back.
let saved = std::mem::take(&mut self.saved);
// Re-add clips that were removed from backend for (layer_id, instance) in &saved {
for (layer_id, instance) in &self.saved { if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) {
let layer = match document.get_layer(layer_id) {
Some(l) => l,
None => continue,
};
if !matches!(layer, AnyLayer::Audio(_)) {
continue; continue;
} }
// A missing track/clip just means there's nothing to restore on the backend.
let track_id = match backend.layer_to_track_map.get(layer_id) { let _ = backend.add_clip_instance(document, layer_id, instance);
Some(id) => *id,
None => continue,
};
let clip = match document.get_audio_clip(&instance.clip_id) {
Some(c) => c,
None => continue,
};
match &clip.clip_type {
AudioClipType::Midi { midi_clip_id } => {
use daw_backend::command::{Query, QueryResponse};
let internal_start = instance.trim_start;
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
let external_start = instance.timeline_start;
// MIDI trims are beats-domain, so the fallback span is beats too.
let external_duration = instance
.timeline_duration
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
let midi_instance = daw_backend::MidiClipInstance::new(
0,
*midi_clip_id,
daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end),
external_start,
external_duration,
);
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
if let Ok(QueryResponse::MidiClipInstanceAdded(Ok(new_id))) =
controller.send_query(query)
{
backend.clip_instance_to_backend_map.insert(
instance.id,
BackendClipInstanceId::Midi(new_id),
);
}
}
AudioClipType::Sampled { audio_pool_index } => {
let internal_start = instance.trim_start;
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
let start_time = instance.timeline_start;
// Fallback span is the content seconds converted to beats at the
// clip's start (not the seconds span treated as beats).
let effective_duration = instance.timeline_duration.unwrap_or_else(|| {
let tempo_map = document.tempo_map();
let content_secs = daw_backend::Seconds(internal_end - internal_start);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
- start_time
});
let new_id = controller.add_audio_clip(
track_id,
*audio_pool_index,
start_time,
effective_duration,
daw_backend::Seconds(internal_start),
);
backend.clip_instance_to_backend_map.insert(
instance.id,
BackendClipInstanceId::Audio(new_id),
);
}
AudioClipType::Recording => {}
}
} }
self.saved = saved;
// Clear saved backend IDs // Clear saved backend IDs
self.saved_backend_ids.clear(); self.saved_backend_ids.clear();

View File

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

View File

@ -0,0 +1,93 @@
//! Set the transport cycle (loop) region.
//!
//! The cycle region is document state (it's saved in the `.beam`), so changing it goes through the
//! action system like any other edit: it's undoable and it marks the document modified.
//!
//! The region is stored in **beats** so it stays put musically across tempo changes. Callers commit
//! one action per gesture (e.g. on drag release, or a toggle click) rather than one per frame —
//! the timeline previews the drag from its own local state, exactly like a clip drag does.
use crate::action::{Action, BackendContext};
use crate::document::Document;
use daw_backend::Beats;
/// Action that sets the cycle region and/or whether the transport loops over it.
#[derive(Clone)]
pub struct SetCycleRegionAction {
old_region: Option<(Beats, Beats)>,
old_enabled: bool,
new_region: Option<(Beats, Beats)>,
new_enabled: bool,
}
impl SetCycleRegionAction {
/// Build from the document's current state and the desired new region/enabled flag.
pub fn new(
document: &Document,
new_region: Option<(Beats, Beats)>,
new_enabled: bool,
) -> Self {
Self {
old_region: document.cycle_region,
old_enabled: document.cycle_enabled,
new_region,
new_enabled,
}
}
/// Toggle looping on/off, leaving the region itself alone.
pub fn toggle_enabled(document: &Document) -> Self {
Self::new(document, document.cycle_region, !document.cycle_enabled)
}
/// True if this action would not actually change anything (lets callers skip a no-op undo entry).
pub fn is_noop(&self) -> bool {
self.old_region == self.new_region && self.old_enabled == self.new_enabled
}
}
impl Action for SetCycleRegionAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
document.cycle_region = self.new_region;
document.cycle_enabled = self.new_enabled;
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
document.cycle_region = self.old_region;
document.cycle_enabled = self.old_enabled;
Ok(())
}
fn description(&self) -> String {
"Set cycle region".to_string()
}
fn execute_backend(
&mut self,
backend: &mut BackendContext,
_document: &Document,
) -> Result<(), String> {
let controller = match backend.audio_controller.as_mut() {
Some(c) => c,
None => return Ok(()),
};
controller.set_loop_region(self.new_region);
controller.set_loop_enabled(self.new_enabled);
Ok(())
}
fn rollback_backend(
&mut self,
backend: &mut BackendContext,
_document: &Document,
) -> Result<(), String> {
let controller = match backend.audio_controller.as_mut() {
Some(c) => c,
None => return Ok(()),
};
controller.set_loop_region(self.old_region);
controller.set_loop_enabled(self.old_enabled);
Ok(())
}
}

View File

@ -7,6 +7,7 @@ use crate::action::{Action, BackendContext};
use crate::clip::ClipInstance; use crate::clip::ClipInstance;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use daw_backend::ContentTime;
use uuid::Uuid; use uuid::Uuid;
/// Action that splits a clip instance at a specific timeline position /// Action that splits a clip instance at a specific timeline position
@ -25,7 +26,7 @@ pub struct SplitClipInstanceAction {
// Stored during execute for rollback // Stored during execute for rollback
/// Original trim_end value of the left (original) instance /// Original trim_end value of the left (original) instance
original_trim_end: Option<f64>, original_trim_end: Option<ContentTime>,
/// Original timeline_duration value of the left (original) instance (beats) /// Original timeline_duration value of the left (original) instance (beats)
original_timeline_duration: Option<daw_backend::Beats>, original_timeline_duration: Option<daw_backend::Beats>,
/// ID of the new (right) instance created by the split /// ID of the new (right) instance created by the split
@ -122,13 +123,15 @@ impl Action for SplitClipInstanceAction {
.find(|ci| ci.id == self.instance_id) .find(|ci| ci.id == self.instance_id)
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
// Get the clip's duration // The clip's content duration in its OWN domain — seconds for audio/video/vector, beats for
let clip_duration = document // MIDI. All the content math below is trim-domain, so it has to be done in whichever domain
.get_clip_duration(&instance.clip_id) // this clip uses; a seconds duration would silently be added to a MIDI clip's beats trim.
let trim_duration = document
.clip_trim_duration(&instance.clip_id)
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
// Calculate the effective duration and timeline end (both in beats) // Calculate the effective duration and timeline end (both in beats)
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let effective_duration = instance.effective_duration(trim_duration, document.tempo_map());
let timeline_end = instance.timeline_start + effective_duration; let timeline_end = instance.timeline_start + effective_duration;
// Validate: split_time must be strictly within the clip's timeline span // Validate: split_time must be strictly within the clip's timeline span
@ -146,27 +149,30 @@ impl Action for SplitClipInstanceAction {
self.original_trim_end = instance.trim_end; self.original_trim_end = instance.trim_end;
self.original_timeline_duration = instance.timeline_duration; self.original_timeline_duration = instance.timeline_duration;
// Check if this is a looping clip. `content_duration` is a trim-domain
// span (seconds), so `clip_duration` must be unwrapped as seconds.
let is_looping = instance.timeline_duration.is_some(); let is_looping = instance.timeline_duration.is_some();
let content_duration = instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()) - instance.trim_start; let content_duration = ContentTime(instance.content_window(trim_duration).native());
// Timeline split point (beats). // Timeline split point (beats).
let time_into_clip = self.split_time - instance.timeline_start; let time_into_clip = self.split_time - instance.timeline_start;
let left_duration = time_into_clip; let left_duration = time_into_clip;
let right_duration = effective_duration - left_duration; let right_duration = effective_duration - left_duration;
// How far the split lands into the clip's *content* (seconds, trim domain). // How far the split lands into the clip's *content*, expressed in the content domain: beats
// content takes the beats delta directly, wall-clock content takes the seconds delta.
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let time_into_clip_secs = (tempo_map.beats_to_seconds(self.split_time) let time_into_content = ContentTime(match trim_duration {
- tempo_map.beats_to_seconds(instance.timeline_start)).seconds_to_f64(); crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(),
crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time)
- tempo_map.beats_to_seconds(instance.timeline_start))
.seconds_to_f64(),
});
// Calculate content split time (seconds) // Calculate the content split point (content domain).
let content_split_time = if is_looping { let content_split_time = if is_looping && content_duration > ContentTime::ZERO {
// For looping clips, wrap around content // For looping clips, wrap around content
instance.trim_start + (time_into_clip_secs % content_duration) instance.trim_start + (time_into_content % content_duration)
} else { } else {
instance.trim_start + time_into_clip_secs instance.trim_start + time_into_content
}; };
// Clone the instance for the right side // Clone the instance for the right side
@ -357,119 +363,63 @@ impl Action for SplitClipInstanceAction {
.get_audio_clip(&new_instance.clip_id) .get_audio_clip(&new_instance.clip_id)
.ok_or_else(|| "Audio clip not found".to_string())?; .ok_or_else(|| "Audio clip not found".to_string())?;
// Look up backend track ID from layer mapping use crate::clip::ResolvedContent;
let backend_track_id = backend if matches!(original_instance.resolve(clip), ResolvedContent::Recording) {
return Err("Cannot split a clip that is currently recording".to_string());
}
// A split is: shorten the left half's backend clip, then add the right half as a new one.
//
// 1. Trim the left (original) instance. `trim_range` tags the bounds with the clip's own
// content domain, so a MIDI clip's beats trims can't be sent as seconds.
let left_trim = clip.trim_range(
original_instance.trim_start,
original_instance
.trim_end
.unwrap_or(ContentTime(clip.content_duration().native())),
);
let new_instance = new_instance.clone();
let backend_track_id = *backend
.layer_to_track_map .layer_to_track_map
.get(&self.layer_id) .get(&self.layer_id)
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?;
let left_backend_id = backend
.clip_instance_to_backend_map
.get(&self.instance_id)
.copied();
// Get audio controller {
let controller = backend let controller = backend
.audio_controller .audio_controller
.as_mut() .as_mut()
.ok_or_else(|| "Audio controller not available".to_string())?; .ok_or_else(|| "Audio controller not available".to_string())?;
match left_backend_id {
// Handle different clip types Some(crate::action::BackendClipInstanceId::Midi(id))
use crate::clip::AudioClipType; | Some(crate::action::BackendClipInstanceId::Audio(id)) => {
match &clip.clip_type { controller.trim_clip(backend_track_id, id, left_trim);
AudioClipType::Midi { midi_clip_id } => {
use daw_backend::command::{Query, QueryResponse};
// 1. Trim the original (left) instance
let orig_internal_start = original_instance.trim_start;
let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native());
// Look up the original backend instance ID
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
backend.clip_instance_to_backend_map.get(&self.instance_id)
{
controller.trim_clip(*backend_track_id, *orig_backend_id, orig_internal_start, orig_internal_end);
} }
None => {}
// 2. Add the new (right) instance
let internal_start = new_instance.trim_start;
let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native());
let external_start = new_instance.timeline_start;
// MIDI trims are beats-domain, so the fallback span is beats too.
let external_duration = new_instance
.timeline_duration
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
let instance = daw_backend::MidiClipInstance::new(
0,
*midi_clip_id,
daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end),
external_start,
external_duration,
);
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
match controller.send_query(query)? {
QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => {
self.backend_track_id = Some(*backend_track_id);
self.backend_midi_instance_id = Some(instance_id);
backend.clip_instance_to_backend_map.insert(
new_instance_id,
crate::action::BackendClipInstanceId::Midi(instance_id),
);
Ok(())
}
QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e),
_ => Err("Unexpected query response".to_string()),
}
}
AudioClipType::Sampled { audio_pool_index } => {
// 1. Trim the original (left) instance
let orig_internal_start = original_instance.trim_start;
let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native());
// Look up the original backend instance ID
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
backend.clip_instance_to_backend_map.get(&self.instance_id)
{
controller.trim_clip(*backend_track_id, *orig_backend_id, orig_internal_start, orig_internal_end);
}
// 2. Add the new (right) instance
let internal_start = new_instance.trim_start;
let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native());
let start_time = new_instance.timeline_start;
// Fallback span is the content seconds converted to beats at the
// clip's start (not the seconds span treated as beats).
let effective_duration = new_instance.timeline_duration.unwrap_or_else(|| {
let tempo_map = document.tempo_map();
let content_secs = daw_backend::Seconds(internal_end - internal_start);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
- start_time
});
let instance_id = controller.add_audio_clip(
*backend_track_id,
*audio_pool_index,
start_time,
effective_duration,
daw_backend::Seconds(internal_start),
);
self.backend_track_id = Some(*backend_track_id);
self.backend_audio_instance_id = Some(instance_id);
backend.clip_instance_to_backend_map.insert(
new_instance_id,
crate::action::BackendClipInstanceId::Audio(instance_id),
);
Ok(())
}
AudioClipType::Recording => {
// Recording clips cannot be split
Err("Cannot split a clip that is currently recording".to_string())
} }
} }
// 2. Add the right (new) instance via the shared helper — same one AddClipInstanceAction
// uses, so the trim/duration conversions live in exactly one place.
if let Some((track_id, backend_id)) =
backend.add_clip_instance(document, &self.layer_id, &new_instance)?
{
self.backend_track_id = Some(track_id);
match backend_id {
crate::action::BackendClipInstanceId::Midi(id) => {
self.backend_midi_instance_id = Some(id)
}
crate::action::BackendClipInstanceId::Audio(id) => {
self.backend_audio_instance_id = Some(id)
}
}
}
Ok(())
} }
fn rollback_backend( fn rollback_backend(
@ -499,26 +449,28 @@ impl Action for SplitClipInstanceAction {
if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) { if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) {
if let Some(clip) = document.get_audio_clip(&instance.clip_id) { if let Some(clip) = document.get_audio_clip(&instance.clip_id) {
let orig_internal_start = instance.trim_start; let orig_internal_start = instance.trim_start;
let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native()); let orig_internal_end = self
.original_trim_end
.unwrap_or(ContentTime(clip.content_duration().native()));
// Restore based on clip type // Restore based on clip type
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { .. } => { ResolvedContent::Midi { .. } => {
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
backend.clip_instance_to_backend_map.get(&self.instance_id) backend.clip_instance_to_backend_map.get(&self.instance_id)
{ {
controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end); controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
} }
} }
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) = if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
backend.clip_instance_to_backend_map.get(&self.instance_id) backend.clip_instance_to_backend_map.get(&self.instance_id)
{ {
controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end); controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording clips - nothing to rollback // Recording clips - nothing to rollback
} }
} }
@ -554,8 +506,8 @@ mod tests {
// Create a clip instance at timeline 0, with trim 0-10 (10 seconds) // Create a clip instance at timeline 0, with trim 0-10 (10 seconds)
let mut clip_instance = ClipInstance::new(clip_id); let mut clip_instance = ClipInstance::new(clip_id);
clip_instance.timeline_start = daw_backend::Beats::ZERO; clip_instance.timeline_start = daw_backend::Beats::ZERO;
clip_instance.trim_start = 0.0; clip_instance.trim_start = ContentTime::ZERO;
clip_instance.trim_end = Some(10.0); clip_instance.trim_end = Some(ContentTime(10.0));
let instance_id = clip_instance.id; let instance_id = clip_instance.id;
vector_layer.clip_instances.push(clip_instance); vector_layer.clip_instances.push(clip_instance);
@ -575,4 +527,42 @@ mod tests {
let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0)); let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0));
assert_eq!(action.description(), "Split clip instance"); assert_eq!(action.description(), "Split clip instance");
} }
#[test]
fn splitting_a_midi_clip_stays_in_the_beats_domain() {
// Regression: `trim_start`/`trim_end` are domain-polymorphic — SECONDS for audio/video/
// vector, but BEATS for MIDI (the backend takes MIDI trims as `Beats`). Split used to map
// the split point into the clip's content in seconds unconditionally, so on a MIDI clip it
// added a seconds delta to a beats offset. At anything but 60 BPM the right half started at
// the wrong place in the content.
//
// At 120 BPM, beat 4 is 2 SECONDS in. The right half must trim to beat 4, not "4 seconds"
// (= beat 8) and not 2 (the seconds value).
let mut document = Document::new("Test");
document.set_bpm(120.0);
// 8-beat MIDI clip at the timeline origin.
let clip = crate::clip::AudioClip::new_midi("Midi", 1, daw_backend::Beats(8.0));
let clip_id = document.add_audio_clip(clip);
let mut audio_layer = crate::layer::AudioLayer::new("Layer 1");
let mut instance = ClipInstance::new(clip_id);
instance.timeline_start = daw_backend::Beats::ZERO;
instance.trim_start = ContentTime::ZERO;
instance.trim_end = Some(ContentTime(8.0)); // beats
let instance_id = instance.id;
audio_layer.clip_instances.push(instance);
let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer));
let mut action = SplitClipInstanceAction::new(layer_id, instance_id, daw_backend::Beats(4.0));
action.execute(&mut document).expect("split");
let new_id = action.new_instance_id().expect("right instance");
let AnyLayer::Audio(al) = document.get_layer(&layer_id).unwrap() else { panic!() };
let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap();
let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
assert_eq!(right.trim_start, ContentTime(4.0), "right half must start 4 BEATS into the content");
assert_eq!(left.trim_end, Some(ContentTime(4.0)), "left half must end 4 BEATS into the content");
}
} }

View File

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

View File

@ -14,7 +14,7 @@
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::layer_tree::LayerTree; use crate::layer_tree::LayerTree;
use crate::object::Transform; use crate::object::Transform;
use daw_backend::{Beats, Seconds}; use daw_backend::{Beats, ContentTime, Seconds};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use uuid::Uuid; use uuid::Uuid;
@ -130,10 +130,14 @@ impl VectorClip {
let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration { let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration {
ci.timeline_start + td_beats ci.timeline_start + td_beats
} else if let Some(te) = ci.trim_end { } else if let Some(te) = ci.trim_end {
let secs = (te - ci.trim_start).max(0.0); // `clip_duration_fn` hands back seconds, so this whole path treats nested
// content as wall-clock. That's right for the vector/video/audio clips a vector
// clip actually nests; a nested MIDI clip (beats content) would need resolving
// against its clip, which this callback can't do. Pre-existing limitation.
let secs = (te - ci.trim_start).raw().max(0.0);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
} else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) { } else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
let secs = (clip_dur_secs - ci.trim_start).max(0.0); let secs = (clip_dur_secs - ci.trim_start.raw()).max(0.0);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
} else { } else {
continue; continue;
@ -201,7 +205,9 @@ impl VectorClip {
// Convert parent clip time (seconds) to nested clip local time (seconds). // Convert parent clip time (seconds) to nested clip local time (seconds).
// timeline_start is in beats; convert to seconds using document BPM. // timeline_start is in beats; convert to seconds using document BPM.
let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; // Nested clips here are vector clips, whose content is wall-clock seconds.
let nested_clip_time =
((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
// Look up the nested clip definition // Look up the nested clip definition
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) { let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
@ -468,6 +474,25 @@ pub enum AudioClipType {
Recording, Recording,
} }
/// One take of a cycle recording — see [`ClipInstance::takes`].
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AudioTake {
/// Display name, e.g. "Take 1". User-editable.
pub name: String,
/// The recorded content this take points at.
pub content: TakeContent,
}
/// What a take actually holds. An instance's takes are all the same kind — one cycle-record session
/// captures either audio or MIDI, never a mix.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum TakeContent {
/// Sampled audio: index into the audio pool.
Audio { audio_pool_index: usize },
/// MIDI: backend MIDI clip ID.
Midi { midi_clip_id: u32 },
}
/// A clip's content duration, tagged by its native unit. /// A clip's content duration, tagged by its native unit.
/// ///
/// Sampled/recording audio and video measure content in wall-clock **seconds**; MIDI measures /// Sampled/recording audio and video measure content in wall-clock **seconds**; MIDI measures
@ -497,6 +522,17 @@ impl ClipDuration {
ClipDuration::Beats(b) => b.beats_to_f64(), ClipDuration::Beats(b) => b.beats_to_f64(),
} }
} }
/// Tag a [`ContentTime`] with *this* duration's domain.
///
/// Handy when you already hold a clip's content duration (so you know the domain) and need to
/// resolve one of its trim bounds, without going back to the clip.
pub fn same_domain(self, t: ContentTime) -> ClipDuration {
match self {
ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(t.raw())),
ClipDuration::Beats(_) => ClipDuration::Beats(Beats(t.raw())),
}
}
} }
/// Audio clip /// Audio clip
@ -532,11 +568,10 @@ impl AudioClip {
/// The clip's content duration, tagged with its native domain (seconds for sampled/recording, /// The clip's content duration, tagged with its native domain (seconds for sampled/recording,
/// beats for MIDI). This is the only sanctioned way to read the raw `duration` field. /// beats for MIDI). This is the only sanctioned way to read the raw `duration` field.
pub fn content_duration(&self) -> ClipDuration { pub fn content_duration(&self) -> ClipDuration {
match self.clip_type { if self.is_midi_domain() {
AudioClipType::Midi { .. } => ClipDuration::Beats(Beats(self.duration)), ClipDuration::Beats(Beats(self.duration))
AudioClipType::Sampled { .. } | AudioClipType::Recording => { } else {
ClipDuration::Seconds(Seconds(self.duration)) ClipDuration::Seconds(Seconds(self.duration))
}
} }
} }
@ -545,15 +580,19 @@ impl AudioClip {
pub fn set_content_duration(&mut self, duration: ClipDuration) { pub fn set_content_duration(&mut self, duration: ClipDuration) {
debug_assert!( debug_assert!(
matches!( matches!(
(&self.clip_type, duration), (self.is_midi_domain(), duration),
(AudioClipType::Midi { .. }, ClipDuration::Beats(_)) (true, ClipDuration::Beats(_)) | (false, ClipDuration::Seconds(_))
| (AudioClipType::Sampled { .. } | AudioClipType::Recording, ClipDuration::Seconds(_))
), ),
"clip duration domain must match clip type", "clip duration domain must match clip type",
); );
self.duration = duration.native(); self.duration = duration.native();
} }
/// Whether this clip's `duration` is measured in beats (MIDI) rather than seconds.
fn is_midi_domain(&self) -> bool {
matches!(self.clip_type, AudioClipType::Midi { .. })
}
/// Create a new sampled audio clip /// Create a new sampled audio clip
/// ///
/// # Arguments /// # Arguments
@ -637,6 +676,73 @@ impl AudioClip {
_ => None, _ => None,
} }
} }
/// The clip's own content, ignoring takes.
///
/// Callers that are handing content to the backend want [`ClipInstance::resolve`] instead — an
/// instance with takes overrides the clip's content with whichever take is active.
pub fn resolve(&self) -> ResolvedContent {
match &self.clip_type {
AudioClipType::Sampled { audio_pool_index } => ResolvedContent::Audio {
audio_pool_index: *audio_pool_index,
},
AudioClipType::Midi { midi_clip_id } => ResolvedContent::Midi {
midi_clip_id: *midi_clip_id,
},
AudioClipType::Recording => ResolvedContent::Recording,
}
}
/// Resolve a content time against this clip's domain.
///
/// This is the ONLY sanctioned way to turn a [`ContentTime`] into a real duration — the type has
/// no `.to_seconds()` of its own precisely so that the clip, which is the one thing that knows
/// whether its content is measured in seconds or beats, has to be consulted.
pub fn resolve_content_time(&self, t: ContentTime) -> ClipDuration {
if self.is_midi_domain() {
ClipDuration::Beats(Beats(t.raw()))
} else {
ClipDuration::Seconds(Seconds(t.raw()))
}
}
/// Tag a pair of trim bounds with this clip's content domain, ready for the backend.
///
/// Building the [`TrimRange`] from the clip means a caller can't reach for the wrong variant:
/// the clip is the one thing that knows the domain.
pub fn trim_range(&self, start: ContentTime, end: ContentTime) -> daw_backend::command::TrimRange {
if self.is_midi_domain() {
daw_backend::command::TrimRange::Beats {
start: Beats(start.raw()),
end: Beats(end.raw()),
}
} else {
daw_backend::command::TrimRange::Seconds {
start: Seconds(start.raw()),
end: Seconds(end.raw()),
}
}
}
/// Whether this clip's own content is the given audio pool index.
pub fn owns_audio_pool_index(&self, pool_index: usize) -> bool {
self.audio_pool_index() == Some(pool_index)
}
/// Whether this clip's own content is the given backend MIDI clip ID.
pub fn owns_midi_clip_id(&self, id: u32) -> bool {
self.midi_clip_id() == Some(id)
}
}
/// What a clip instance actually plays, once takes are resolved to the active one.
/// Produced by [`ClipInstance::resolve`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ResolvedContent {
Audio { audio_pool_index: usize },
Midi { midi_clip_id: u32 },
/// A recording in progress (or an empty take folder) — no backend content yet.
Recording,
} }
/// Unified clip enum for polymorphic handling /// Unified clip enum for polymorphic handling
@ -715,16 +821,17 @@ pub struct ClipInstance {
/// Default: None (use trimmed clip duration, no looping) /// Default: None (use trimmed clip duration, no looping)
pub timeline_duration: Option<Beats>, pub timeline_duration: Option<Beats>,
/// Trim start: offset into the clip's internal content, in **seconds**. /// Trim start: offset into the clip's internal content.
/// - For audio: byte-offset into the audio file ///
/// - For video: seek position in the video file /// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled
/// - For vector: time offset into the animation /// audio/video/vector but BEATS for MIDI. Resolve it against the clip
/// ([`Document::resolve_content_time`]) before combining it with anything on the timeline.
/// Default: 0.0 /// Default: 0.0
pub trim_start: f64, pub trim_start: ContentTime,
/// Trim end: offset into the clip's internal content, in **seconds**. /// Trim end: offset into the clip's internal content. See [`Self::trim_start`].
/// Default: None (use full clip duration) /// Default: None (use full clip duration)
pub trim_end: Option<f64>, pub trim_end: Option<ContentTime>,
/// Playback speed multiplier /// Playback speed multiplier
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
@ -741,6 +848,35 @@ pub struct ClipInstance {
/// Default: None (no pre-loop) /// Default: None (no pre-loop)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub loop_before: Option<Beats>, pub loop_before: Option<Beats>,
/// Alternate takes from cycle recording. Empty = an ordinary instance with no takes.
///
/// The takes live on the INSTANCE, not the clip, so managing them is per-instance: deleting or
/// renaming a take on one instance leaves every other instance alone. Splitting clones the list
/// along with the rest of the instance, so the two halves get independent take lists — and,
/// since they can each select a different take, comping still falls out for free.
///
/// Every take spans the full cycle region (partial passes are padded with silence at capture
/// time), so they're all the same length as the clip's own content. That uniformity is what lets
/// a take switch leave the instance's geometry untouched.
///
/// When non-empty, these OVERRIDE the clip's own content — see [`Self::resolve`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub takes: Vec<AudioTake>,
/// Which of [`Self::takes`] plays. `None` (or a stale index) means take 0.
/// Default: None
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_take: Option<usize>,
/// The cycle region's length in beats when these takes were recorded.
///
/// Audio takes are cut geometrically (by sample count), so they're only meaningful against the
/// tempo they were recorded at. Keeping the recorded length lets a future time-stretch/conform
/// feature reconcile them if the tempo moves underneath, and lets a new recording tell whether
/// it belongs in this take list or a fresh one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recorded_loop_beats: Option<Beats>,
} }
/// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID. /// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID.
@ -794,11 +930,14 @@ impl ClipInstance {
name: None, name: None,
timeline_start: Beats::ZERO, timeline_start: Beats::ZERO,
timeline_duration: None, timeline_duration: None,
trim_start: 0.0, trim_start: ContentTime::ZERO,
trim_end: None, trim_end: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None, loop_before: None,
takes: Vec::new(),
active_take: None,
recorded_loop_beats: None,
} }
} }
@ -812,11 +951,14 @@ impl ClipInstance {
name: None, name: None,
timeline_start: Beats::ZERO, timeline_start: Beats::ZERO,
timeline_duration: None, timeline_duration: None,
trim_start: 0.0, trim_start: ContentTime::ZERO,
trim_end: None, trim_end: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None, loop_before: None,
takes: Vec::new(),
active_take: None,
recorded_loop_beats: None,
} }
} }
@ -852,7 +994,7 @@ impl ClipInstance {
} }
/// Set trimming (start and end time within the clip's internal content) /// Set trimming (start and end time within the clip's internal content)
pub fn with_trimming(mut self, trim_start: f64, trim_end: Option<f64>) -> Self { pub fn with_trimming(mut self, trim_start: ContentTime, trim_end: Option<ContentTime>) -> Self {
self.trim_start = trim_start; self.trim_start = trim_start;
self.trim_end = trim_end; self.trim_end = trim_end;
self self
@ -876,24 +1018,89 @@ impl ClipInstance {
self self
} }
/// Content window size in seconds: `trim_end - trim_start`. /// The take this instance plays, if it has any.
///
/// A `None`/stale `active_take` falls back to take 0 — an index can go stale (an undo that
/// shrank the list, an old `.beam`), and silently playing the first take beats playing nothing.
pub fn active_take(&self) -> Option<&AudioTake> {
self.takes
.get(self.active_take.unwrap_or(0))
.or_else(|| self.takes.first())
}
/// The index [`Self::active_take`] actually resolves to, clamped into range.
pub fn active_take_index(&self) -> usize {
let i = self.active_take.unwrap_or(0);
if i < self.takes.len() { i } else { 0 }
}
/// What this instance plays: its active take if it has takes, otherwise the clip's own content.
///
/// This is the sanctioned way to ask "what content do I hand the backend for this instance?".
/// Takes are never a distinct *case* at the call site — an instance with takes is just an audio
/// or MIDI instance whose identity depends on which take is live.
pub fn resolve(&self, clip: &AudioClip) -> ResolvedContent {
match self.active_take().map(|t| &t.content) {
Some(TakeContent::Audio { audio_pool_index }) => ResolvedContent::Audio {
audio_pool_index: *audio_pool_index,
},
Some(TakeContent::Midi { midi_clip_id }) => ResolvedContent::Midi {
midi_clip_id: *midi_clip_id,
},
None => clip.resolve(),
}
}
/// The audio pool index this instance plays. See [`Self::resolve`].
pub fn resolved_audio_pool_index(&self, clip: &AudioClip) -> Option<usize> {
match self.resolve(clip) {
ResolvedContent::Audio { audio_pool_index } => Some(audio_pool_index),
_ => None,
}
}
/// The backend MIDI clip ID this instance plays. See [`Self::resolve`].
pub fn resolved_midi_clip_id(&self, clip: &AudioClip) -> Option<u32> {
match self.resolve(clip) {
ResolvedContent::Midi { midi_clip_id } => Some(midi_clip_id),
_ => None,
}
}
/// Content window (`trim_end - trim_start`) in the clip's own content domain.
/// Used for internal looping calculations. /// Used for internal looping calculations.
pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds { pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration {
let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64()); let end = self.trim_end.map_or(clip_content.native(), |t| t.raw());
Seconds((end - self.trim_start).max(0.0)) let window = (end - self.trim_start.raw()).max(0.0);
match clip_content {
ClipDuration::Beats(_) => ClipDuration::Beats(Beats(window)),
ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(window)),
}
} }
/// How long this instance appears on the timeline, in **beats**. /// How long this instance appears on the timeline, in **beats**.
/// ///
/// If `timeline_duration` is set, returns that (enabling content looping). /// If `timeline_duration` is set, returns that (enabling content looping). Otherwise the clip
/// Otherwise converts the content window from seconds to beats using the tempo map. /// occupies its content window — converted to beats *in the clip's own domain*:
pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { ///
/// - MIDI content is already beats and is tempo-invariant, so it carries over directly.
/// - Wall-clock content (audio/video/vector) is a seconds span, so it converts at the clip's
/// position on the timeline.
///
/// Taking a `ClipDuration` rather than a bare `Seconds` is what keeps those apart: this used to
/// take seconds and subtract `trim_start` from it, which for a TRIMMED MIDI clip subtracted a
/// beats offset from a seconds duration and got the clip's length wrong.
pub fn effective_duration_beats(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
if let Some(td) = self.timeline_duration { if let Some(td) = self.timeline_duration {
return td; return td;
} }
let window = self.content_window_secs(clip_duration_secs); match self.content_window(clip_content) {
let start_secs = tempo_map.beats_to_seconds(self.timeline_start); ClipDuration::Beats(b) => b,
tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start ClipDuration::Seconds(s) => {
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
tempo_map.seconds_to_beats(start_secs + s) - self.timeline_start
}
}
} }
/// Left edge of the clip's visual extent on the timeline, in **beats**. /// Left edge of the clip's visual extent on the timeline, in **beats**.
@ -902,27 +1109,32 @@ impl ClipInstance {
} }
/// Total visual duration (loop_before + effective_duration), in **beats**. /// Total visual duration (loop_before + effective_duration), in **beats**.
pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { pub fn total_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map) self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_content, tempo_map)
} }
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**). /// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
/// ///
/// Returns `None` if the clip instance is not active at `time_secs`. /// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats
pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> { /// trims are converted rather than read as seconds. Callers are the wall-clock consumers (video
/// seek, vector/raster rendering), which want seconds regardless of how the clip stores content.
///
/// Returns `None` if the clip instance is not active at `time`.
pub fn remap_time_secs(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
let start_secs = tempo_map.beats_to_seconds(self.timeline_start); let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map); let dur_beats = self.effective_duration_beats(clip_content, tempo_map);
let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats); let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats);
if time < start_secs || time >= end_secs { if time < start_secs || time >= end_secs {
return None; return None;
} }
let trim_start_secs = clip_content.same_domain(self.trim_start).to_seconds(tempo_map);
let content_time = (time - start_secs) * self.playback_speed; let content_time = (time - start_secs) * self.playback_speed;
let content_window = self.content_window_secs(clip_duration_secs); let content_window = self.content_window(clip_content).to_seconds(tempo_map);
if content_window == Seconds::ZERO { if content_window == Seconds::ZERO {
return Some(Seconds(self.trim_start)); return Some(trim_start_secs);
} }
let looped = if content_time > content_window { let looped = if content_time > content_window {
@ -931,19 +1143,19 @@ impl ClipInstance {
content_time content_time
}; };
Some(Seconds(self.trim_start) + looped) Some(trim_start_secs + looped)
} }
/// Alias for `remap_time_secs`. /// Alias for `remap_time_secs`.
#[inline] #[inline]
pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> { pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
self.remap_time_secs(time, clip_duration_secs, tempo_map) self.remap_time_secs(time, clip_content, tempo_map)
} }
/// Alias for `effective_duration_beats`. /// Alias for `effective_duration_beats`.
#[inline] #[inline]
pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
self.effective_duration_beats(clip_duration_secs, tempo_map) self.effective_duration_beats(clip_content, tempo_map)
} }
/// Convert to affine transform /// Convert to affine transform
@ -1020,7 +1232,7 @@ mod tests {
assert_eq!(instance.clip_id, clip_id); assert_eq!(instance.clip_id, clip_id);
assert_eq!(instance.opacity, 1.0); assert_eq!(instance.opacity, 1.0);
assert_eq!(instance.timeline_start, Beats::ZERO); assert_eq!(instance.timeline_start, Beats::ZERO);
assert_eq!(instance.trim_start, 0.0); assert_eq!(instance.trim_start, ContentTime::ZERO);
assert_eq!(instance.trim_end, None); assert_eq!(instance.trim_end, None);
assert_eq!(instance.playback_speed, 1.0); assert_eq!(instance.playback_speed, 1.0);
assert_eq!(instance.gain, 1.0); assert_eq!(instance.gain, 1.0);
@ -1030,27 +1242,52 @@ mod tests {
fn test_clip_instance_trimming() { fn test_clip_instance_trimming() {
let clip_id = Uuid::new_v4(); let clip_id = Uuid::new_v4();
let instance = ClipInstance::new(clip_id) let instance = ClipInstance::new(clip_id)
.with_trimming(2.0, Some(8.0)); .with_trimming(ContentTime(2.0), Some(ContentTime(8.0)));
assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_start, ContentTime(2.0));
assert_eq!(instance.trim_end, Some(8.0)); assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the // At 60 BPM the tempo map is identity (1 beat == 1 second), so the
// beats-domain effective duration equals the seconds content window. // beats-domain effective duration equals the seconds content window.
let tempo_map = crate::tempo_map::TempoMap::constant(60.0); let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(6.0)); let content = ClipDuration::Seconds(Seconds(10.0));
assert_eq!(instance.effective_duration(content, &tempo_map), Beats(6.0));
} }
#[test] #[test]
fn test_clip_instance_no_end_trim() { fn test_clip_instance_no_end_trim() {
let clip_id = Uuid::new_v4(); let clip_id = Uuid::new_v4();
let instance = ClipInstance::new(clip_id) let instance = ClipInstance::new(clip_id)
.with_trimming(2.0, None); .with_trimming(ContentTime(2.0), None);
assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_start, ContentTime(2.0));
assert_eq!(instance.trim_end, None); assert_eq!(instance.trim_end, None);
// At 60 BPM the tempo map is identity (1 beat == 1 second). // At 60 BPM the tempo map is identity (1 beat == 1 second).
let tempo_map = crate::tempo_map::TempoMap::constant(60.0); let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(8.0)); let content = ClipDuration::Seconds(Seconds(10.0));
assert_eq!(instance.effective_duration(content, &tempo_map), Beats(8.0));
}
#[test]
fn trimmed_midi_clip_keeps_its_beats_length_across_tempo() {
// Regression: `effective_duration_beats` used to take a SECONDS clip duration and subtract
// `trim_start` from it. For a TRIMMED MIDI clip that subtracted a beats offset from a
// seconds duration, so the clip's timeline length came out wrong at any tempo but 60 BPM.
//
// MIDI content is beats and tempo-invariant: a clip trimmed to beats 2..6 is 4 beats long
// whatever the tempo says.
let clip_id = Uuid::new_v4();
let instance = ClipInstance::new(clip_id)
.with_trimming(ContentTime(2.0), Some(ContentTime(6.0)));
let content = ClipDuration::Beats(Beats(8.0));
for bpm in [60.0, 120.0, 90.0] {
let tempo_map = crate::tempo_map::TempoMap::constant(bpm);
assert_eq!(
instance.effective_duration(content, &tempo_map),
Beats(4.0),
"a MIDI clip trimmed to beats 2..6 is 4 beats long at {bpm} BPM",
);
}
} }
#[test] #[test]
@ -1070,4 +1307,109 @@ mod tests {
assert_eq!(instance.playback_speed, 2.0); assert_eq!(instance.playback_speed, 2.0);
assert_eq!(instance.gain, 0.8); assert_eq!(instance.gain, 0.8);
} }
/// A clip whose own content is pool 10, plus an instance carrying takes over the given pools.
fn with_takes(pool_indices: &[usize]) -> (AudioClip, ClipInstance) {
let clip = AudioClip::new_sampled("Cycle rec", pool_indices[0], 2.0);
let mut instance = ClipInstance::new(clip.id);
instance.takes = pool_indices
.iter()
.enumerate()
.map(|(i, &audio_pool_index)| AudioTake {
name: format!("Take {}", i + 1),
content: TakeContent::Audio { audio_pool_index },
})
.collect();
instance.recorded_loop_beats = Some(Beats(8.0));
(clip, instance)
}
#[test]
fn active_take_selects_the_pool_file() {
let (clip, mut instance) = with_takes(&[10, 11, 12]);
instance.active_take = Some(0);
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10));
instance.active_take = Some(2);
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(12));
// None means take 0.
instance.active_take = None;
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10));
}
#[test]
fn out_of_range_take_falls_back_to_the_first() {
// An index can go stale (an undo that shrank the list, an old .beam). Playing the first take
// beats playing nothing.
let (clip, mut instance) = with_takes(&[10, 11]);
instance.active_take = Some(99);
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10));
assert_eq!(instance.active_take_index(), 0);
}
#[test]
fn an_instance_without_takes_plays_the_clips_own_content() {
let clip = AudioClip::new_sampled("Plain", 42, 2.0);
let instance = ClipInstance::new(clip.id);
assert!(instance.takes.is_empty());
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(42));
}
#[test]
fn midi_takes_resolve_to_midi_content() {
let clip = AudioClip::new_midi("Cycle rec", 7, Beats(4.0));
let mut instance = ClipInstance::new(clip.id);
instance.takes = vec![AudioTake {
name: "Take 1".into(),
content: TakeContent::Midi { midi_clip_id: 7 },
}];
assert_eq!(clip.content_duration(), ClipDuration::Beats(Beats(4.0)));
assert_eq!(instance.resolved_midi_clip_id(&clip), Some(7));
assert_eq!(instance.resolved_audio_pool_index(&clip), None);
}
#[test]
fn splitting_gives_each_half_an_independent_take_list() {
// Takes live on the INSTANCE, so a split (which clones the instance) hands each half its own
// list. Two consequences, both wanted: the halves can select different takes (comping), and
// deleting a take from one leaves the other alone.
let (clip, left_src) = with_takes(&[10, 11, 12]);
let mut left = left_src.clone();
let mut right = left_src.clone();
right.id = Uuid::new_v4();
left.active_take = Some(0);
right.active_take = Some(2);
assert_eq!(left.resolved_audio_pool_index(&clip), Some(10));
assert_eq!(right.resolved_audio_pool_index(&clip), Some(12));
// Delete take 2 (pool 11) from the left half only.
left.takes.remove(1);
assert_eq!(left.takes.len(), 2);
assert_eq!(right.takes.len(), 3, "the other half keeps its own takes");
assert_eq!(
right.resolved_audio_pool_index(&clip),
Some(12),
"and its selection still points where it did",
);
}
#[test]
fn clip_instance_without_active_take_deserializes() {
// Back-compat: .beam files written before take folders have no `active_take` field.
let json = r#"{
"id": "550e8400-e29b-41d4-a716-446655440000",
"clip_id": "550e8400-e29b-41d4-a716-446655440001",
"transform": {"x": 0.0, "y": 0.0, "rotation": 0.0, "scale_x": 1.0, "scale_y": 1.0, "skew_x": 0.0, "skew_y": 0.0},
"opacity": 1.0,
"name": null,
"timeline_start": 0.0,
"timeline_duration": null,
"trim_start": 0.0,
"trim_end": null,
"playback_speed": 1.0,
"gain": 1.0
}"#;
let instance: ClipInstance = serde_json::from_str(json).expect("old instances must load");
assert_eq!(instance.active_take, None);
}
} }

View File

@ -201,6 +201,17 @@ pub struct Document {
#[serde(default)] #[serde(default)]
pub time_signature: TimeSignature, pub time_signature: TimeSignature,
/// Transport cycle (loop) region, as `(start, end)` in **beats**.
///
/// Authored in beats so it stays put musically when the tempo changes. `None` = no region set.
/// Saved with the project; `#[serde(default)]` keeps older `.beam` files loading.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cycle_region: Option<(Beats, Beats)>,
/// Whether the transport loops over `cycle_region`.
#[serde(default)]
pub cycle_enabled: bool,
/// Master track (master bus + tempo automation lane). /// Master track (master bus + tempo automation lane).
/// Stored separately from the root layer tree; shown in timeline when /// Stored separately from the root layer tree; shown in timeline when
/// `show_master_track` is enabled in the editor state. /// `show_master_track` is enabled in the editor state.
@ -297,6 +308,8 @@ impl Default for Document {
height: 1080.0, height: 1080.0,
framerate: 60.0, framerate: 60.0,
time_signature: TimeSignature::default(), time_signature: TimeSignature::default(),
cycle_region: None,
cycle_enabled: false,
master_layer: { master_layer: {
let mut ml = GroupLayer::new_master(120.0); let mut ml = GroupLayer::new_master(120.0);
ml.layer.id = uuid::Uuid::new_v4(); ml.layer.id = uuid::Uuid::new_v4();
@ -451,8 +464,11 @@ impl Document {
let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration { let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration {
instance.timeline_start + timeline_duration instance.timeline_start + timeline_duration
} else { } else {
let trim_end = instance.trim_end.unwrap_or(clip_duration); // `clip_duration` arrives as seconds (the recursive helper's signature), so this
let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0); // path is the wall-clock one; MIDI content would need resolving against its clip.
let trim_end = instance.trim_end.map_or(clip_duration, |t| t.raw());
let trimmed_secs =
((trim_end - instance.trim_start.raw()) / instance.playback_speed).max(0.0);
let start_secs = tempo_map.beats_to_seconds(instance.timeline_start); let start_secs = tempo_map.beats_to_seconds(instance.timeline_start);
tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs)) tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs))
}; };
@ -767,16 +783,18 @@ impl Document {
} }
/// Find the document audio clip (UUID + ref) that owns the given backend pool index. /// Find the document audio clip (UUID + ref) that owns the given backend pool index.
/// A take folder owns one pool file per take, so any of them maps back to the folder.
pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> { pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> {
self.audio_clips.iter() self.audio_clips.iter()
.find(|(_, c)| c.audio_pool_index() == Some(pool_index)) .find(|(_, c)| c.owns_audio_pool_index(pool_index))
.map(|(&id, c)| (id, c)) .map(|(&id, c)| (id, c))
} }
/// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID. /// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID.
/// As above, a take folder owns one MIDI clip per take.
pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> { pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> {
self.audio_clips.iter() self.audio_clips.iter()
.find(|(_, c)| c.midi_clip_id() == Some(midi_clip_id)) .find(|(_, c)| c.owns_midi_clip_id(midi_clip_id))
.map(|(&id, c)| (id, c)) .map(|(&id, c)| (id, c))
} }
@ -898,6 +916,81 @@ impl Document {
/// Searches through all clip libraries to find the clip and return its duration. /// Searches through all clip libraries to find the clip and return its duration.
/// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects /// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects
/// have infinite internal duration. /// have infinite internal duration.
/// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**.
///
/// Content time is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for sampled
/// audio, video and vector, but BEATS for MIDI. Anything doing arithmetic against a trim value —
/// mapping a timeline position into the clip's content, say — has to work in that same domain,
/// and [`Self::get_clip_duration`] can't tell it which: that one always converts to seconds.
///
/// Returns `None` for unknown clips.
pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option<crate::clip::ClipDuration> {
if let Some(clip) = self.audio_clips.get(clip_id) {
return Some(clip.content_duration());
}
// Everything else measures its content in wall-clock seconds.
self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds)
}
/// Find an existing take folder on `layer_id` that a new cycle recording should be appended to.
///
/// Cycle-recording over a region that already holds a take folder should ADD to that folder
/// rather than drop a second clip on top of it — otherwise the new takes are stranded in a
/// separate, overlapping clip and can't be auditioned against the ones already there.
///
/// "The same region" means the instance starts at `loop_start` and its folder was recorded
/// against the same loop length. Matching the length too means resizing the cycle region starts
/// a fresh folder rather than appending takes of a different length to an existing one, which
/// would break the uniform-take invariant comping depends on.
///
/// `exclude` is the in-progress recording's own instance, which is on the layer but isn't a
/// candidate. Returns the instance id.
pub fn take_folder_at(
&self,
layer_id: &Uuid,
loop_start: Beats,
loop_len: Beats,
exclude: &Uuid,
) -> Option<Uuid> {
let Some(AnyLayer::Audio(audio_layer)) = self.get_layer(layer_id) else {
return None;
};
const EPS: f64 = 1e-6;
audio_layer.clip_instances.iter().find_map(|ci| {
if ci.id == *exclude
|| ci.takes.is_empty()
|| (ci.timeline_start - loop_start).beats_to_f64().abs() > EPS
{
return None;
}
let recorded = ci.recorded_loop_beats?;
((recorded - loop_len).beats_to_f64().abs() < EPS).then_some(ci.id)
})
}
/// Resolve a [`ContentTime`] (a trim bound) against the clip it belongs to.
///
/// The clip is the only thing that knows whether its content is measured in seconds or beats, so
/// this is the sanctioned exit from `ContentTime`. Works for every clip kind, not just audio.
/// Returns `None` for unknown clips.
pub fn resolve_content_time(
&self,
clip_id: &Uuid,
t: daw_backend::ContentTime,
) -> Option<crate::clip::ClipDuration> {
if let Some(clip) = self.audio_clips.get(clip_id) {
return Some(clip.resolve_content_time(t));
}
if self.vector_clips.contains_key(clip_id)
|| self.video_clips.contains_key(clip_id)
|| self.effect_definitions.contains_key(clip_id)
{
// Wall-clock content.
return Some(crate::clip::ClipDuration::Seconds(Seconds(t.raw())));
}
None
}
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> { pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
if let Some(clip) = self.vector_clips.get(clip_id) { if let Some(clip) = self.vector_clips.get(clip_id) {
if clip.is_group { if clip.is_group {
@ -951,9 +1044,9 @@ impl Document {
}; };
let instance = instances.iter().find(|inst| &inst.id == instance_id)?; let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
let clip_duration = self.get_clip_duration(&instance.clip_id)?; // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
// End position on the timeline, in beats (convert the seconds content window via tempo map). let clip_content = self.clip_trim_duration(&instance.clip_id)?;
Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map())) Some(instance.timeline_start + instance.effective_duration_beats(clip_content, self.tempo_map()))
} }
/// Check if a time range overlaps with any existing clip on the layer /// Check if a time range overlaps with any existing clip on the layer
@ -993,13 +1086,14 @@ impl Document {
continue; continue;
} }
// Calculate instance extent (accounting for loop_before) // Calculate instance extent (accounting for loop_before). Content duration in the clip's
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { // own domain, so the trims resolve correctly for MIDI.
let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else {
continue; continue;
}; };
let instance_start = instance.effective_start(); let instance_start = instance.effective_start();
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map()); let instance_end = instance.timeline_start + instance.effective_duration(clip_content, self.tempo_map());
// Check overlap: start_a < end_b AND start_b < end_a // Check overlap: start_a < end_b AND start_b < end_a
if start_time < instance_end && instance_start < end_time { if start_time < instance_end && instance_start < end_time {
@ -1056,7 +1150,7 @@ impl Document {
continue; continue;
} }
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) { if let Some(clip_dur) = self.clip_trim_duration(&instance.clip_id) {
let inst_start = instance.effective_start(); let inst_start = instance.effective_start();
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map()); let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
occupied_ranges.push((inst_start, inst_end, instance.id)); occupied_ranges.push((inst_start, inst_end, instance.id));
@ -1152,7 +1246,7 @@ impl Document {
if group_ids.contains(&inst.id) { if group_ids.contains(&inst.id) {
continue; continue;
} }
if let Some(dur) = self.get_clip_duration(&inst.clip_id) { if let Some(dur) = self.clip_trim_duration(&inst.clip_id) {
let start = inst.effective_start(); let start = inst.effective_start();
let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map()); let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
non_group.push((start, end)); non_group.push((start, end));
@ -1226,9 +1320,8 @@ impl Document {
} }
// Calculate other clip's extent (accounting for loop_before) // Calculate other clip's extent (accounting for loop_before)
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) {
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
// (clip_duration is Seconds via get_clip_duration; effective_duration converts.)
// If this clip is to the left and closer than current nearest // If this clip is to the left and closer than current nearest
if other_end <= current_timeline_start && other_end > nearest_end { if other_end <= current_timeline_start && other_end > nearest_end {
@ -1326,7 +1419,7 @@ impl Document {
continue; continue;
} }
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) {
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
if other_end <= current_effective_start && other_end > nearest_end { if other_end <= current_effective_start && other_end > nearest_end {

View File

@ -152,7 +152,12 @@ impl EffectLayer {
self.clip_instances self.clip_instances
.iter() .iter()
.filter(|e| { .filter(|e| {
let end = e.timeline_start + e.effective_duration(daw_backend::Seconds(EFFECT_DURATION), tempo_map); // Effects have an "infinite" wall-clock content length.
let end = e.timeline_start
+ e.effective_duration(
crate::clip::ClipDuration::Seconds(daw_backend::Seconds(EFFECT_DURATION)),
tempo_map,
);
time_beats >= e.timeline_start && time_beats < end time_beats >= e.timeline_start && time_beats < end
}) })
.collect() .collect()

View File

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

View File

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

View File

@ -35,6 +35,18 @@ pub struct AppConfig {
#[serde(default = "defaults::audio_buffer_size")] #[serde(default = "defaults::audio_buffer_size")]
pub audio_buffer_size: u32, pub audio_buffer_size: u32,
/// How a cycle MIDI recording treats its passes.
///
/// `false` (default) = **merge**: every pass overdubs into one clip, and earlier passes play back
/// as you record so you can layer against them. `true` = **separate takes**: each pass becomes
/// its own take in a take folder, exactly as audio always does, and earlier passes stay silent
/// (they're alternatives, not layers).
///
/// Only applies when the transport actually wraps; a recording that stops inside the first pass
/// is an ordinary single recording either way.
#[serde(default = "defaults::cycle_midi_separate_takes")]
pub cycle_midi_separate_takes: bool,
/// Reopen last session on startup /// Reopen last session on startup
#[serde(default = "defaults::reopen_last_session")] #[serde(default = "defaults::reopen_last_session")]
pub reopen_last_session: bool, pub reopen_last_session: bool,
@ -90,6 +102,7 @@ impl Default for AppConfig {
file_height: defaults::file_height(), file_height: defaults::file_height(),
scroll_speed: defaults::scroll_speed(), scroll_speed: defaults::scroll_speed(),
audio_buffer_size: defaults::audio_buffer_size(), audio_buffer_size: defaults::audio_buffer_size(),
cycle_midi_separate_takes: defaults::cycle_midi_separate_takes(),
reopen_last_session: defaults::reopen_last_session(), reopen_last_session: defaults::reopen_last_session(),
restore_layout_from_file: defaults::restore_layout_from_file(), restore_layout_from_file: defaults::restore_layout_from_file(),
debug: defaults::debug(), debug: defaults::debug(),
@ -296,6 +309,7 @@ mod defaults {
pub fn file_height() -> u32 { 600 } pub fn file_height() -> u32 { 600 }
pub fn scroll_speed() -> f64 { 1.0 } pub fn scroll_speed() -> f64 { 1.0 }
pub fn audio_buffer_size() -> u32 { 256 } pub fn audio_buffer_size() -> u32 { 256 }
pub fn cycle_midi_separate_takes() -> bool { false }
pub fn reopen_last_session() -> bool { false } pub fn reopen_last_session() -> bool { false }
pub fn restore_layout_from_file() -> bool { true } pub fn restore_layout_from_file() -> bool { true }
pub fn debug() -> bool { false } pub fn debug() -> bool { false }

View File

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

View File

@ -1105,6 +1105,23 @@ impl EditingContext {
} }
} }
/// A finished cycle recording whose takes belong to a take folder that already exists at that
/// region, queued for [`EditorApp::append_cycle_takes`].
struct PendingTakeAppend {
layer_id: Uuid,
/// The throwaway clip + instance the recording itself was captured into.
recording_instance_id: Uuid,
recording_clip_id: Uuid,
/// The recording's backend clip, which has to be torn down or it keeps playing alongside the
/// take folder we're appending to. Carried explicitly rather than looked up in
/// `clip_instance_to_backend_map`, because on the audio path the recording instance isn't in
/// that map yet — it's only added after promotion, which the append path skips.
recording_backend_id: Option<lightningbeam_core::action::BackendClipInstanceId>,
loop_start: Beats,
loop_len: Beats,
takes: Vec<lightningbeam_core::clip::AudioTake>,
}
struct EditorApp { struct EditorApp {
layouts: Vec<LayoutDefinition>, layouts: Vec<LayoutDefinition>,
current_layout_index: usize, current_layout_index: usize,
@ -1213,6 +1230,10 @@ struct EditorApp {
metronome_enabled: bool, // Whether metronome clicks during recording metronome_enabled: bool, // Whether metronome clicks during recording
count_in_enabled: bool, // Whether count-in fires before recording count_in_enabled: bool, // Whether count-in fires before recording
recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during recording recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during recording
/// Cycle takes waiting to be folded into an existing take folder. Queued from the audio-event
/// loop (which holds a borrow on the event queue, so it can't call a `&mut self` method) and
/// drained just after it.
pending_take_appends: Vec<PendingTakeAppend>,
recording_start_time: f64, // Playback time when recording started recording_start_time: f64, // Playback time when recording started
recording_layer_ids: Vec<Uuid>, // Layers being recorded to (for creating clips) recording_layer_ids: Vec<Uuid>, // Layers being recorded to (for creating clips)
// Asset drag-and-drop state // Asset drag-and-drop state
@ -1581,6 +1602,7 @@ impl EditorApp {
metronome_enabled: false, // Metronome off by default metronome_enabled: false, // Metronome off by default
count_in_enabled: false, // Count-in off by default count_in_enabled: false, // Count-in off by default
recording_clips: HashMap::new(), // No active recording clips recording_clips: HashMap::new(), // No active recording clips
pending_take_appends: Vec::new(),
recording_start_time: 0.0, // Will be set when recording starts recording_start_time: 0.0, // Will be set when recording starts
recording_layer_ids: Vec::new(), // Will be populated when recording starts recording_layer_ids: Vec::new(), // Will be populated when recording starts
dragging_asset: None, // No asset being dragged initially dragging_asset: None, // No asset being dragged initially
@ -2036,9 +2058,113 @@ impl EditorApp {
/// 2. For MIDI: Loads the default instrument /// 2. For MIDI: Loads the default instrument
/// 3. Stores the bidirectional mapping /// 3. Stores the bidirectional mapping
/// 4. Syncs any existing clips on the layer /// 4. Syncs any existing clips on the layer
/// Fold freshly-recorded cycle takes into an existing take folder covering the same region, if
/// there is one. Returns true if it did.
///
/// Recording more takes over a region you've already recorded should extend that folder, not
/// stack a second clip on top of it — otherwise the new takes are stranded in an overlapping
/// clip and you can't audition them against the ones already there.
///
/// The in-progress recording's own clip and instance are throwaway scaffolding in this case (the
/// takes themselves already live in the backend pools), so they're discarded. They were never
/// committed as an action, so there's nothing on the undo stack to unwind — the single
/// `AppendTakesAction` is the whole undoable step.
fn append_cycle_takes(
&mut self,
layer_id: uuid::Uuid,
recording_instance_id: uuid::Uuid,
recording_clip_id: uuid::Uuid,
recording_backend_id: Option<lightningbeam_core::action::BackendClipInstanceId>,
loop_start: Beats,
loop_len: Beats,
takes: Vec<lightningbeam_core::clip::AudioTake>,
) -> bool {
let Some(target_instance_id) = self.action_executor.document().take_folder_at(
&layer_id,
loop_start,
loop_len,
&recording_instance_id,
) else {
return false;
};
// Tear down the recording's own backend clip. Without this it keeps playing on top of the
// take folder we're appending to — two takes sounding at once. The target instance's clip is
// separately repointed at the new active take by the action's backend sync below.
let backend_id = recording_backend_id
.or_else(|| self.clip_instance_to_backend_map.remove(&recording_instance_id));
self.clip_instance_to_backend_map.remove(&recording_instance_id);
let track_id = self.layer_to_track_map.get(&layer_id).copied();
if let (Some(backend_id), Some(track_id), Some(controller_arc)) =
(backend_id, track_id, self.audio_controller.as_ref())
{
let mut controller = controller_arc.lock().unwrap();
match backend_id {
lightningbeam_core::action::BackendClipInstanceId::Audio(id) => {
controller.remove_audio_clip(track_id, id)
}
lightningbeam_core::action::BackendClipInstanceId::Midi(id) => {
controller.remove_midi_clip(track_id, id)
}
}
}
// Discard the scaffolding clip + instance.
{
let doc = self.action_executor.document_mut();
if let Some(AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) {
al.clip_instances.retain(|ci| ci.id != recording_instance_id);
}
doc.audio_clips.remove(&recording_clip_id);
}
let action = lightningbeam_core::actions::AppendTakesAction::new(
layer_id,
target_instance_id,
takes,
);
if let Some(controller_arc) = self.audio_controller.clone() {
let mut controller = controller_arc.lock().unwrap();
let mut backend_context = lightningbeam_core::action::BackendContext {
audio_controller: Some(&mut *controller),
layer_to_track_map: &self.layer_to_track_map,
clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map,
};
if let Err(e) = self
.action_executor
.execute_with_backend(Box::new(action), &mut backend_context)
{
eprintln!("Failed to append cycle takes: {}", e);
}
}
self.autosave.pending_event = true;
true
}
fn sync_audio_layers_to_backend(&mut self) { fn sync_audio_layers_to_backend(&mut self) {
use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
// Push the document's cycle region to the engine. Needed on load/new: the region is
// document state, but the engine starts blank (and is cleared on Reset), so without this a
// loaded project would show its cycle strip while the transport never actually looped.
// Changes made later go through SetCycleRegionAction's execute_backend.
{
let (region, enabled) = {
let doc = self.action_executor.document();
(doc.cycle_region, doc.cycle_enabled)
};
if let Some(ref controller_arc) = self.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.set_loop_region(region);
controller.set_loop_enabled(enabled);
// Cycle MIDI mode is a *preference*, not document state, so it rides along here
// rather than through an action.
controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes);
}
}
// Ensure the master layer has a backend group track. // Ensure the master layer has a backend group track.
let master_layer_id = self.action_executor.document().master_layer.layer.id; let master_layer_id = self.action_executor.document().master_layer.layer.id;
if !self.layer_to_track_map.contains_key(&master_layer_id) { if !self.layer_to_track_map.contains_key(&master_layer_id) {
@ -2371,7 +2497,7 @@ impl EditorApp {
) -> Vec<uuid::Uuid> { ) -> Vec<uuid::Uuid> {
let mut result = Vec::new(); let mut result = Vec::new();
for instance in clip_instances { for instance in clip_instances {
if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) { if let Some(clip_duration) = document.clip_trim_duration(&instance.clip_id) {
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
let timeline_end = instance.timeline_start + effective_duration; let timeline_end = instance.timeline_start + effective_duration;
@ -3260,7 +3386,9 @@ impl EditorApp {
let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| { let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| {
let mut duplicate = original.clone(); let mut duplicate = original.clone();
duplicate.id = uuid::Uuid::new_v4(); duplicate.id = uuid::Uuid::new_v4();
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(Seconds(1.0)); let clip_duration = document
.clip_trim_duration(&original.clip_id)
.unwrap_or(ClipDuration::Seconds(Seconds(1.0)));
let effective_duration = original.effective_duration(clip_duration, document.tempo_map()); let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
duplicate.timeline_start = original.timeline_start + effective_duration; duplicate.timeline_start = original.timeline_start + effective_duration;
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) { if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
@ -5461,7 +5589,7 @@ impl EditorApp {
// matches the video clip exactly). // matches the video clip exactly).
let (_dur, sample_rate, channels) = controller let (_dur, sample_rate, channels) = controller
.get_pool_file_info(pool_index) .get_pool_file_info(pool_index)
.unwrap_or((video_duration, 0, 0)); .unwrap_or((Seconds(video_duration), 0, 0));
drop(controller); drop(controller);
let audio_clip_name = format!("{} (Audio)", video_name); let audio_clip_name = format!("{} (Audio)", video_name);
@ -6368,7 +6496,9 @@ impl eframe::App for EditorApp {
use daw_backend::AudioEvent; use daw_backend::AudioEvent;
match event { match event {
AudioEvent::PlaybackPosition(time) => { AudioEvent::PlaybackPosition(time) => {
self.playback_time = time; // `playback_time` is the UI's seconds playhead (see the timeline's
// seconds/beats model); unwrap at this boundary, not before it.
self.playback_time = time.seconds_to_f64();
} }
AudioEvent::PlaybackStopped => { AudioEvent::PlaybackStopped => {
self.is_playing = false; self.is_playing = false;
@ -6474,6 +6604,25 @@ impl eframe::App for EditorApp {
}) })
}; };
// While cycling, the recording keeps running but the clip only ever
// occupies ONE region — each further pass is a new take layered on
// the same span, not more length. Cap the preview there so the bar
// doesn't grow off past the loop end while the playhead wraps.
let cycle_cap = {
let doc = self.action_executor.document();
match (doc.cycle_enabled, doc.cycle_region) {
(true, Some((ls, le))) if le > ls => {
let tm = doc.tempo_map();
Some(tm.beats_to_seconds(le) - tm.beats_to_seconds(ls))
}
_ => None,
}
};
let duration = match cycle_cap {
Some(cap) if duration > cap => cap,
_ => duration,
};
// Then update the clip duration (mutable borrow) // Then update the clip duration (mutable borrow)
if let Some(doc_clip_id) = doc_clip_id { if let Some(doc_clip_id) = doc_clip_id {
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
@ -6485,6 +6634,158 @@ impl eframe::App for EditorApp {
} }
ctx.request_repaint(); ctx.request_repaint();
} }
AudioEvent::CycleRecordingStopped { clip_id: backend_clip_id, takes, loop_start, loop_len_beats, loop_len_seconds } => {
eprintln!("[STOP] CycleRecordingStopped: {} takes", takes.len());
// Clean up the live-recording waveform cache (keyed usize::MAX).
self.raw_audio_cache.remove(&usize::MAX);
self.waveform_gpu_dirty.remove(&usize::MAX);
// Pull every take's samples in for waveform rendering — the user can
// switch to any of them, not just the active one.
if let Some(ref controller_arc) = self.audio_controller {
let mut controller = controller_arc.lock().unwrap();
for &(pool_index, _) in &takes {
match controller.get_pool_audio_samples(pool_index) {
Ok((samples, sr, ch)) => {
self.raw_audio_cache.insert(pool_index, (Arc::new(samples), sr, ch));
self.waveform_gpu_dirty.insert(pool_index);
self.audio_pools_with_new_waveforms.insert(pool_index);
}
Err(e) => eprintln!("Failed to fetch take audio: {}", e),
}
self.audio_duration_cache.insert(pool_index, loop_len_seconds.seconds_to_f64());
}
}
let recording_layer = self.recording_clips.iter()
.find(|(_, &cid)| cid == backend_clip_id)
.map(|(&lid, _)| lid);
if let (Some(layer_id), false) = (recording_layer, takes.is_empty()) {
let (clip_id, instance_id) = {
let document = self.action_executor.document();
document.get_layer(&layer_id)
.and_then(|layer| {
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer {
audio_layer.clip_instances.last().map(|i| (i.clip_id, i.id))
} else {
None
}
})
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil()))
};
if !clip_id.is_nil() {
self.autosave.pending_event = true;
let last_take = takes.len() - 1;
let new_takes: Vec<lightningbeam_core::clip::AudioTake> = takes
.iter()
.map(|&(pool_index, _)| lightningbeam_core::clip::AudioTake {
// Renumbered by the folder that ends up owning them.
name: String::new(),
content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index },
})
.collect();
// If this region already holds a take folder, extend it rather
// than dropping a second clip on top. Queued and run just after
// this loop, which holds a borrow on the event queue.
if self.action_executor.document()
.take_folder_at(&layer_id, loop_start, loop_len_beats, &instance_id)
.is_some()
{
self.pending_take_appends.push(PendingTakeAppend {
layer_id,
recording_instance_id: instance_id,
recording_clip_id: clip_id,
// The engine's recording clip. It isn't in
// clip_instance_to_backend_map yet (that only happens on
// promotion, which we're skipping), so hand it over
// directly or it'll keep sounding alongside the folder.
recording_backend_id: Some(
lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id),
),
loop_start,
loop_len: loop_len_beats,
takes: new_takes,
});
self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id);
ctx.request_repaint();
continue;
}
// Finalize the recording clip, and hang the takes off the INSTANCE.
{
let doc = self.action_executor.document_mut();
if let Some(clip) = doc.audio_clips.get_mut(&clip_id) {
// The clip's own content is take 1; the instance's take
// list overrides it with whichever take is active. Every
// take is exactly one cycle region long, so the clip's
// duration is the region.
clip.finalize_recording(
takes[0].0,
loop_len_seconds.seconds_to_f64(),
);
clip.name = format!("Cycle recording ({} takes)", takes.len());
}
// Anchor the instance to the region and select the most
// recent take, GarageBand-style.
//
// timeline_duration stays None on purpose: pinning it would
// make a later tempo change loop/repeat the take's content
// to fill the span instead of letting it drift naturally.
if let Some(lightningbeam_core::layer::AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) {
if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) {
inst.timeline_start = loop_start;
inst.timeline_duration = None;
// Audio take content is seconds, and each take spans
// exactly one cycle region.
inst.trim_start = daw_backend::ContentTime::ZERO;
inst.trim_end =
Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64()));
inst.takes = takes.iter().enumerate().map(|(i, &(pool_index, _))| {
lightningbeam_core::clip::AudioTake {
name: format!("Take {}", i + 1),
content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index },
}
}).collect();
inst.active_take = Some(last_take);
inst.recorded_loop_beats = Some(loop_len_beats);
}
}
}
// The backend already has a clip for the active take (the engine
// pointed it at the last take on stop), so map to it rather than
// adding a duplicate.
let backend_id = lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id);
self.clip_instance_to_backend_map.insert(instance_id, backend_id);
// Commit the whole cycle-record session as ONE undoable action.
let clip_instance = self.layer_to_track_map.get(&layer_id).copied().and_then(|track_id| {
self.action_executor.document()
.get_layer(&layer_id)
.and_then(|l| if let AnyLayer::Audio(al) = l {
al.clip_instances.iter().find(|ci| ci.id == instance_id).cloned()
} else { None })
.map(|ci| (track_id, ci))
});
if let Some((track_id, clip_instance)) = clip_instance {
let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied(
layer_id, clip_instance, track_id, backend_id,
);
self.action_executor.push_applied(Box::new(action));
} else {
self.media_modified = true;
}
}
}
self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id);
}
AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => { AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => {
eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index); eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index);
@ -6510,6 +6811,7 @@ impl eframe::App for EditorApp {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
match controller.get_pool_file_info(pool_index) { match controller.get_pool_file_info(pool_index) {
Ok((dur, _, _)) => { Ok((dur, _, _)) => {
let dur = dur.seconds_to_f64();
eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur); eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur);
self.audio_duration_cache.insert(pool_index, dur); self.audio_duration_cache.insert(pool_index, dur);
dur dur
@ -6543,7 +6845,7 @@ impl eframe::App for EditorApp {
None None
} }
}) })
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, 0.0)) .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, daw_backend::ContentTime::ZERO))
}; };
if !clip_id.is_nil() { if !clip_id.is_nil() {
@ -6667,7 +6969,11 @@ impl eframe::App for EditorApp {
); );
} }
} }
// Update the clip's duration so the timeline bar grows // Update the clip's duration so the timeline bar grows. Once the
// transport has wrapped, the backend reports the whole cycle
// region here and keeps reporting it, so the bar grows through
// the first pass and then holds — every further pass merges into
// the same clip rather than extending it.
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
clip.set_content_duration(ClipDuration::Beats(duration)); clip.set_content_duration(ClipDuration::Beats(duration));
} }
@ -6687,6 +6993,144 @@ impl eframe::App for EditorApp {
} }
ctx.request_repaint(); ctx.request_repaint();
} }
AudioEvent::MidiCycleRecordingStopped { track_id, clip_ids, loop_start, loop_len_beats } => {
println!("🎹 MIDI cycle recording stopped: {} takes", clip_ids.len());
// Pull every take's events into the cache — the user can switch to any of
// them, not just the active one.
if let Some(ref controller_arc) = self.audio_controller {
let mut controller = controller_arc.lock().unwrap();
for &take_clip_id in &clip_ids {
if let Ok(data) = controller.query_midi_clip(track_id, take_clip_id) {
self.midi_event_cache.insert(take_clip_id, data.events);
}
}
}
let layer_id = self.track_to_layer_map.get(&track_id).copied();
if let (Some(layer_id), Some(&first_clip_id)) = (layer_id, clip_ids.first()) {
// The doc clip is the one the recording started on — which the backend
// reused as take 1.
let doc_clip_id = self.action_executor.document()
.audio_clip_by_midi_clip_id(first_clip_id)
.map(|(id, _)| id);
if let Some(doc_clip_id) = doc_clip_id {
self.autosave.pending_event = true;
let last_take = clip_ids.len() - 1;
let recording_instance_id = self.action_executor.document()
.get_layer(&layer_id)
.and_then(|l| if let AnyLayer::Audio(al) = l {
al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).map(|ci| ci.id)
} else { None });
let new_takes: Vec<lightningbeam_core::clip::AudioTake> = clip_ids
.iter()
.map(|&mid| lightningbeam_core::clip::AudioTake {
// Renumbered by the folder that ends up owning them.
name: String::new(),
content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid },
})
.collect();
// If this region already holds a take folder, extend it rather
// than dropping a second clip on top. Queued and run just after
// this loop, which holds a borrow on the event queue.
let existing_folder = recording_instance_id.filter(|rec_inst| {
self.action_executor.document()
.take_folder_at(&layer_id, loop_start, loop_len_beats, rec_inst)
.is_some()
});
if let Some(rec_inst) = existing_folder {
self.pending_take_appends.push(PendingTakeAppend {
layer_id,
recording_instance_id: rec_inst,
recording_clip_id: doc_clip_id,
// The MIDI path maps its recording instance during
// MidiRecordingProgress, so the map has it.
recording_backend_id: None,
loop_start,
loop_len: loop_len_beats,
takes: new_takes,
});
self.recording_layer_ids.retain(|id| *id != layer_id);
self.recording_clips.remove(&layer_id);
if self.recording_layer_ids.is_empty() {
self.is_recording = false;
self.recording_clips.clear();
}
ctx.request_repaint();
continue;
}
{
let doc = self.action_executor.document_mut();
if let Some(clip) = doc.audio_clips.get_mut(&doc_clip_id) {
// The clip's own content stays take 1 (the clip the
// recording started on); the instance's take list
// overrides it with whichever take is active. MIDI takes
// are beats-domain and each spans one cycle region.
clip.set_content_duration(ClipDuration::Beats(loop_len_beats));
clip.name = format!("Cycle recording ({} takes)", clip_ids.len());
}
// Anchor to the region and select the most recent take.
// timeline_duration stays None on purpose — pinning it would
// make a later tempo change loop the take's content to fill
// the span instead of letting it drift naturally.
if let Some(AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) {
if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.clip_id == doc_clip_id) {
inst.timeline_start = loop_start;
inst.timeline_duration = None;
inst.trim_start = daw_backend::ContentTime::ZERO;
inst.trim_end = Some(daw_backend::ContentTime(loop_len_beats.beats_to_f64()));
inst.takes = clip_ids.iter().enumerate().map(|(i, &mid)| {
lightningbeam_core::clip::AudioTake {
name: format!("Take {}", i + 1),
content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid },
}
}).collect();
inst.active_take = Some(last_take);
inst.recorded_loop_beats = Some(loop_len_beats);
}
}
}
// Commit the whole cycle-record session as ONE undoable action.
// The backend instance was mapped during MidiRecordingProgress and
// the engine already repointed it at the active take.
let instance = self.action_executor.document()
.get_layer(&layer_id)
.and_then(|l| if let AnyLayer::Audio(al) = l {
al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).cloned()
} else { None });
if let Some(instance) = instance {
match (self.layer_to_track_map.get(&layer_id).copied(),
self.clip_instance_to_backend_map.get(&instance.id).copied()) {
(Some(tid), Some(backend_id)) => {
let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied(
layer_id, instance, tid, backend_id,
);
self.action_executor.push_applied(Box::new(action));
}
_ => self.media_modified = true,
}
}
}
}
// Clear recording state, same as the single-clip MIDI path.
if let Some(&layer_id) = self.track_to_layer_map.get(&track_id) {
self.recording_layer_ids.retain(|id| *id != layer_id);
self.recording_clips.remove(&layer_id);
}
if self.recording_layer_ids.is_empty() {
self.is_recording = false;
self.recording_clips.clear();
}
ctx.request_repaint();
}
AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => { AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => {
println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes", println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes",
track_id, clip_id, note_count); track_id, clip_id, note_count);
@ -6705,7 +7149,7 @@ impl eframe::App for EditorApp {
.map(|(id, _)| id); .map(|(id, _)| id);
if let Some(doc_clip_id) = doc_clip_id { if let Some(doc_clip_id) = doc_clip_id {
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
clip.set_content_duration(ClipDuration::Beats(Beats(midi_clip_data.duration))); clip.set_content_duration(ClipDuration::Beats(midi_clip_data.duration));
clip.name = format!("MIDI Recording {}", clip_id); clip.name = format!("MIDI Recording {}", clip_id);
} }
} }
@ -6833,6 +7277,20 @@ impl eframe::App for EditorApp {
} }
// Cycle takes that landed on an existing take folder. Deferred out of the event loop above,
// which holds a borrow on the event queue and so can't call a `&mut self` method.
for req in std::mem::take(&mut self.pending_take_appends) {
self.append_cycle_takes(
req.layer_id,
req.recording_instance_id,
req.recording_clip_id,
req.recording_backend_id,
req.loop_start,
req.loop_len,
req.takes,
);
}
// Update input monitoring based on active layer (only send command when changed) // Update input monitoring based on active layer (only send command when changed)
{ {
let should_monitor = self.audio_controller.is_some() && self.active_layer_id.map_or(false, |layer_id| { let should_monitor = self.audio_controller.is_some() && self.active_layer_id.map_or(false, |layer_id| {
@ -7027,6 +7485,11 @@ impl eframe::App for EditorApp {
if result.buffer_size_changed { if result.buffer_size_changed {
println!("⚠️ Audio buffer size will be applied on next app restart"); println!("⚠️ Audio buffer size will be applied on next app restart");
} }
// Cycle MIDI mode takes effect immediately — no restart needed, unlike the buffer size.
if let Some(ref controller_arc) = self.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes);
}
// Apply new keybindings if changed // Apply new keybindings if changed
if let Some(new_keymap) = result.new_keymap { if let Some(new_keymap) = result.new_keymap {
self.keymap = new_keymap; self.keymap = new_keymap;

View File

@ -42,6 +42,8 @@ pub const GRIP_HORIZONTAL: &str = "\u{e0ea}";
pub const CHEVRONS_UP: &str = "\u{e074}"; pub const CHEVRONS_UP: &str = "\u{e074}";
pub const PLAY: &str = "\u{e13c}"; pub const PLAY: &str = "\u{e13c}";
pub const PAUSE: &str = "\u{e12e}"; pub const PAUSE: &str = "\u{e12e}";
pub const REPEAT: &str = "\u{e146}"; // cycle / loop region toggle
pub const TRASH: &str = "\u{e18d}"; // delete a take
pub const SETTINGS: &str = "\u{e154}"; pub const SETTINGS: &str = "\u{e154}";
pub const SEARCH: &str = "\u{e151}"; pub const SEARCH: &str = "\u{e151}";
pub const PLUS: &str = "\u{e13d}"; pub const PLUS: &str = "\u{e13d}";

View File

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

View File

@ -1612,8 +1612,12 @@ impl InfopanelPane {
ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64())); ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64()));
}); });
let clip_dur = document.get_clip_duration(&ci.clip_id) let clip_dur = document.clip_trim_duration(&ci.clip_id)
.unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); .unwrap_or_else(|| lightningbeam_core::clip::ClipDuration::Seconds(
daw_backend::Seconds(
(ci.trim_end.unwrap_or(daw_backend::ContentTime(1.0)) - ci.trim_start).raw(),
),
));
let total_dur = ci.total_duration(clip_dur, document.tempo_map()); let total_dur = ci.total_duration(clip_dur, document.tempo_map());
let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur) let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur)
- document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64(); - document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64();
@ -1622,10 +1626,16 @@ impl InfopanelPane {
ui.label(format!("{:.2}s", total_dur_secs)); ui.label(format!("{:.2}s", total_dur_secs));
}); });
if ci.trim_start > 0.0 { if ci.trim_start > daw_backend::ContentTime::ZERO {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Trim Start:"); ui.label("Trim Start:");
ui.label(format!("{:.2}s", ci.trim_start)); // Content time in the clip's own domain: seconds for sampled
// audio/video/vector, beats for MIDI. Label it accordingly.
let unit = match clip_dur {
lightningbeam_core::clip::ClipDuration::Beats(_) => "beats",
lightningbeam_core::clip::ClipDuration::Seconds(_) => "s",
};
ui.label(format!("{:.2}{}", ci.trim_start.raw(), unit));
}); });
} }

View File

@ -465,9 +465,13 @@ impl PianoRollPane {
if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) { if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) {
for instance in &audio_layer.clip_instances { for instance in &audio_layer.clip_instances {
if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { // Resolve through the instance's active take, so a MIDI take folder edits
let duration = instance.effective_duration(clip.content_duration().to_seconds(document.tempo_map()), document.tempo_map()); // whichever take it's actually playing.
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id)); if let Some(midi_clip_id) = instance.resolved_midi_clip_id(clip) {
let duration = instance.effective_duration(clip.content_duration(), document.tempo_map());
// A MIDI clip's content time IS beats, which is what the piano roll's
// x-axis uses.
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), instance.id));
} }
} }
} }
@ -2463,7 +2467,8 @@ impl PianoRollPane {
}); });
// Get sample rate from raw_audio_cache // Get sample rate from raw_audio_cache
if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) { if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) {
clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), *sr)); // A sampled clip's content time is seconds.
clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), *sr));
} }
} }
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -55,6 +55,7 @@ struct PreferencesState {
file_height: u32, file_height: u32,
scroll_speed: f64, scroll_speed: f64,
audio_buffer_size: u32, audio_buffer_size: u32,
cycle_midi_separate_takes: bool,
reopen_last_session: bool, reopen_last_session: bool,
restore_layout_from_file: bool, restore_layout_from_file: bool,
debug: bool, debug: bool,
@ -72,6 +73,7 @@ impl From<(&AppConfig, &Theme)> for PreferencesState {
file_height: config.file_height, file_height: config.file_height,
scroll_speed: config.scroll_speed, scroll_speed: config.scroll_speed,
audio_buffer_size: config.audio_buffer_size, audio_buffer_size: config.audio_buffer_size,
cycle_midi_separate_takes: config.cycle_midi_separate_takes,
reopen_last_session: config.reopen_last_session, reopen_last_session: config.reopen_last_session,
restore_layout_from_file: config.restore_layout_from_file, restore_layout_from_file: config.restore_layout_from_file,
debug: config.debug, debug: config.debug,
@ -91,6 +93,7 @@ impl Default for PreferencesState {
file_height: 600, file_height: 600,
scroll_speed: 1.0, scroll_speed: 1.0,
audio_buffer_size: 256, audio_buffer_size: 256,
cycle_midi_separate_takes: false,
reopen_last_session: false, reopen_last_session: false,
restore_layout_from_file: true, restore_layout_from_file: true,
debug: false, debug: false,
@ -543,6 +546,39 @@ impl PreferencesDialog {
}); });
ui.label("Requires app restart to take effect"); ui.label("Requires app restart to take effect");
ui.separator();
ui.horizontal(|ui| {
ui.label("Cycle MIDI recording:");
egui::ComboBox::from_id_salt("cycle_midi_mode")
.selected_text(if self.working_prefs.cycle_midi_separate_takes {
"Separate takes"
} else {
"Merge"
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.working_prefs.cycle_midi_separate_takes,
false,
"Merge",
)
.on_hover_text(
"Every pass overdubs into one clip, and earlier passes play back as \
you record so you can layer against them.",
);
ui.selectable_value(
&mut self.working_prefs.cycle_midi_separate_takes,
true,
"Separate takes",
)
.on_hover_text(
"Each pass becomes its own take in a take folder, as audio always \
does. Earlier passes stay silent they're alternatives, not layers.",
);
});
});
}); });
} }
@ -641,6 +677,7 @@ impl PreferencesDialog {
temp_config.file_height = self.working_prefs.file_height; temp_config.file_height = self.working_prefs.file_height;
temp_config.scroll_speed = self.working_prefs.scroll_speed; temp_config.scroll_speed = self.working_prefs.scroll_speed;
temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size; temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size;
temp_config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes;
temp_config.reopen_last_session = self.working_prefs.reopen_last_session; temp_config.reopen_last_session = self.working_prefs.reopen_last_session;
temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
temp_config.debug = self.working_prefs.debug; temp_config.debug = self.working_prefs.debug;
@ -675,6 +712,7 @@ impl PreferencesDialog {
config.file_height = self.working_prefs.file_height; config.file_height = self.working_prefs.file_height;
config.scroll_speed = self.working_prefs.scroll_speed; config.scroll_speed = self.working_prefs.scroll_speed;
config.audio_buffer_size = self.working_prefs.audio_buffer_size; config.audio_buffer_size = self.working_prefs.audio_buffer_size;
config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes;
config.reopen_last_session = self.working_prefs.reopen_last_session; config.reopen_last_session = self.working_prefs.reopen_last_session;
config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
config.debug = self.working_prefs.debug; config.debug = self.working_prefs.debug;