# src/core/instrument/engine/loop — the sustain loop's span rule

## Scope

One pure module, `loop_span`: the ONE fold that turns a stored `SampleLoop` + crossfade
length into the `ResolvedLoop` the voice's read path wraps on, plus the editor's default
handle placement for a capture with no loop. No REAPER, no VST3, no allocation, no I/O.
Everything lives in `reasampler::instrument::engine::loop`.

`resolveLoop` is cold — called once per note-on and by the editor. `crossfadeWeight` is
header-inline because it is evaluated per voice per sample.

## Invariants

### The crossfade is PRE-SEAM, and that is what makes it one tap

The fade runs over the last `crossfade` frames before `end`, blending the material running
into `end` toward the material running into `start`. The incoming material is the same read
head one loop length earlier, so the second tap is `pos - length` — no second position to
advance, no second wrap rule, no state. At `end` the incoming tap has arrived at `start`,
which is exactly where the wrap puts the head, so the seam is continuous by construction
rather than by a fade that merely hides it.

The consequence is a hard clamp: **`crossfade <= start`**. A loop starting at frame 0 has no
material ahead of it and therefore gets no crossfade, whatever the user dialled — honest
rather than silently reading before the buffer.

### The weight is normalized over `crossfade - 1`, so the last rendered frame lands AT 1

`crossfadeWeight` normalizes by `1/(crossfade-1)`, not `1/crossfade`: `d` at the last rendered
frame (`end - 1`) is always exactly `crossfade - 1` — the ceiling's own threshold — for every
REAL crossfade length (`crossfade >= 2`), so that frame is the incoming tap outright rather than
a blend approaching it. The seam across the wrap is therefore the material's OWN one-frame step
(`sampler_core_tests`, `testSeamStepMatchesTheNaturalStepForAnyCrossfade`), not a residual that
merely shrinks with a longer fade — the earlier `1/crossfade` normalization left `(xf-1)/xf` at
that frame, which is what the `crossfade - 1` fix closes. `crossfade == 1` degenerates to the
hard seam instead: its one frame sits at `d == 0`, caught by the `d <= 0` floor before the
multiply/ceiling ever runs, so `fadeInv` is guarded to 0 rather than dividing by zero.

### Linear, not equal-power

The two taps are one loop length apart in the same material and are usually well correlated,
where an equal-power pair bulges. Linear also costs a subtract and a multiply on a path that
forbids a transcendental. The filter's morph crossfade is equal-power for a reason specific
to quadrature taps (`engine/filter/CLAUDE.md`) — that reasoning does not transfer here.

**Known exception:** a full-mix or stem bounce (in this tool's own stated material scope) is
not quasi-periodic, so its two taps are effectively decorrelated — a linear pair then dips
~3 dB at the fade midpoint the way it wouldn't on a correlated tonal/one-shot loop. Accepted
rather than fixed: an equal-power pair would cost the transcendental this path forbids, and the
dip is a fade-region loudness wobble, not the seam click the crossfade exists to kill.

### An invalid span is refused, never repaired

An inverted span, a span reaching past the PCM, a negative start, a Trigger voice: all yield
`active == false`, so the note plays straight through. Repairing a corrupt span into a
plausible one would make a wrong loop audible and a bug invisible; the crossfade length is
the one field that IS clamped rather than refused, because its bound is a property of the
loop it sits in rather than of the user's intent.

## Gotchas

- **`crossfadeWeight` assumes its argument is already wrapped** into `[start, end)`. The
  ceiling at 1.0 is a belt against an unwrapped caller, not permission to skip the wrap —
  an unwrapped position would otherwise extrapolate past the incoming tap.
- **`defaultLoopBounds` is a UI default living in an engine module** on purpose: the span the
  user is offered and the span `resolveLoop` will accept have to be one definition, and the
  previous frame-0 default put the loop-start handle underneath the start marker where no
  grab could reach it.
