loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone

This commit is contained in:
2026-07-31 17:36:36 -04:00
parent a90ccd9a00
commit 0fe4166d7d
25 changed files with 991 additions and 64 deletions
+57
View File
@@ -5,6 +5,7 @@
//
// Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width);
// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection);
// markerHandleRect (the top-strip tab that keeps coincident markers independently grabbable);
// resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width
// no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower,
// no-crossing keeps target, target clamp, degenerate buffers); waveformSurface (two stacked
@@ -309,6 +310,57 @@ static void testMarkerGrabInMonoSpansTheBand() {
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.bottom() + 5) == -1);
}
// --- The marker grab handle ----------------------------------------------------
static void testMarkerHandleIsATopStripCentredOnTheMarker() {
const Rect a = wideArea();
const int mx = frameToX(overlayOf(a), 1000, 250);
const Rect h = markerHandleRect(overlayOf(a), 1000, 250);
CHECK(h.x == mx - kMarkerHandleHalfWidth);
CHECK(h.right() == mx + kMarkerHandleHalfWidth + 1);
CHECK(h.y == a.y);
CHECK(h.height == kMarkerHandleHeight);
CHECK(contains(h, mx, a.y));
CHECK(contains(h, mx, a.y + kMarkerHandleHeight - 1));
CHECK(!contains(h, mx, a.y + kMarkerHandleHeight)); // below the strip is the column's
}
// The whole reason the handle exists: two markers that share a frame both stay reachable —
// markerAtPoint gives its full-height column to the first in draw order, and the handle owns
// the strip above. Without the split, the loser could never be dragged apart again.
static void testCoincidentMarkersStayIndependentlyGrabbable() {
const Rect a = wideArea();
const std::int64_t markers[2] = {250, 250};
const int mx = frameToX(overlayOf(a), 1000, 250);
// The column resolves to the first marker at every height, including the top strip.
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 2, mx, a.y) == 0);
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 2, mx, a.bottom() - 1) == 0);
// The handle, asked first, resolves the second one in that same top strip.
CHECK(contains(markerHandleRect(overlayOf(a), 1000, 250), mx, a.y));
CHECK(!contains(markerHandleRect(overlayOf(a), 1000, 250), mx, a.bottom() - 1));
}
static void testMarkerHandleClipsIntoTheArea() {
const Rect a = wideArea();
// At the last frame the marker maps to right(); an unclipped tab would claim pixels
// outside the band the caller already hit-tested.
const Rect hi = markerHandleRect(overlayOf(a), 1000, 1000);
CHECK(hi.right() == a.right());
CHECK(!contains(hi, a.right(), a.y));
CHECK(contains(hi, a.right() - 1, a.y));
// And at frame 0 it cannot reach left of the band.
const Rect lo = markerHandleRect(overlayOf(a), 1000, 0);
CHECK(lo.x == a.x);
CHECK(!contains(lo, a.x - 1, a.y));
}
static void testMarkerHandleOnDegenerateAreas() {
CHECK(markerHandleRect(overlayOf(Rect{}), 1000, 0).empty());
// A band shorter than the strip yields a handle the height of the band, never taller.
const Rect thin = Rect{0, 0, 100, 4};
CHECK(markerHandleRect(overlayOf(thin), 1000, 500).height == 4);
}
// --- Per-lane envelope content -------------------------------------------------
static void testAsymmetricStereoLanesCarryDifferentContent() {
@@ -379,6 +431,11 @@ int main() {
testMarkerGrabReachesTheLowerStereoLane();
testMarkerGrabInMonoSpansTheBand();
testMarkerHandleIsATopStripCentredOnTheMarker();
testCoincidentMarkersStayIndependentlyGrabbable();
testMarkerHandleClipsIntoTheArea();
testMarkerHandleOnDegenerateAreas();
testAsymmetricStereoLanesCarryDifferentContent();
testLaneEnvelopeRejectsOutOfRangeLane();