diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 6e22ad4..2aed285 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -41,6 +41,9 @@ pub struct Engine { /// Cycle-region sample bounds frozen for the duration of an **audio** recording. /// See `loop_bounds_samples` for why. `None` = derive live from the tempo map. loop_bounds_frozen: Option<(i64, i64)>, + /// How a cycle MIDI recording treats its passes: merge into one clip (default), or one clip per + /// pass for the editor to fold into a take folder. + cycle_midi_separate_takes: bool, // Lock-free communication command_rx: rtrb::Consumer, @@ -167,6 +170,7 @@ impl Engine { loop_region: None, loop_enabled: false, loop_bounds_frozen: None, + cycle_midi_separate_takes: false, command_rx, midi_command_rx: None, event_tx, @@ -494,6 +498,11 @@ impl Engine { self.project.reset_read_ahead_targets(); // Render the entire project hierarchy into the mix buffer + // Silence everything else on the track being recorded into (see RenderContext). + let recording_midi = self + .midi_recording_state + .as_ref() + .map(|rec| (rec.track_id, rec.clip_id)); self.project.render( &mut self.mix_buffer, &self.audio_pool, @@ -503,6 +512,7 @@ impl Engine { self.sample_rate, self.channels, false, + recording_midi, ); // Copy mix to output @@ -543,7 +553,7 @@ impl Engine { 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 // overdubbing against silence, which defeats the point of merging (you can't // 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 // captured so far into it now, at the boundary. Disjoint field borrows: // `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) = self.project.midi_clip_pool.get_clip_mut(rec.clip_id) { @@ -633,7 +648,19 @@ impl Engine { let duration = recording .cycle_loop_len .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( recording.track_id, recording.clip_id, @@ -671,6 +698,7 @@ impl Engine { self.sample_rate, self.channels, true, // live_only + None, // no clips are scheduled at all in live_only, so nothing to mute ); output.copy_from_slice(&self.mix_buffer); } @@ -955,6 +983,9 @@ impl Engine { Command::SetLoopEnabled(enabled) => { self.loop_enabled = enabled; } + Command::SetCycleMidiSeparateTakes(separate) => { + self.cycle_midi_separate_takes = separate; + } Command::Stop => { self.playing = false; self.playhead = 0; @@ -3564,6 +3595,76 @@ impl Engine { let clip_id = recording.clip_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 = Vec::with_capacity(buckets.len()); + for (i, bucket) in buckets.iter().enumerate() { + // Pass 0 reuses the clip the recording started on; later passes get fresh ones. + let take_clip_id = if i == 0 { + clip_id + } else { + let id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); + let clip = MidiClip::empty(id, loop_len, format!("Take {}", i + 1)); + self.project.midi_clip_pool.add_existing_clip(clip); + id + }; + + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(take_clip_id) { + clip.events.clear(); + clip.duration = loop_len; + for (start, note, velocity, duration) in bucket { + clip.events.push(MidiEvent::note_on(*start, 0, *note, *velocity)); + clip.events.push(MidiEvent::note_off(*start + *duration, 0, *note, 64)); + } + clip.events + .sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + } + clip_ids.push(take_clip_id); + } + + // Point the track's instance at the take the editor will make active (the last one, + // GarageBand-style) and stretch it over the region. + if let Some(&last) = clip_ids.last() { + if let Some(crate::audio::track::TrackNode::Midi(track)) = + self.project.get_track_mut(track_id) + { + if let Some(instance) = + track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) + { + instance.clip_id = last; + instance.internal_start = Beats::ZERO; + instance.internal_end = loop_len; + instance.external_start = loop_start; + instance.external_duration = loop_len; + } + } + } + self.refresh_clip_snapshot(); + + let _ = self.event_tx.push(AudioEvent::MidiCycleRecordingStopped { + track_id, + clip_ids, + loop_start, + loop_len_beats: loop_len, + }); + return; + } + let notes = recording.get_notes().to_vec(); let note_count = notes.len(); // 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)); } + /// How a cycle MIDI recording treats its passes: merge into one clip (false, the default), or + /// one clip per pass (true) for the editor to fold into a take folder. + pub fn set_cycle_midi_separate_takes(&mut self, separate: bool) { + let _ = self.command_tx.push(Command::SetCycleMidiSeparateTakes(separate)); + } + /// Stop playback and reset to beginning pub fn stop(&mut self) { let _ = self.command_tx.push(Command::Stop); diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs index 789b9d8..3110024 100644 --- a/daw-backend/src/audio/export.rs +++ b/daw-backend/src/audio/export.rs @@ -211,6 +211,7 @@ pub fn render_to_memory( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we actually need from this chunk @@ -557,6 +558,7 @@ fn export_mp3>( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we need from this chunk @@ -727,6 +729,7 @@ fn export_aac>( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we need from this chunk diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index 4cce4a1..c44eafc 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -383,6 +383,7 @@ impl Project { sample_rate: u32, channels: u32, live_only: bool, + recording_midi: Option<(TrackId, MidiClipId)>, ) { output.fill(0.0); @@ -391,6 +392,7 @@ impl Project { // Create initial render context let ctx = RenderContext { live_only, + recording_midi, ..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len()) }; diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index 0a292d0..504f69a 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -302,6 +302,13 @@ pub struct MidiRecordingState { /// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each /// other with no folding needed. Set only if the transport actually wrapped. pub cycle_loop_len: Option, + /// Which cycle pass is currently being recorded (0-based). Bumped at each wrap. + current_pass: usize, + /// The pass each completed note belongs to, parallel to `completed_notes`. + /// + /// Only meaningful in "separate takes" mode, where each pass becomes its own MIDI clip. Merge + /// mode ignores it — all passes fold into one clip, which is the whole point. + note_pass: Vec, } impl MidiRecordingState { @@ -313,9 +320,25 @@ impl MidiRecordingState { active_notes: HashMap::new(), completed_notes: Vec::new(), 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) { self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time }); } @@ -325,16 +348,38 @@ impl MidiRecordingState { if absolute_time <= self.start_time { return; } - let note_start = active_note.start_time.max(self.start_time); - self.completed_notes.push(( - note_start - self.start_time, - active_note.note, - active_note.velocity, - absolute_time - note_start, - )); + self.push_completed(&active_note, absolute_time); } } + /// Completed notes grouped by cycle pass — one bucket per pass, in recording order. + /// + /// Used by "separate takes" mode, where each pass becomes its own MIDI clip. + /// + /// An *interior* pass in which nothing was played still yields an empty take, so take N in the + /// folder is always pass N on the transport — otherwise the numbering would silently shift and + /// "take 3" would stop meaning "the third time round". A *trailing* empty pass is dropped + /// though: that's what you get by hitting stop shortly after a wrap, and it's a stop artifact + /// rather than a take you played. (Same reasoning as the audio path's short-final-take rule.) + pub fn notes_by_pass(&self, passes: usize) -> Vec> { + let mut buckets = vec![Vec::new(); passes.max(1)]; + for (note, &pass) in self.completed_notes.iter().zip(self.note_pass.iter()) { + if let Some(bucket) = buckets.get_mut(pass) { + bucket.push(*note); + } + } + // Never drop the only take. + while buckets.len() > 1 && buckets.last().is_some_and(|b| b.is_empty()) { + buckets.pop(); + } + buckets + } + + /// How many cycle passes this recording covered (1 if the transport never wrapped). + pub fn pass_count(&self) -> usize { + self.current_pass + 1 + } + pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] { &self.completed_notes } @@ -343,18 +388,28 @@ impl MidiRecordingState { self.completed_notes.len() } + /// The still-held notes, given a provisional duration running to `current_time`. + /// + /// These belong to whatever pass is in progress, so a per-pass view can append them as-is. + pub fn active_notes_with_provisional_end(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { + self.active_notes + .values() + .map(|active| { + let note_start = active.start_time.max(self.start_time); + ( + note_start - self.start_time, + active.note, + active.velocity, + (current_time - note_start).max(Beats::ZERO), + ) + }) + .collect() + } + /// Get all completed notes plus currently-held notes with a provisional duration. pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { let mut notes = self.completed_notes.clone(); - for active in self.active_notes.values() { - let note_start = active.start_time.max(self.start_time); - notes.push(( - note_start - self.start_time, - active.note, - active.velocity, - (current_time - note_start).max(Beats::ZERO), - )); - } + notes.extend(self.active_notes_with_provisional_end(current_time)); notes } @@ -366,13 +421,7 @@ impl MidiRecordingState { let active_notes: Vec<_> = self.active_notes.drain().collect(); for (_note_num, active_note) in active_notes { - let note_start = active_note.start_time.max(self.start_time); - self.completed_notes.push(( - note_start - self.start_time, - active_note.note, - active_note.velocity, - end_time - note_start, - )); + self.push_completed(&active_note, end_time); } } @@ -392,7 +441,10 @@ impl MidiRecordingState { .map(|n| (n.note, n.velocity)) .collect(); + // Close first, so a note held across the boundary has its tail attributed to the pass that's + // ending; then advance, so the re-opened half belongs to the pass that's beginning. self.close_active_notes(region_end); + self.current_pass += 1; for (note, velocity) in held { self.note_on(note, velocity, region_start); @@ -502,3 +554,104 @@ mod cycle_tests { assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]); } } + +#[cfg(test)] +mod midi_cycle_tests { + use super::*; + + /// A MIDI recording anchored at the region start (beat 0), region 4 beats long. + fn rec() -> MidiRecordingState { + MidiRecordingState::new(0, 0, Beats(0.0)) + } + + /// One pass of the transport around a 4-beat region. + fn wrap(r: &mut MidiRecordingState) { + r.wrap_at_cycle(Beats(4.0), Beats(0.0)); + } + + #[test] + fn notes_are_bucketed_by_the_pass_they_were_played_in() { + let mut r = rec(); + r.note_on(60, 100, Beats(1.0)); + r.note_off(60, Beats(2.0)); // pass 0 + wrap(&mut r); + r.note_on(62, 100, Beats(1.0)); + r.note_off(62, Beats(2.0)); // pass 1 + wrap(&mut r); + r.note_on(64, 100, Beats(1.0)); + r.note_off(64, Beats(2.0)); // pass 2 + + assert_eq!(r.pass_count(), 3); + let by_pass = r.notes_by_pass(r.pass_count()); + let pitches: Vec> = by_pass + .iter() + .map(|p| p.iter().map(|n| n.1).collect()) + .collect(); + assert_eq!(pitches, vec![vec![60], vec![62], vec![64]]); + } + + #[test] + fn a_note_held_across_a_wrap_splits_between_the_two_passes() { + // The key is still down at the boundary: the sounding half belongs to the pass that's + // ending, and the re-opened half to the pass that's beginning. Getting the pass bump on the + // wrong side of close_active_notes would file the whole note under one pass. + let mut r = rec(); + r.note_on(60, 100, Beats(3.0)); + wrap(&mut r); // still held + r.note_off(60, Beats(1.0)); // released 1 beat into the next pass + + assert_eq!(r.pass_count(), 2); + let by_pass = r.notes_by_pass(r.pass_count()); + assert_eq!(by_pass[0].len(), 1, "the held half lands in the pass that ended"); + assert_eq!(by_pass[1].len(), 1, "the re-opened half lands in the next pass"); + // Pass 0's half runs from beat 3 to the region end at 4. + assert_eq!(by_pass[0][0].0, Beats(3.0)); + assert_eq!(by_pass[0][0].3, Beats(1.0)); + // Pass 1's half starts at the region start and runs to the release. + assert_eq!(by_pass[1][0].0, Beats(0.0)); + assert_eq!(by_pass[1][0].3, Beats(1.0)); + } + + #[test] + fn a_silent_interior_pass_still_yields_an_empty_take() { + // Take N in the folder must be pass N on the transport, even if nothing was played — else + // the take numbering silently shifts under the user. + let mut r = rec(); + r.note_on(60, 100, Beats(1.0)); + r.note_off(60, Beats(2.0)); // pass 0 + wrap(&mut r); + wrap(&mut r); // pass 1: played nothing + r.note_on(64, 100, Beats(1.0)); + r.note_off(64, Beats(2.0)); // pass 2 + + let by_pass = r.notes_by_pass(r.pass_count()); + assert_eq!(by_pass.len(), 3); + assert_eq!(by_pass[1].len(), 0, "the silent pass is still take 2"); + assert_eq!(by_pass[2][0].1, 64); + } + + #[test] + fn a_trailing_empty_pass_is_dropped() { + // Hitting stop shortly after a wrap leaves a pass you never played into. That's a stop + // artifact, not a take — unlike a silent pass in the middle, which was a deliberate rest. + let mut r = rec(); + r.note_on(60, 100, Beats(1.0)); + r.note_off(60, Beats(2.0)); // pass 0 + wrap(&mut r); + r.note_on(62, 100, Beats(1.0)); + r.note_off(62, Beats(2.0)); // pass 1 + wrap(&mut r); // pass 2 begins... and the user hits stop + + assert_eq!(r.pass_count(), 3); + let by_pass = r.notes_by_pass(r.pass_count()); + assert_eq!(by_pass.len(), 2, "the empty trailing pass is not a take"); + } + + #[test] + fn an_empty_recording_still_yields_one_take() { + let mut r = rec(); + wrap(&mut r); + wrap(&mut r); + assert_eq!(r.notes_by_pass(r.pass_count()).len(), 1); + } +} diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index f7da3d2..b79cb1a 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -1,6 +1,6 @@ use super::automation::{AutomationLane, AutomationLaneId, ParameterId}; use super::clip::{AudioClipInstance, AudioClipInstanceId}; -use super::midi::{MidiClipInstance, MidiClipInstanceId, MidiEvent}; +use super::midi::{MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent}; use super::midi_pool::MidiClipPool; use super::node_graph::AudioGraph; use super::node_graph::nodes::{AudioInputNode, AudioOutputNode}; @@ -43,6 +43,13 @@ pub struct RenderContext<'a> { /// Used after pause/stop to route note-off tails through the normal group hierarchy /// without re-triggering notes from clips at the paused position. pub live_only: bool, + /// The MIDI recording in progress, if any: (track being recorded to, clip being recorded into). + /// + /// On that track, every OTHER clip is silenced for the duration of the recording. You're playing + /// a part into this region — hearing what's already there (a previous take, say) fighting with + /// what you're playing now is just noise. The clip being recorded into is exempt, because in + /// merge mode that's exactly what you DO want to hear: the overdub you've been building up. + pub recording_midi: Option<(TrackId, MidiClipId)>, } impl<'a> RenderContext<'a> { @@ -61,6 +68,7 @@ impl<'a> RenderContext<'a> { buffer_size, time_stretch: 1.0, live_only: false, + recording_midi: None, } } @@ -864,9 +872,21 @@ impl MidiTrack { let playhead_beats = ctx.playhead_beats(); let buffer_end_beats = ctx.buffer_end_beats(); + // While recording into this track, every clip EXCEPT the one being recorded into is + // silenced. Otherwise a take folder already sitting in the cycle region would play its + // active take underneath you on every pass, fighting the part you're trying to record. + // The recording clip itself is exempt: in merge mode that's the overdub monitoring. + let muted_clip = match ctx.recording_midi { + Some((track_id, clip_id)) if track_id == self.id => Some(clip_id), + _ => None, + }; + // Collect MIDI events from all clip instances that overlap with current beat range let mut currently_active = HashSet::new(); for instance in &self.clip_instances { + if muted_clip.is_some_and(|recording| instance.clip_id != recording) { + continue; + } if instance.overlaps_range(playhead_beats, buffer_end_beats) { currently_active.insert(instance.id); } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index e14a19b..e475179 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -133,6 +133,12 @@ pub enum Command { SetLoopRegion(Option<(Beats, Beats)>), /// Enable/disable wrapping at the cycle region's end. SetLoopEnabled(bool), + /// How a cycle MIDI recording treats its passes. + /// + /// `false` (default) = MERGE: every pass overdubs into one clip. `true` = SEPARATE TAKES: each + /// pass becomes its own MIDI clip, and the editor folds them into a take folder — the same shape + /// audio always gets. + SetCycleMidiSeparateTakes(bool), // Recording commands /// Start recording on a track (track_id, start_time) @@ -313,6 +319,19 @@ pub enum AudioEvent { RecordingProgress(ClipId, Seconds), /// Recording stopped (clip_id, pool_index, waveform) RecordingStopped(ClipId, usize, Vec), + /// A MIDI recording that wrapped the cycle region at least once, in SEPARATE TAKES mode. + /// + /// One MIDI clip per pass, in recording order. (Merge mode emits the ordinary + /// `MidiRecordingStopped` instead — all passes are already folded into the one clip.) + MidiCycleRecordingStopped { + track_id: TrackId, + /// One pool MIDI clip per pass. The first is the clip the recording started on. + clip_ids: Vec, + /// Where the takes sit on the timeline — the cycle region's start. + loop_start: Beats, + /// The region's length in beats: every take spans exactly this. + loop_len_beats: Beats, + }, /// A recording that wrapped the cycle region at least once, and so became multi-take. /// /// Each take spans the full region and they're all the same length (partial passes are padded diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs new file mode 100644 index 0000000..b32626e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs @@ -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, + + // Stored during execute for rollback. + old_take_count: usize, + old_active_take: Option, + executed: bool, +} + +impl AppendTakesAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, clip_id: Uuid, new_takes: Vec) -> 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 = 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) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 153b065..148c1f6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -15,6 +15,7 @@ pub mod reorder_clip_instances; pub mod paint_bucket; pub mod remove_effect; pub mod set_cycle_region; +pub mod append_takes; pub mod set_active_take; pub mod set_document_properties; pub mod set_instance_properties; @@ -53,6 +54,7 @@ pub mod resize_text_box; pub use add_clip_instance::AddClipInstanceAction; pub use set_cycle_region::SetCycleRegionAction; +pub use append_takes::AppendTakesAction; pub use set_active_take::SetActiveTakeAction; pub use add_effect::AddEffectAction; pub use add_layer::AddLayerAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index a5ec6b6..a7b8d67 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -932,6 +932,46 @@ impl Document { 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. /// /// The clip is the only thing that knows whether its content is measured in seconds or beats, so diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index a724288..8fb4344 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -35,6 +35,18 @@ pub struct AppConfig { #[serde(default = "defaults::audio_buffer_size")] pub audio_buffer_size: u32, + /// How a cycle MIDI recording treats its passes. + /// + /// `false` (default) = **merge**: every pass overdubs into one clip, and earlier passes play back + /// as you record so you can layer against them. `true` = **separate takes**: each pass becomes + /// its own take in a take folder, exactly as audio always does, and earlier passes stay silent + /// (they're alternatives, not layers). + /// + /// Only applies when the transport actually wraps; a recording that stops inside the first pass + /// is an ordinary single recording either way. + #[serde(default = "defaults::cycle_midi_separate_takes")] + pub cycle_midi_separate_takes: bool, + /// Reopen last session on startup #[serde(default = "defaults::reopen_last_session")] pub reopen_last_session: bool, @@ -90,6 +102,7 @@ impl Default for AppConfig { file_height: defaults::file_height(), scroll_speed: defaults::scroll_speed(), audio_buffer_size: defaults::audio_buffer_size(), + cycle_midi_separate_takes: defaults::cycle_midi_separate_takes(), reopen_last_session: defaults::reopen_last_session(), restore_layout_from_file: defaults::restore_layout_from_file(), debug: defaults::debug(), @@ -296,6 +309,7 @@ mod defaults { pub fn file_height() -> u32 { 600 } pub fn scroll_speed() -> f64 { 1.0 } pub fn audio_buffer_size() -> u32 { 256 } + pub fn cycle_midi_separate_takes() -> bool { false } pub fn reopen_last_session() -> bool { false } pub fn restore_layout_from_file() -> bool { true } pub fn debug() -> bool { false } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index bef3d97..f9bfe5c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -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, +} + struct EditorApp { layouts: Vec, current_layout_index: usize, @@ -1213,6 +1225,10 @@ struct EditorApp { metronome_enabled: bool, // Whether metronome clicks during recording count_in_enabled: bool, // Whether count-in fires before recording recording_clips: HashMap, // layer_id -> backend clip_id during recording + /// Cycle takes waiting to be folded into an existing take folder. Queued from the audio-event + /// loop (which holds a borrow on the event queue, so it can't call a `&mut self` method) and + /// drained just after it. + pending_take_appends: Vec, recording_start_time: f64, // Playback time when recording started recording_layer_ids: Vec, // Layers being recorded to (for creating clips) // Asset drag-and-drop state @@ -1581,6 +1597,7 @@ impl EditorApp { metronome_enabled: false, // Metronome off by default count_in_enabled: false, // Count-in off by default recording_clips: HashMap::new(), // No active recording clips + pending_take_appends: Vec::new(), recording_start_time: 0.0, // Will be set when recording starts recording_layer_ids: Vec::new(), // Will be populated when recording starts dragging_asset: None, // No asset being dragged initially @@ -2036,6 +2053,88 @@ impl EditorApp { /// 2. For MIDI: Loads the default instrument /// 3. Stores the bidirectional mapping /// 4. Syncs any existing clips on the layer + /// Fold freshly-recorded cycle takes into an existing take folder covering the same region, if + /// there is one. Returns true if it did. + /// + /// Recording more takes over a region you've already recorded should extend that folder, not + /// stack a second clip on top of it — otherwise the new takes are stranded in an overlapping + /// clip and you can't audition them against the ones already there. + /// + /// The in-progress recording's own clip and instance are throwaway scaffolding in this case (the + /// takes themselves already live in the backend pools), so they're discarded. They were never + /// committed as an action, so there's nothing on the undo stack to unwind — the single + /// `AppendTakesAction` is the whole undoable step. + fn append_cycle_takes( + &mut self, + layer_id: uuid::Uuid, + recording_instance_id: uuid::Uuid, + recording_clip_id: uuid::Uuid, + loop_start: Beats, + loop_len: Beats, + takes: Vec, + ) -> 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) { use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; @@ -2052,6 +2151,9 @@ impl EditorApp { let mut controller = controller_arc.lock().unwrap(); controller.set_loop_region(region); controller.set_loop_enabled(enabled); + // Cycle MIDI mode is a *preference*, not document state, so it rides along here + // rather than through an action. + controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes); } } @@ -6570,6 +6672,35 @@ impl eframe::App for EditorApp { self.autosave.pending_event = true; let last_take = takes.len() - 1; + let new_takes: Vec = takes + .iter() + .map(|&(pool_index, _)| lightningbeam_core::clip::AudioTake { + // Renumbered by the folder that ends up owning them. + name: String::new(), + content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index }, + }) + .collect(); + + // If this region already holds a take folder, extend it rather + // than dropping a second clip on top. Queued and run just after + // this loop, which holds a borrow on the event queue. + if self.action_executor.document() + .take_folder_at(&layer_id, loop_start, loop_len_beats, &instance_id) + .is_some() + { + self.pending_take_appends.push(PendingTakeAppend { + layer_id, + recording_instance_id: instance_id, + recording_clip_id: clip_id, + 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. { let doc = self.action_executor.document_mut(); @@ -6844,6 +6975,141 @@ impl eframe::App for EditorApp { } ctx.request_repaint(); } + AudioEvent::MidiCycleRecordingStopped { track_id, clip_ids, loop_start, loop_len_beats } => { + println!("🎹 MIDI cycle recording stopped: {} takes", clip_ids.len()); + + // Pull every take's events into the cache — the user can switch to any of + // them, not just the active one. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for &take_clip_id in &clip_ids { + if let Ok(data) = controller.query_midi_clip(track_id, take_clip_id) { + self.midi_event_cache.insert(take_clip_id, data.events); + } + } + } + + let layer_id = self.track_to_layer_map.get(&track_id).copied(); + if let (Some(layer_id), Some(&first_clip_id)) = (layer_id, clip_ids.first()) { + // The doc clip is the one the recording started on — which the backend + // reused as take 1. + let doc_clip_id = self.action_executor.document() + .audio_clip_by_midi_clip_id(first_clip_id) + .map(|(id, _)| id); + + if let Some(doc_clip_id) = doc_clip_id { + self.autosave.pending_event = true; + let last_take = clip_ids.len() - 1; + + let recording_instance_id = self.action_executor.document() + .get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Audio(al) = l { + al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).map(|ci| ci.id) + } else { None }); + + let new_takes: Vec = clip_ids + .iter() + .map(|&mid| lightningbeam_core::clip::AudioTake { + // Renumbered by the folder that ends up owning them. + name: String::new(), + content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid }, + }) + .collect(); + + // If this region already holds a take folder, extend it rather + // than dropping a second clip on top. Queued and run just after + // this loop, which holds a borrow on the event queue. + let existing_folder = recording_instance_id.filter(|rec_inst| { + self.action_executor.document() + .take_folder_at(&layer_id, loop_start, loop_len_beats, rec_inst) + .is_some() + }); + if let Some(rec_inst) = existing_folder { + self.pending_take_appends.push(PendingTakeAppend { + layer_id, + recording_instance_id: rec_inst, + recording_clip_id: doc_clip_id, + 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) => { println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes", 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) { 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 { println!("⚠️ Audio buffer size will be applied on next app restart"); } + // Cycle MIDI mode takes effect immediately — no restart needed, unlike the buffer size. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes); + } // Apply new keybindings if changed if let Some(new_keymap) = result.new_keymap { self.keymap = new_keymap; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index fa3cd2e..5586901 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -465,7 +465,9 @@ impl PianoRollPane { if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) { for instance in &audio_layer.clip_instances { if let Some(clip) = document.audio_clips.get(&instance.clip_id) { - if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { + // 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()); // A MIDI clip's content time IS beats, which is what the piano roll's // x-axis uses. diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs index 7ef5391..8b19e6f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs @@ -55,6 +55,7 @@ struct PreferencesState { file_height: u32, scroll_speed: f64, audio_buffer_size: u32, + cycle_midi_separate_takes: bool, reopen_last_session: bool, restore_layout_from_file: bool, debug: bool, @@ -72,6 +73,7 @@ impl From<(&AppConfig, &Theme)> for PreferencesState { file_height: config.file_height, scroll_speed: config.scroll_speed, audio_buffer_size: config.audio_buffer_size, + cycle_midi_separate_takes: config.cycle_midi_separate_takes, reopen_last_session: config.reopen_last_session, restore_layout_from_file: config.restore_layout_from_file, debug: config.debug, @@ -91,6 +93,7 @@ impl Default for PreferencesState { file_height: 600, scroll_speed: 1.0, audio_buffer_size: 256, + cycle_midi_separate_takes: false, reopen_last_session: false, restore_layout_from_file: true, debug: false, @@ -543,6 +546,39 @@ impl PreferencesDialog { }); ui.label("Requires app restart to take effect"); + + ui.separator(); + + ui.horizontal(|ui| { + ui.label("Cycle MIDI recording:"); + + egui::ComboBox::from_id_salt("cycle_midi_mode") + .selected_text(if self.working_prefs.cycle_midi_separate_takes { + "Separate takes" + } else { + "Merge" + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.working_prefs.cycle_midi_separate_takes, + false, + "Merge", + ) + .on_hover_text( + "Every pass overdubs into one clip, and earlier passes play back as \ + you record so you can layer against them.", + ); + ui.selectable_value( + &mut self.working_prefs.cycle_midi_separate_takes, + true, + "Separate takes", + ) + .on_hover_text( + "Each pass becomes its own take in a take folder, as audio always \ + does. Earlier passes stay silent — they're alternatives, not layers.", + ); + }); + }); }); } @@ -641,6 +677,7 @@ impl PreferencesDialog { temp_config.file_height = self.working_prefs.file_height; temp_config.scroll_speed = self.working_prefs.scroll_speed; temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size; + temp_config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes; temp_config.reopen_last_session = self.working_prefs.reopen_last_session; temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; temp_config.debug = self.working_prefs.debug; @@ -675,6 +712,7 @@ impl PreferencesDialog { config.file_height = self.working_prefs.file_height; config.scroll_speed = self.working_prefs.scroll_speed; config.audio_buffer_size = self.working_prefs.audio_buffer_size; + config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes; config.reopen_last_session = self.working_prefs.reopen_last_session; config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; config.debug = self.working_prefs.debug;