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:
+204
-12
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user