Skip to content

Stop snapping cursor into loop range on play/record start#11

Merged
lucaromagnoli merged 6 commits into
magda_minimalfrom
fix/transport-loop-snap
May 7, 2026
Merged

Stop snapping cursor into loop range on play/record start#11
lucaromagnoli merged 6 commits into
magda_minimalfrom
fix/transport-loop-snap

Conversation

@lucaromagnoli

Copy link
Copy Markdown
Contributor

TransportControl::performPlay and performRecord forcibly relocated the play cursor to loopRange.start whenever the cursor sat outside the loop on play/record start. That diverges from how every modern DAW (Live, Bitwig, Logic, Cubase, Reaper, Studio One) handles loop mode and was the user-visible bug downstream in MAGDA issue #1204.

performPlay now keeps the cursor in place and uses PlayHead's existing setRollInToLoop machinery to play through into the loop when the cursor is before loopStart. Cursor-past-loop plays forward without looping this session — looping re-engages on next play once the cursor is back in or before the region. performRecord just drops the snap; pre-roll and loop+punch composition stay in scope for a follow-up.

TransportControl::performPlay and performRecord forcibly relocated the
play cursor to loopRange.start whenever the cursor sat outside the loop
on play/record start. That diverges from how every modern DAW (Live,
Bitwig, Logic, Cubase, Reaper, Studio One) handles loop mode and was
the user-visible bug downstream in MAGDA issue #1204.

performPlay now keeps the cursor in place and uses PlayHead's existing
setRollInToLoop machinery to play through into the loop when the cursor
is before loopStart. Cursor-past-loop plays forward without looping
this session — looping re-engages on next play once the cursor is back
in or before the region. performRecord just drops the snap; pre-roll
and loop+punch composition stay in scope for a follow-up.
performPlay already treats cursor-past-loop as a non-looping session
(matches the user's expectation that playback continues forward without
jumping back to a loop region the cursor has already passed). performRecord
was missing the equivalent: it called setLoopTimes(true, lr) and
setRollInToLoop(prerollStart) unconditionally on looping=true, and
PlayHead::setRollInToLoop clamps positions to loopEnd, so a record start
with the cursor past the loop ended up rolling at the loop boundary
instead of the cursor.

Compute effectivelyLooping the same way performPlay does, and use it to
gate the looped playhead branch.
…t loopEnd

The previous patch reused TE's snap-trigger predicate (cursor > loopEnd - 0.1s)
to decide whether to disable looping for the play session. Wrong action context:
that 100ms band includes positions still inside the loop, so cursor at e.g. 7.95
in a [4,8] loop ended up running forward to MaxEnd with looping silently off.

Split the logic into two clear cases at play-start:
  - Cursor in the last 100ms of the loop: keep TE's original snap-to-loopStart
    (one-time choice at play, the user just confirmed this is desirable — avoids
    landing at a position that would wrap on the very first sample).
  - Cursor strictly past loopEnd: play forward, no looping this session.

performRecord drops the 0.1s tolerance from the past-loop predicate too — record
just splits past/not-past, no near-end snap (narrow scope).
Previous iteration had two latent issues:

1. The cursor-vs-loop predicate was in seconds. Since the cursor and the
   loop-end both derive from beats via independent paths through the tempo
   sequence, FP drift could put a click that landed "exactly at bar 5" on
   either side of the seconds boundary, flipping play-vs-snap-vs-forward
   behavior arbitrarily. Switched the predicate to operate in beats via
   tempoSequence.toBeats so the boundary is exact in the canonical domain.

2. The periodic setLoopTimes call in performRunning re-asserted
   setLoopTimes(true, loopRange) every ~10 ticks whenever transport.looping
   was on, regardless of what performPlay had decided. This silently
   overrode "no looping this session" decisions: cursor-past-loop play
   would start forward, then a few ticks later the playhead would have its
   loop range set back to the loop region, position past loopEnd would
   wrap, and the user would see playback jump back to loopStart. Gated the
   re-assert on transportState->endTime so the play-session decision
   survives.

The near-end seconds snap is gone — it was only ever there to defend
against the FP boundary the beats comparison now eliminates.
The 8-core Windows runner isn't available to this fork, so the job
queues forever. ubuntu-latest and macos-latest still run.
The 8-core Windows runner isn't available on this fork. Re-enable the
Windows job on the standard windows-latest runner.
@lucaromagnoli lucaromagnoli merged commit ad97a0b into magda_minimal May 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant