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:
parent
6629adc7d2
commit
c62164c365
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue