From 6629adc7d29ff09ff4b38cf590874a98711264d5 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 14 Jul 2026 09:31:23 -0400 Subject: [PATCH] Cycle recording: monitor the MIDI overdub on later passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In merge mode every pass layers into the same clip, so a later pass has to PLAY BACK what earlier passes laid down — otherwise you overdub against silence, which defeats the point of merging (you can't put a hi-hat on a kick you can't hear). Two things stood in the way, and they turned out to be the same bug: - The captured notes only reached the backend's MIDI pool clip at STOP, so during the session the sequencer had nothing to schedule. The wrap now folds the notes captured so far into the pool clip. Their offsets drop straight in: a cycle MIDI recording is anchored at loop_start, so they're already region-relative. - The recording-progress block resizes the clip instance every audio buffer from `playhead - start_time`. The playhead jumps BACKWARDS at a wrap, so that duration collapsed to zero and grew again on every pass. It reset the clip bar to zero each pass (visible), and it shrank the clip instance back to nothing at each wrap (invisible) — so even once the notes were in the pool, the sequencer saw a zero-length instance and scheduled none of them. Fixed at the root: once the transport has wrapped, the recording spans the whole cycle region and STAYS there — it doesn't track the playhead at all. `cycle_loop_len` (set at the first wrap) pins it, which both holds the clip bar at full region length after pass one and keeps the instance stretched across the region so the merged notes get scheduled. Writing the events reuses the clip's existing Vec, so it's allocation-free after the first wrap; mutating the pool from the audio thread is what Command::UpdateMidiClipNotes already does. --- daw-backend/src/audio/engine.rs | 52 ++++++++++++++++++- .../lightningbeam-editor/src/main.rs | 6 ++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 1f6cba8..6e22ad4 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -543,6 +543,47 @@ impl Engine { rec.wrap_at_cycle(le_beats, ls_beats); } + // 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). + // + // 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() { + if let Some(clip) = + self.project.midi_clip_pool.get_clip_mut(rec.clip_id) + { + // Note offsets are relative to `start_time`, which for a cycle + // recording IS the region start — so they're already region-relative + // and drop straight in. + clip.duration = le_beats - ls_beats; + // Reusing the existing Vec keeps this allocation-free after the + // first wrap, which matters on the audio thread. (Mutating the pool + // here is the same thing `Command::UpdateMidiClipNotes` already does + // from this thread.) + clip.events.clear(); + for (start, note, velocity, duration) in rec.get_notes() { + 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() + }); + } + } + // The clip INSTANCE is sized by the recording-progress block above, which + // now pins it to the whole region once `cycle_loop_len` is set (this wrap + // sets it). That's what lets the sequencer actually schedule the events we + // just wrote — an instance sized to the elapsed-since-start playhead would + // have collapsed back to nothing here. + // An audio recording in progress just completed a pass. This only decides // *whether* the recording becomes multi-take — the takes themselves are cut // geometrically at stop, since the playhead advances before the capture @@ -582,7 +623,16 @@ impl Engine { if let Some(recording) = &self.midi_recording_state { let current_time_secs = Seconds(self.playhead as f64 / self.sample_rate as f64); let current_time = self.tempo_map.seconds_to_beats(current_time_secs); - let duration = current_time - recording.start_time; + // Once the transport has wrapped, the recording covers the WHOLE cycle region and + // stays there — every further pass merges into the same clip rather than + // extending it. Measuring from the playhead instead would reset to zero at each + // wrap (the playhead jumps back), which both made the clip bar restart from zero + // every pass and — because this block also resizes the backend clip instance + // below — shrank the instance back to nothing, so the notes just merged into it + // were never scheduled and you overdubbed against silence. + let duration = recording + .cycle_loop_len + .unwrap_or(current_time - recording.start_time); let notes = recording.get_notes_with_active(current_time); let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress( recording.track_id, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 33adcfd..bef3d97 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -6820,7 +6820,11 @@ impl eframe::App for EditorApp { ); } } - // Update the clip's duration so the timeline bar grows + // Update the clip's duration so the timeline bar grows. Once the + // transport has wrapped, the backend reports the whole cycle + // region here and keeps reporting it, so the bar grows through + // the first pass and then holds — every further pass merges into + // the same clip rather than extending it. if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { clip.set_content_duration(ClipDuration::Beats(duration)); }