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.
This commit is contained in:
Skyler Lehmkuhl 2026-07-14 10:39:46 -04:00
parent 6629adc7d2
commit c62164c365
13 changed files with 869 additions and 28 deletions

View File

@ -41,6 +41,9 @@ pub struct Engine {
/// Cycle-region sample bounds frozen for the duration of an **audio** recording. /// Cycle-region sample bounds frozen for the duration of an **audio** recording.
/// See `loop_bounds_samples` for why. `None` = derive live from the tempo map. /// See `loop_bounds_samples` for why. `None` = derive live from the tempo map.
loop_bounds_frozen: Option<(i64, i64)>, loop_bounds_frozen: Option<(i64, i64)>,
/// How a cycle MIDI recording treats its passes: merge into one clip (default), or one clip per
/// pass for the editor to fold into a take folder.
cycle_midi_separate_takes: bool,
// Lock-free communication // Lock-free communication
command_rx: rtrb::Consumer<Command>, command_rx: rtrb::Consumer<Command>,
@ -167,6 +170,7 @@ impl Engine {
loop_region: None, loop_region: None,
loop_enabled: false, loop_enabled: false,
loop_bounds_frozen: None, loop_bounds_frozen: None,
cycle_midi_separate_takes: false,
command_rx, command_rx,
midi_command_rx: None, midi_command_rx: None,
event_tx, event_tx,
@ -494,6 +498,11 @@ impl Engine {
self.project.reset_read_ahead_targets(); self.project.reset_read_ahead_targets();
// Render the entire project hierarchy into the mix buffer // Render the entire project hierarchy into the mix buffer
// Silence everything else on the track being recorded into (see RenderContext).
let recording_midi = self
.midi_recording_state
.as_ref()
.map(|rec| (rec.track_id, rec.clip_id));
self.project.render( self.project.render(
&mut self.mix_buffer, &mut self.mix_buffer,
&self.audio_pool, &self.audio_pool,
@ -503,6 +512,7 @@ impl Engine {
self.sample_rate, self.sample_rate,
self.channels, self.channels,
false, false,
recording_midi,
); );
// Copy mix to output // Copy mix to output
@ -543,7 +553,7 @@ impl Engine {
rec.wrap_at_cycle(le_beats, ls_beats); rec.wrap_at_cycle(le_beats, ls_beats);
} }
// Overdub monitoring. In merge mode every pass layers onto the same clip, so // Overdub monitoring. In MERGE mode every pass layers onto the same clip, so
// the next pass has to PLAY BACK what was just laid down — otherwise you're // the next pass has to PLAY BACK what was just laid down — otherwise you're
// overdubbing against silence, which defeats the point of merging (you can't // overdubbing against silence, which defeats the point of merging (you can't
// put a hi-hat on a kick you can't hear). // put a hi-hat on a kick you can't hear).
@ -551,7 +561,12 @@ impl Engine {
// The notes only reach the pool clip at stop otherwise, so fold what's been // The notes only reach the pool clip at stop otherwise, so fold what's been
// captured so far into it now, at the boundary. Disjoint field borrows: // captured so far into it now, at the boundary. Disjoint field borrows:
// `midi_recording_state` read, `project` written. // `midi_recording_state` read, `project` written.
if let Some(rec) = self.midi_recording_state.as_ref() { //
// Deliberately NOT done in separate-takes mode: there, each pass is an
// alternative rather than a layer, so hearing the previous take play back
// under you would just be confusing — you'd be playing along with the take
// you're trying to replace.
if let Some(rec) = self.midi_recording_state.as_ref().filter(|_| !self.cycle_midi_separate_takes) {
if let Some(clip) = if let Some(clip) =
self.project.midi_clip_pool.get_clip_mut(rec.clip_id) self.project.midi_clip_pool.get_clip_mut(rec.clip_id)
{ {
@ -633,7 +648,19 @@ impl Engine {
let duration = recording let duration = recording
.cycle_loop_len .cycle_loop_len
.unwrap_or(current_time - recording.start_time); .unwrap_or(current_time - recording.start_time);
let notes = recording.get_notes_with_active(current_time); // In separate-takes mode the preview shows only the pass being played now — the
// earlier passes are alternative takes, not layers, so drawing them all on top of
// each other would misrepresent what's being recorded.
let notes = if self.cycle_midi_separate_takes && recording.cycle_loop_len.is_some() {
let mut current = recording
.notes_by_pass(recording.pass_count())
.pop()
.unwrap_or_default();
current.extend(recording.active_notes_with_provisional_end(current_time));
current
} else {
recording.get_notes_with_active(current_time)
};
let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress( let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress(
recording.track_id, recording.track_id,
recording.clip_id, recording.clip_id,
@ -671,6 +698,7 @@ impl Engine {
self.sample_rate, self.sample_rate,
self.channels, self.channels,
true, // live_only true, // live_only
None, // no clips are scheduled at all in live_only, so nothing to mute
); );
output.copy_from_slice(&self.mix_buffer); output.copy_from_slice(&self.mix_buffer);
} }
@ -955,6 +983,9 @@ impl Engine {
Command::SetLoopEnabled(enabled) => { Command::SetLoopEnabled(enabled) => {
self.loop_enabled = enabled; self.loop_enabled = enabled;
} }
Command::SetCycleMidiSeparateTakes(separate) => {
self.cycle_midi_separate_takes = separate;
}
Command::Stop => { Command::Stop => {
self.playing = false; self.playing = false;
self.playhead = 0; self.playhead = 0;
@ -3564,6 +3595,76 @@ impl Engine {
let clip_id = recording.clip_id; let clip_id = recording.clip_id;
let track_id = recording.track_id; let track_id = recording.track_id;
// ---- Separate takes: one pool clip per cycle pass ----
//
// Only when the transport actually wrapped; a recording that stopped inside the first
// pass is an ordinary single recording and falls through to the merge path below, just
// as it does for audio.
if let (true, Some(loop_len)) =
(self.cycle_midi_separate_takes, recording.cycle_loop_len)
{
let loop_start = recording.start_time; // a cycle recording is anchored at the region
let passes = recording.pass_count();
let buckets = recording.notes_by_pass(passes);
eprintln!(
"[MIDI_RECORDING] Cycle recording (separate takes): {} passes",
passes
);
let mut clip_ids: Vec<MidiClipId> = Vec::with_capacity(buckets.len());
for (i, bucket) in buckets.iter().enumerate() {
// Pass 0 reuses the clip the recording started on; later passes get fresh ones.
let take_clip_id = if i == 0 {
clip_id
} else {
let id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
let clip = MidiClip::empty(id, loop_len, format!("Take {}", i + 1));
self.project.midi_clip_pool.add_existing_clip(clip);
id
};
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(take_clip_id) {
clip.events.clear();
clip.duration = loop_len;
for (start, note, velocity, duration) in bucket {
clip.events.push(MidiEvent::note_on(*start, 0, *note, *velocity));
clip.events.push(MidiEvent::note_off(*start + *duration, 0, *note, 64));
}
clip.events
.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap());
}
clip_ids.push(take_clip_id);
}
// Point the track's instance at the take the editor will make active (the last one,
// GarageBand-style) and stretch it over the region.
if let Some(&last) = clip_ids.last() {
if let Some(crate::audio::track::TrackNode::Midi(track)) =
self.project.get_track_mut(track_id)
{
if let Some(instance) =
track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id)
{
instance.clip_id = last;
instance.internal_start = Beats::ZERO;
instance.internal_end = loop_len;
instance.external_start = loop_start;
instance.external_duration = loop_len;
}
}
}
self.refresh_clip_snapshot();
let _ = self.event_tx.push(AudioEvent::MidiCycleRecordingStopped {
track_id,
clip_ids,
loop_start,
loop_len_beats: loop_len,
});
return;
}
let notes = recording.get_notes().to_vec(); let notes = recording.get_notes().to_vec();
let note_count = notes.len(); let note_count = notes.len();
// A cycle MIDI recording is anchored at the region start and every pass overdubs into // A cycle MIDI recording is anchored at the region start and every pass overdubs into
@ -3698,6 +3799,12 @@ impl EngineController {
let _ = self.command_tx.push(Command::SetLoopEnabled(enabled)); let _ = self.command_tx.push(Command::SetLoopEnabled(enabled));
} }
/// How a cycle MIDI recording treats its passes: merge into one clip (false, the default), or
/// one clip per pass (true) for the editor to fold into a take folder.
pub fn set_cycle_midi_separate_takes(&mut self, separate: bool) {
let _ = self.command_tx.push(Command::SetCycleMidiSeparateTakes(separate));
}
/// Stop playback and reset to beginning /// Stop playback and reset to beginning
pub fn stop(&mut self) { pub fn stop(&mut self) {
let _ = self.command_tx.push(Command::Stop); let _ = self.command_tx.push(Command::Stop);

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

@ -302,6 +302,13 @@ pub struct MidiRecordingState {
/// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each /// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each
/// other with no folding needed. Set only if the transport actually wrapped. /// other with no folding needed. Set only if the transport actually wrapped.
pub cycle_loop_len: Option<Beats>, pub cycle_loop_len: Option<Beats>,
/// 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 {
@ -313,9 +320,25 @@ impl MidiRecordingState {
active_notes: HashMap::new(), active_notes: HashMap::new(),
completed_notes: Vec::new(), completed_notes: Vec::new(),
cycle_loop_len: None, cycle_loop_len: None,
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 });
} }
@ -325,16 +348,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
} }
@ -343,18 +388,28 @@ impl MidiRecordingState {
self.completed_notes.len() self.completed_notes.len()
} }
/// Get all completed notes plus currently-held notes with a provisional duration. /// The still-held notes, given a provisional duration running to `current_time`.
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { ///
let mut notes = self.completed_notes.clone(); /// These belong to whatever pass is in progress, so a per-pass view can append them as-is.
for active in self.active_notes.values() { 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); let note_start = active.start_time.max(self.start_time);
notes.push(( (
note_start - self.start_time, note_start - self.start_time,
active.note, active.note,
active.velocity, active.velocity,
(current_time - note_start).max(Beats::ZERO), (current_time - note_start).max(Beats::ZERO),
)); )
})
.collect()
} }
/// Get all completed notes plus currently-held notes with a provisional duration.
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
let mut notes = self.completed_notes.clone();
notes.extend(self.active_notes_with_provisional_end(current_time));
notes notes
} }
@ -366,13 +421,7 @@ impl MidiRecordingState {
let active_notes: Vec<_> = self.active_notes.drain().collect(); let active_notes: Vec<_> = self.active_notes.drain().collect();
for (_note_num, active_note) in active_notes { for (_note_num, active_note) in active_notes {
let note_start = active_note.start_time.max(self.start_time); self.push_completed(&active_note, end_time);
self.completed_notes.push((
note_start - self.start_time,
active_note.note,
active_note.velocity,
end_time - note_start,
));
} }
} }
@ -392,7 +441,10 @@ impl MidiRecordingState {
.map(|n| (n.note, n.velocity)) .map(|n| (n.note, n.velocity))
.collect(); .collect();
// Close first, so a note held across the boundary has its tail attributed to the pass that's
// ending; then advance, so the re-opened half belongs to the pass that's beginning.
self.close_active_notes(region_end); self.close_active_notes(region_end);
self.current_pass += 1;
for (note, velocity) in held { for (note, velocity) in held {
self.note_on(note, velocity, region_start); self.note_on(note, velocity, region_start);
@ -502,3 +554,104 @@ mod cycle_tests {
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]); 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

@ -133,6 +133,12 @@ pub enum Command {
SetLoopRegion(Option<(Beats, Beats)>), SetLoopRegion(Option<(Beats, Beats)>),
/// Enable/disable wrapping at the cycle region's end. /// Enable/disable wrapping at the cycle region's end.
SetLoopEnabled(bool), SetLoopEnabled(bool),
/// How a cycle MIDI recording treats its passes.
///
/// `false` (default) = MERGE: every pass overdubs into one clip. `true` = SEPARATE TAKES: each
/// pass becomes its own MIDI clip, and the editor folds them into a take folder — the same shape
/// audio always gets.
SetCycleMidiSeparateTakes(bool),
// Recording commands // Recording commands
/// Start recording on a track (track_id, start_time) /// Start recording on a track (track_id, start_time)
@ -313,6 +319,19 @@ 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. /// 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 /// Each take spans the full region and they're all the same length (partial passes are padded

View File

@ -0,0 +1,157 @@
//! 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::{AudioClipType, AudioTake};
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
/// Action that appends takes to a take-folder clip and selects the last of them.
pub struct AppendTakesAction {
layer_id: Uuid,
/// The instance whose folder is being extended (and whose active take changes).
instance_id: Uuid,
clip_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, clip_id: Uuid, new_takes: Vec<AudioTake>) -> Self {
Self {
layer_id,
instance_id,
clip_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 clip = document
.audio_clips
.get_mut(&self.clip_id)
.ok_or_else(|| format!("Audio clip {} not found", self.clip_id))?;
let AudioClipType::TakeFolder { takes, .. } = &mut clip.clip_type else {
return Err("Can only append takes to a take folder".to_string());
};
// 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 = takes.len();
}
takes.extend(self.new_takes.iter().cloned());
// Renumber so the names stay in step with the take indices the badge shows.
for (i, take) in takes.iter_mut().enumerate() {
take.name = format!("Take {}", i + 1);
}
let new_active = takes.len() - 1;
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))?;
if !self.executed {
self.old_active_take = instance.active_take;
}
// Land on the take just recorded, GarageBand-style.
instance.active_take = Some(new_active);
self.executed = true;
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(clip) = document.audio_clips.get_mut(&self.clip_id) {
if let AudioClipType::TakeFolder { takes, .. } = &mut clip.clip_type {
takes.truncate(self.old_take_count);
for (i, take) in takes.iter_mut().enumerate() {
take.name = format!("Take {}", i + 1);
}
}
}
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.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

@ -15,6 +15,7 @@ pub mod reorder_clip_instances;
pub mod paint_bucket; pub mod paint_bucket;
pub mod remove_effect; pub mod remove_effect;
pub mod set_cycle_region; pub mod set_cycle_region;
pub mod append_takes;
pub mod set_active_take; 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;
@ -53,6 +54,7 @@ pub mod resize_text_box;
pub use add_clip_instance::AddClipInstanceAction; pub use add_clip_instance::AddClipInstanceAction;
pub use set_cycle_region::SetCycleRegionAction; pub use set_cycle_region::SetCycleRegionAction;
pub use append_takes::AppendTakesAction;
pub use set_active_take::SetActiveTakeAction; 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;

View File

@ -932,6 +932,46 @@ impl Document {
self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::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 (instance id, clip id).
pub fn take_folder_at(
&self,
layer_id: &Uuid,
loop_start: Beats,
loop_len: Beats,
exclude: &Uuid,
) -> Option<(Uuid, 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.timeline_start - loop_start).beats_to_f64().abs() > EPS {
return None;
}
let clip = self.audio_clips.get(&ci.clip_id)?;
match clip.clip_type {
crate::clip::AudioClipType::TakeFolder { recorded_loop_beats, .. }
if (recorded_loop_beats - loop_len).beats_to_f64().abs() < EPS =>
{
Some((ci.id, ci.clip_id))
}
_ => None,
}
})
}
/// Resolve a [`ContentTime`] (a trim bound) against the clip it belongs to. /// 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 /// The clip is the only thing that knows whether its content is measured in seconds or beats, so

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

@ -1105,6 +1105,18 @@ 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,
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 +1225,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 +1597,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,6 +2053,88 @@ 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,
loop_start: Beats,
loop_len: Beats,
takes: Vec<lightningbeam_core::clip::AudioTake>,
) -> bool {
let Some((target_instance_id, target_clip_id)) = self.action_executor.document().take_folder_at(
&layer_id,
loop_start,
loop_len,
&recording_instance_id,
) else {
return false;
};
// Drop the recording's backend clip; the target instance's own clip gets repointed at the
// new active take by the action's backend sync.
let backend_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,
target_clip_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};
@ -2052,6 +2151,9 @@ impl EditorApp {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
controller.set_loop_region(region); controller.set_loop_region(region);
controller.set_loop_enabled(enabled); controller.set_loop_enabled(enabled);
// Cycle MIDI mode is a *preference*, not document state, so it rides along here
// rather than through an action.
controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes);
} }
} }
@ -6570,6 +6672,35 @@ impl eframe::App for EditorApp {
self.autosave.pending_event = true; self.autosave.pending_event = true;
let last_take = takes.len() - 1; 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,
loop_start,
loop_len: loop_len_beats,
takes: new_takes,
});
self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id);
ctx.request_repaint();
continue;
}
// Promote the in-progress recording clip to a take folder. // Promote the in-progress recording clip to a take folder.
{ {
let doc = self.action_executor.document_mut(); let doc = self.action_executor.document_mut();
@ -6844,6 +6975,141 @@ 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,
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) {
clip.clip_type = lightningbeam_core::clip::AudioClipType::TakeFolder {
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(),
recorded_loop_beats: loop_len_beats,
};
// MIDI takes are beats-domain, and every take spans exactly
// 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.active_take = Some(last_take);
}
}
}
// 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);
@ -6990,6 +7256,19 @@ 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.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| {
@ -7184,6 +7463,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

@ -465,7 +465,9 @@ 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
// whichever take it's actually playing.
if let Some(midi_clip_id) = clip.resolved_midi_clip_id(instance.active_take) {
let duration = instance.effective_duration(clip.content_duration(), document.tempo_map()); 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 // A MIDI clip's content time IS beats, which is what the piano roll's
// x-axis uses. // x-axis uses.

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;