Loop: an explicit enable, four named marks with grabbable caps, and the crossfade painted where it is actually heard

hasLoop becomes user-owned with the gestures as shortcuts onto it; no format change. START uses overlay/trace, not accent/primary, which is the waveform's own fill.
This commit is contained in:
2026-08-02 05:18:53 -04:00
parent ef59265e7a
commit a7c3c7a828
26 changed files with 1190 additions and 191 deletions
+204 -12
View File
@@ -8,7 +8,10 @@
// 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
// no-crossing keeps target, target clamp, degenerate buffers); the four marks (per-mark cap
// resolve, the reverse cap order that keeps a coincident pair separable, label sides/nudging,
// the suppression rule and its promoted-first placement, the crossfade wedge ramp);
// waveformSurface (two stacked
// lanes L-over-R in stereo, one lane in mono AND for a mono source, overlay always the full
// stacked height, grabs reaching the lower lane); laneEnvelope (per-lane channel split).
@@ -361,18 +364,18 @@ static void testMarkerHandleOnDegenerateAreas() {
CHECK(markerHandleRect(overlayOf(thin), 1000, 500).height == 4);
}
// The shell (editor_input_waveform.cpp) checks the loop crossfade's own grab handle — at
// loopStart - crossfade — before it iterates the ordinary marker array, because a zero-length
// fade puts that handle exactly on the loop-start marker's frame. The same coincidence recurs
// whenever ANY marker shares that frame, most plausibly the START marker dragged up against the
// fade edge: this module can't exercise the shell's check-order itself, but it can prove the
// geometric ambiguity that makes the ordering load-bearing — the array's own first-match rule
// would otherwise resolve the top strip to the START marker, not the fade handle.
// The shell (editor_input_waveform.cpp) resolves a mark's CAP before it iterates the ordinary
// marker array, because a zero-length fade puts the crossfade cap exactly on the loop-end
// marker's frame. The same coincidence recurs whenever ANY marker shares that frame, most
// plausibly the START marker dragged up against the fade edge: this module can't exercise the
// shell's check-order itself, but it can prove the geometric ambiguity that makes the ordering
// load-bearing — the array's own first-match rule would otherwise resolve the top strip to the
// START marker, not the fade handle.
static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() {
const Rect a = wideArea();
const std::int64_t loopStart = 400, crossfade = 30;
const std::int64_t fadeEdge = loopStart - crossfade; // where the crossfade handle sits
const std::int64_t markers[3] = {fadeEdge, loopStart, loopStart + 100}; // start dialled here
const std::int64_t loopEnd = 400, crossfade = 30;
const std::int64_t fadeEdge = loopEnd - crossfade; // where the crossfade cap sits
const std::int64_t markers[3] = {fadeEdge, 200, loopEnd}; // start dialled onto the fade edge
const int mx = frameToX(overlayOf(a), 1000, fadeEdge);
const int topY = a.y; // inside the handle's top strip
// Without the shell's priority check, the array's own first-match rule already resolves the
@@ -380,10 +383,188 @@ static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() {
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 3, mx, topY) == 0);
// ...and the fade handle's rect claims the exact same pixel — the ambiguity the shell
// resolves by smallest-target-first (the handle's clipped tab is always the narrower
// target), same as it does for the zero-fade/loop-start case.
// target), same as it does for the zero-fade/loop-end case.
CHECK(contains(markerHandleRect(overlayOf(a), 1000, fadeEdge), mx, topY));
}
// --- The four marks: cap resolve, labels, suppression, crossfade wedge ----------
static WaveMarks marksAt(std::int64_t start, std::int64_t loopStart, std::int64_t loopEnd,
std::int64_t xfade, bool loopPresent) {
WaveMarks m;
m.frame[0] = start;
m.frame[1] = loopStart;
m.frame[2] = loopEnd;
m.frame[3] = xfade;
m.present[0] = true;
m.present[1] = m.present[2] = m.present[3] = loopPresent;
return m;
}
static void testEveryMarkAnswersItsOwnCap() {
const Rect a = wideArea();
const OverlayArea ov = overlayOf(a);
const WaveMarks m = marksAt(50, 300, 700, 620, true);
for (int i = 0; i < kWaveMarkCount; ++i) {
const int mx = frameToX(ov, 1000, m.frame[i]);
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == i);
CHECK(capAtPoint(ov, 1000, m, mx, a.y + kMarkerHandleHeight - 1) == i);
// Below the cap strip is the column's, never the cap's.
CHECK(capAtPoint(ov, 1000, m, mx, a.y + kMarkerHandleHeight) == -1);
}
}
static void testAMarkThatIsNotPresentAnswersNoCap() {
const Rect a = wideArea();
const OverlayArea ov = overlayOf(a);
const WaveMarks m = marksAt(50, 300, 700, 620, /*loopPresent=*/false);
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 300), a.y) == -1);
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 620), a.y) == -1);
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 50), a.y) == 0); // START stays live
}
// The separability argument the reverse cap order exists for: for a coincident PAIR, one mark
// answers the cap and the OTHER answers the full-height column, so neither is ever stranded.
static void testACoincidentPairStaysSeparableAcrossCapAndColumn() {
const Rect a = wideArea();
const OverlayArea ov = overlayOf(a);
const int midY = a.y + a.height / 2;
// Zero-length fade: the crossfade mark sits at loopEnd - 0, i.e. exactly on the END marker.
// This is the live case — the crossfade is anchored to the seam it closes.
{
const WaveMarks m = marksAt(50, 300, 700, 700, true);
const int mx = frameToX(ov, 1000, 700);
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast<int>(WaveMark::kCrossfade));
const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]};
CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast<int>(WaveMark::kLoopEnd));
}
// START dragged onto the loop start: the cap goes to LOOP, the column to START.
{
const WaveMarks m = marksAt(300, 300, 700, 100, true);
const int mx = frameToX(ov, 1000, 300);
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast<int>(WaveMark::kLoopStart));
const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]};
CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast<int>(WaveMark::kStart));
}
// START dragged onto the loop end: the cap goes to END, the column to START.
{
const WaveMarks m = marksAt(700, 300, 700, 100, true);
const int mx = frameToX(ov, 1000, 700);
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast<int>(WaveMark::kLoopEnd));
const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]};
CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast<int>(WaveMark::kStart));
}
}
// The crossfade is the one mark with NO full-height column, so it must never lose a cap tie.
static void testTheCrossfadeCapOutranksEveryOtherMark() {
const Rect a = wideArea();
const OverlayArea ov = overlayOf(a);
const WaveMarks m = marksAt(400, 400, 400, 400, true); // every mark on one frame
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 400), a.y) ==
static_cast<int>(WaveMark::kCrossfade));
}
static void testLabelSidesKeepEachLabelOutOfTheSpanItBounds() {
CHECK(!markLabelLeftOfLine(WaveMark::kStart));
CHECK(!markLabelLeftOfLine(WaveMark::kLoopStart));
CHECK(markLabelLeftOfLine(WaveMark::kLoopEnd));
CHECK(markLabelLeftOfLine(WaveMark::kCrossfade));
const Rect a = wideArea();
const OverlayArea ov = overlayOf(a);
const int mx = frameToX(ov, 1000, 500);
const Rect right = markLabelRect(ov, 1000, 500, /*leftOfLine=*/false, 30);
const Rect left = markLabelRect(ov, 1000, 500, /*leftOfLine=*/true, 30);
CHECK(right.x == mx + kMarkLabelGap && right.width == 30);
CHECK(left.right() == mx - kMarkLabelGap && left.width == 30);
// Directly under the cap strip, so caps and labels never fight for the same pixels.
CHECK(right.y == a.y + kMarkerHandleHeight && right.height == kMarkLabelHeight);
CHECK(left.y == right.y);
}
static void testALabelIsNudgedInsideTheAreaRatherThanClipped() {
const Rect a = wideArea();
const OverlayArea ov = overlayOf(a);
// At frame 0 a right-side label would still fit; at the last frame it would overhang.
const Rect atEnd = markLabelRect(ov, 1000, 1000, /*leftOfLine=*/false, 40);
CHECK(atEnd.width == 40);
CHECK(atEnd.right() == a.right());
const Rect atStart = markLabelRect(ov, 1000, 0, /*leftOfLine=*/true, 40);
CHECK(atStart.width == 40);
CHECK(atStart.x == a.x);
// Wider than the whole band, or no band to draw in: nothing placed.
CHECK(markLabelRect(ov, 1000, 500, false, a.width + 1).empty());
CHECK(markLabelRect(overlayOf(Rect{0, 0, 200, kMarkerHandleHeight}), 1000, 500, false, 20)
.empty());
}
static void testOverlappingLabelsAreSuppressedInPlacementOrder() {
const Rect a = wideArea();
const OverlayArea ov = overlayOf(a);
// LOOP labels right of its line at 500, XFADE left of its line at 520: the two boxes point
// at each other and cannot both fit. (LOOP and END never collide however close they get —
// their labels point away from the span they bound.)
const WaveMarks m = marksAt(50, 500, 900, 520, true);
const int w[kWaveMarkCount] = {36, 32, 26, 40};
const WaveMarkLabels lab = layoutMarkLabels(ov, 1000, m, w, /*promoted=*/-1);
CHECK(!lab.box[0].empty()); // START, far away, always placed
CHECK(!lab.box[1].empty()); // LOOP placed before XFADE, so LOOP wins
CHECK(!lab.box[2].empty()); // END, far away, always placed
CHECK(lab.box[3].empty()); // XFADE suppressed
// Every placed box is disjoint from every other.
for (int i = 0; i < kWaveMarkCount; ++i) {
for (int j = i + 1; j < kWaveMarkCount; ++j) {
if (lab.box[i].empty() || lab.box[j].empty()) continue;
CHECK(lab.box[i].x >= lab.box[j].right() || lab.box[j].x >= lab.box[i].right());
}
}
}
// The promoted mark is placed FIRST, so grabbing or hovering a mark always shows its label —
// even the one the resting layout suppresses.
static void testThePromotedMarkIsNeverTheSuppressedOne() {
const Rect a = wideArea();
const OverlayArea ov = overlayOf(a);
const WaveMarks m = marksAt(50, 500, 900, 520, true);
const int w[kWaveMarkCount] = {36, 32, 26, 40};
CHECK(layoutMarkLabels(ov, 1000, m, w, -1).box[3].empty()); // XFADE suppressed at rest
const WaveMarkLabels grabbed =
layoutMarkLabels(ov, 1000, m, w, static_cast<int>(WaveMark::kCrossfade));
CHECK(!grabbed.box[3].empty()); // and placed when it is the one being grabbed
CHECK(grabbed.box[1].empty()); // LOOP yields to it instead
}
static void testAbsentMarksTakeNoLabel() {
const Rect a = wideArea();
const OverlayArea ov = overlayOf(a);
const WaveMarks m = marksAt(50, 300, 700, 620, /*loopPresent=*/false);
const int w[kWaveMarkCount] = {36, 32, 26, 40};
const WaveMarkLabels lab = layoutMarkLabels(ov, 1000, m, w, -1);
CHECK(!lab.box[0].empty());
CHECK(lab.box[1].empty() && lab.box[2].empty() && lab.box[3].empty());
}
static void testTheCrossfadeWedgeRampsToItsPeakAtTheSeam() {
// Zero at the fade's start, the peak at its last column, monotone in between.
CHECK(crossfadeWedgeHeight(100, 200, 100) == 0);
CHECK(crossfadeWedgeHeight(100, 200, 199) == kCrossfadeWedgePx);
int prev = -1;
for (int x = 100; x < 200; ++x) {
const int h = crossfadeWedgeHeight(100, 200, x);
CHECK(h >= prev);
CHECK(h >= 0 && h <= kCrossfadeWedgePx);
prev = h;
}
// Outside the span it contributes nothing, so a caller can sweep a wider range safely.
CHECK(crossfadeWedgeHeight(100, 200, 99) == 0);
CHECK(crossfadeWedgeHeight(100, 200, 200) == 0);
// Degenerate spans: an empty one draws nothing, a one-column one is all peak.
CHECK(crossfadeWedgeHeight(100, 100, 100) == 0);
CHECK(crossfadeWedgeHeight(100, 99, 100) == 0);
CHECK(crossfadeWedgeHeight(100, 101, 100) == kCrossfadeWedgePx);
}
// --- Per-lane envelope content -------------------------------------------------
static void testAsymmetricStereoLanesCarryDifferentContent() {
@@ -460,6 +641,17 @@ int main() {
testMarkerHandleOnDegenerateAreas();
testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge();
testEveryMarkAnswersItsOwnCap();
testAMarkThatIsNotPresentAnswersNoCap();
testACoincidentPairStaysSeparableAcrossCapAndColumn();
testTheCrossfadeCapOutranksEveryOtherMark();
testLabelSidesKeepEachLabelOutOfTheSpanItBounds();
testALabelIsNudgedInsideTheAreaRatherThanClipped();
testOverlappingLabelsAreSuppressedInPlacementOrder();
testThePromotedMarkIsNeverTheSuppressedOne();
testAbsentMarksTakeNoLabel();
testTheCrossfadeWedgeRampsToItsPeakAtTheSeam();
testAsymmetricStereoLanesCarryDifferentContent();
testLaneEnvelopeRejectsOutOfRangeLane();