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.
Recording into a cycle region now produces one take per pass. Pick a take
from a badge on the clip; split the clip and pick different takes on the
halves, and you've comped.
Data model (phase 2):
- AudioClipType::TakeFolder { takes, recorded_loop_beats } holds the take
list on the CLIP; ClipInstance::active_take holds the selection on the
INSTANCE. That split is what makes comping fall out of the existing
split action for free — split clones the instance, so the two halves
share one take list but choose independently. recorded_loop_beats lets
a future time-stretch/conform pass reconcile audio takes if the tempo
moves under them.
- AudioClip::resolve(active_take) -> ResolvedContent{Audio|Midi|Recording}
collapses a take folder to what an instance actually plays. A folder is
not a distinct *case* at call sites — it's an audio or MIDI clip whose
identity depends on which take is live — so every backend-sync site now
resolves through this instead of matching clip_type raw. Reverse lookups
go through owns_audio_pool_index/owns_midi_clip_id, since a folder owns
one pool file per take, not just the active one.
- BackendContext::add_clip_instance/remove_clip_instance: switching takes
is a remove + re-add (there's no in-place pool-swap command), and that's
the same work AddClipInstanceAction does. One implementation, on the
context that already owns the controller and both ID maps, so the
seconds-vs-beats conversions can't drift between copies.
Capture (phase 3):
- Takes are cut GEOMETRICALLY at stop, in exact loop-length multiples. The
playhead advances before the capture block in process(), so the wrap
instant isn't sample-exact against the buffer just captured — but the
geometry is. wrap_count only decides *whether* the recording is
multi-take, never where the cuts land.
- Partial passes are padded with silence: punch in mid-region and take 1
gets silence prepended back to the region start; stop mid-pass and the
last take gets silence appended. Every take is the same length, which is
the invariant comping depends on. A final take under 50ms of real audio
is dropped as a stop artifact (but a take that FILLED the region never
is, however short the region).
- MIDI merges, and it falls out for free: anchoring the recording at
loop_start rather than the punch-in point means the transport always
wraps back INTO the region, so every note's offset already lands inside
[0, loop_len) and passes overdub with no folding logic at all.
- The whole session commits as ONE undoable action via push_applied.
Fixes found on the way:
- Split was seconds/beats confused on MIDI. trim_start/trim_end are
domain-polymorphic exactly like AudioClip::duration was — SECONDS for
audio/video/vector, BEATS for MIDI — and split mapped the split point
into clip content in seconds unconditionally. Now it works in the clip's
own domain via Document::clip_trim_duration(). Regression test included.
- TrimClip took raw f64s whose meaning flipped by track type, and the
engine set an AUDIO clip's external_duration = Beats(end - start) where
those bounds were SECONDS — so a 1-second split played back as half a
second at 120 BPM. Replaced with a domain-tagged TrimRange, built from
the clip (clip.trim_range()) so the wrong unit isn't expressible, and
the span is now converted at the clip's position on the timeline.
- The live preview grew past the loop end while the playhead wrapped. It
now grows through the first pass then pins at the region length, and the
waveform inside restarts at the region start on each pass. The pass
offset is derived from the captured buffer, not the playhead — those
advance on different clocks, and differencing them made the waveform
jitter horizontally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a cycle (loop) region: a range on the timeline ruler that the
transport wraps at during playback. This is the substrate for
GarageBand-style multi-take cycle recording (phases 2 and 3).
The region is authored in BEATS so it stays put musically across tempo
changes. It lives on the Document (saved in the .beam, serde-defaulted
so old files load) and is edited through SetCycleRegionAction, so it is
undoable and marks the document modified like any other edit.
Backend:
- Engine gains loop_region/loop_enabled plus a wrap at the single
playhead-advance point in process(). The wrap is phase-preserving
(modulo, so an overshoot larger than the loop can't strand the
playhead outside the region) and gated on playhead >= 0 so a count-in
pre-roll never wraps.
- Sounding voices are deliberately NOT reset at the wrap the way
Command::Seek does, since that would chop sustain and reverb tails at
every pass.
- MidiRecordingState::wrap_at_cycle writes note-offs for held notes at
the region end and re-opens them at the region start, so a key held
across the boundary can't end up with a negative duration or hang.
- loop_bounds_frozen freezes the region's sample bounds for the duration
of an *audio* recording. Phrased positively on audio so future
multi-track recording inherits it: MIDI is beat-segmented and
tempo-invariant, but audio is segmented geometrically and we have no
time-stretch, so cross-tempo audio takes wouldn't be compable anyway.
- Command::Play jumps to loop_start when starting from outside the
region; starting inside it plays from where you are.
Editor:
- Cycle lane along the bottom of the ruler (bottom, so it doesn't cover
the bar numbers), painted inside render_ruler under the ticks. Only
exists while looping is armed; with cycle off the ruler is entirely
the playhead scrubber, as before.
- Drag to create/move/resize with a three-zone hit test, previewed
locally and committed as ONE action on release. Driven off raw pointer
state rather than an egui Response: the lane sits inside the timeline's
content response, and a second widget on the same pixels just contests
hover every frame.
- snap_to_grid/quantize_grid_size take a min_grid_px "visual coarseness"
parameter instead of a hardcoded constant, with two named profiles:
SNAP_PX_FINE for the playhead and clip edges, SNAP_PX_CYCLE (coarser)
for the cycle region, so loops land on bars rather than odd
subdivisions.
- Cycle toggle button using the Lucide repeat glyph.
Cargo.lock picks up the 1.0.9-alpha version bump it missed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>