Merge Γ-W2-T2: an explicit loop enable, four named marks with grabbable caps, and the crossfade painted where it is heard

This commit is contained in:
2026-08-02 06:32:29 -04:00
28 changed files with 1289 additions and 192 deletions
+22
View File
@@ -1105,6 +1105,27 @@ static void testLoopSpanAndCrossfadeRoundTrip() {
CHECK(out.params.rootOverride && *out.params.rootOverride == 55);
}
// The loop ENABLE is `hasLoop`, and the block writes start/end unconditionally — so the wire
// already carries "off, with a span remembered." Nothing about the format changes to make the
// enable user-owned; this pins that the off state and its retained span both survive a reload,
// because an off that came back as a re-parked default would be a delete button, not a toggle.
static void testAnOffLoopRoundTripsWithItsSpanAndCrossfadeRetained() {
ComponentState in;
in.selectionId = "pad";
SampleLoop lp;
lp.hasLoop = false;
lp.start = 4096;
lp.end = 65536;
in.params.loopOverride = lp;
in.params.loopCrossfadeFrames = 1024;
const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.loopOverride && !out.params.loopOverride->hasLoop);
CHECK(out.params.loopOverride && out.params.loopOverride->start == 4096);
CHECK(out.params.loopOverride && out.params.loopOverride->end == 65536);
CHECK(out.params.loopCrossfadeFrames == 1024);
}
// A negative fade cannot mean anything and would only reach resolveLoop's clamp; refusing it
// at the wire keeps the parameter set the editor reads back sane.
static void testNegativeCrossfadeOnTheWireLiftsToZero() {
@@ -2155,6 +2176,7 @@ int main() {
testDefaultStateRoundTripsToDefaults();
testEnvelopePrefixBytesFrozen();
testLoopSpanAndCrossfadeRoundTrip();
testAnOffLoopRoundTripsWithItsSpanAndCrossfadeRetained();
testNegativeCrossfadeOnTheWireLiftsToZero();
testPriorPayloadVersionsLiftToAHardSeam();
testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact();
+224
View File
@@ -0,0 +1,224 @@
// Standalone tests for reasampler::instrument::ui::loop_marks — no VST3, no REAPER, no
// framework. Asserts the loop enable's whole state machine: the resolve's park rule and its two
// OFF states, the write's collapse fold and crossfade retention, and the four gestures that
// reach `hasLoop` composed end to end (resolve -> edit -> apply -> resolve), which is exactly
// how the editor drives it.
#include "../src/core/instrument/ui/loop_marks.h"
#include "../src/core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the park target)
#include <cstdio>
using namespace reasampler::instrument::ui;
using reasampler::SampleLoop;
using reasampler::instrument::engine::loop::LoopBounds;
using reasampler::instrument::engine::loop::defaultLoopBounds;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static constexpr std::int64_t kFrames = 10000;
// The editor's own round trip: what the band shows after a marker set is written back.
static LoopMarks writeThenRead(const LoopMarks& edited, std::int64_t frames = kFrames) {
const LoopWrite w = applyLoopMarks(edited);
StoredLoop s;
s.override_ = w.loop;
s.crossfade = w.crossfade;
s.startPoint = w.start;
return resolveLoopMarks(s, frames);
}
static StoredLoop storedSpan(bool on, std::int64_t start, std::int64_t end,
std::int64_t crossfade) {
StoredLoop s;
s.override_ = SampleLoop{on, start, end};
s.crossfade = crossfade;
return s;
}
// --- resolve: the two OFF states -----------------------------------------------
static void testNothingSetParksOnTheDefaultBoundsAndReadsAsNeverSet() {
const LoopMarks m = resolveLoopMarks(StoredLoop{}, kFrames);
const LoopBounds d = defaultLoopBounds(kFrames);
CHECK(!m.hasLoop);
CHECK(m.parked); // the "DRAG TO SET LOOP" state
CHECK(m.loopStart == d.start && m.loopEnd == d.end);
}
static void testAValidSpanSwitchedOffKeepsItsOwnPositions() {
const LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames);
CHECK(!m.hasLoop);
CHECK(!m.parked); // the "LOOP OFF" state — there is nothing to "set"
CHECK(m.loopStart == 4000 && m.loopEnd == 6000);
CHECK(m.crossfade == 300);
// And it is NOT the park position, which is what the off->on restore depends on.
const LoopBounds d = defaultLoopBounds(kFrames);
CHECK(!(m.loopStart == d.start && m.loopEnd == d.end));
}
static void testAnUnusableSpanParksWhateverTheEnableSays() {
const LoopBounds d = defaultLoopBounds(kFrames);
// Collapsed, inverted, past the PCM, and negative — the four the engine refuses.
const StoredLoop bad[4] = {storedSpan(true, 500, 500, 0), storedSpan(true, 900, 400, 0),
storedSpan(true, 500, kFrames + 1, 0),
storedSpan(true, -5, 400, 0)};
for (const StoredLoop& s : bad) {
const LoopMarks m = resolveLoopMarks(s, kFrames);
CHECK(!m.hasLoop);
CHECK(m.parked);
CHECK(m.loopStart == d.start && m.loopEnd == d.end);
}
}
static void testTheOverrideSupersedesTheBankIntrinsic() {
StoredLoop s;
s.intrinsic = SampleLoop{true, 100, 200};
s.override_ = SampleLoop{true, 4000, 6000};
const LoopMarks m = resolveLoopMarks(s, kFrames);
CHECK(m.hasLoop && m.loopStart == 4000 && m.loopEnd == 6000);
// With no override the intrinsic is what the band shows.
s.override_.reset();
const LoopMarks i = resolveLoopMarks(s, kFrames);
CHECK(i.hasLoop && i.loopStart == 100 && i.loopEnd == 200);
// An intrinsic that itself says "no loop" is not a span to adopt.
s.intrinsic = SampleLoop{false, 100, 200};
CHECK(resolveLoopMarks(s, kFrames).parked);
}
// --- the four gestures onto hasLoop --------------------------------------------
// Gesture 1: the enable clicked ON. Span and crossfade retained as-is.
static void testEnableOnKeepsTheSpanAndCrossfade() {
LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames);
m.hasLoop = true;
const LoopMarks after = writeThenRead(m);
CHECK(after.hasLoop);
CHECK(after.loopStart == 4000 && after.loopEnd == 6000);
CHECK(after.crossfade == 300);
}
// Gesture 2: the enable clicked OFF. Span and crossfade retained — the whole point of it being
// a toggle rather than a delete button.
static void testEnableOffRetainsTheSpanAndCrossfade() {
LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames);
m.hasLoop = false;
const LoopMarks after = writeThenRead(m);
CHECK(!after.hasLoop);
CHECK(!after.parked);
CHECK(after.loopStart == 4000 && after.loopEnd == 6000);
CHECK(after.crossfade == 300);
}
// The acceptance criterion in one assertion: off then on restores the loop EXACTLY.
static void testOffThenOnRestoresTheLoopExactly() {
const LoopMarks before = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames);
LoopMarks off = before;
off.hasLoop = false;
LoopMarks mid = writeThenRead(off);
mid.hasLoop = true;
const LoopMarks back = writeThenRead(mid);
CHECK(back.hasLoop == before.hasLoop);
CHECK(back.loopStart == before.loopStart && back.loopEnd == before.loopEnd);
CHECK(back.crossfade == before.crossfade);
CHECK(back.parked == before.parked);
}
// Gesture 3: the span collapsed onto itself. OFF, span destroyed and re-parked, crossfade zeroed.
static void testCollapsingTheSpanTurnsItOffReparksAndZeroesTheCrossfade() {
LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames);
m.loopEnd = m.loopStart; // the drag that lands one mark on the other
const LoopWrite w = applyLoopMarks(m);
CHECK(!w.loop.hasLoop);
CHECK(w.crossfade == 0);
const LoopMarks after = writeThenRead(m);
const LoopBounds d = defaultLoopBounds(kFrames);
CHECK(!after.hasLoop && after.parked);
CHECK(after.loopStart == d.start && after.loopEnd == d.end);
CHECK(after.crossfade == 0);
}
// Gesture 4: dragging a loop mark while OFF turns it on, in BOTH off-states.
static void testDraggingALoopMarkWhileOffTurnsItOn() {
// Never set: the pair is parked, and the drag takes it off the park.
LoopMarks parked = resolveLoopMarks(StoredLoop{}, kFrames);
CHECK(parked.parked && !parked.hasLoop);
parked.loopStart = 3000; // the drag
parked.hasLoop = true;
const LoopMarks fromParked = writeThenRead(parked);
CHECK(fromParked.hasLoop && !fromParked.parked);
CHECK(fromParked.loopStart == 3000);
// Span retained: the drag turns it on at the dragged positions, crossfade retained.
LoopMarks retained = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames);
CHECK(!retained.parked && !retained.hasLoop);
retained.loopEnd = 7000;
retained.hasLoop = true;
const LoopMarks fromRetained = writeThenRead(retained);
CHECK(fromRetained.hasLoop);
CHECK(fromRetained.loopStart == 4000 && fromRetained.loopEnd == 7000);
CHECK(fromRetained.crossfade == 300);
}
// --- the write's own rules ------------------------------------------------------
// Dragging the START marker with the enable off must not turn the loop on, and must not
// destroy the crossfade travelling with the retained span.
static void testEditingTheStartMarkerWhileOffLeavesTheEnableAndCrossfadeAlone() {
LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames);
m.start = 512;
const LoopWrite w = applyLoopMarks(m);
CHECK(!w.loop.hasLoop);
CHECK(w.loop.start == 4000 && w.loop.end == 6000);
CHECK(w.crossfade == 300);
CHECK(w.start == 512);
}
// Dragging the START marker while parked (never set) must not turn "DRAG TO SET LOOP" into
// "LOOP OFF" — the ordinary gesture that exposed the bug, distinct from the retained-span case
// above.
static void testEditingTheStartMarkerWhileParkedStaysParked() {
LoopMarks m = resolveLoopMarks(StoredLoop{}, kFrames);
CHECK(m.parked && !m.hasLoop);
m.start = 512;
const LoopMarks after = writeThenRead(m);
CHECK(!after.hasLoop);
CHECK(after.parked);
const LoopBounds d = defaultLoopBounds(kFrames);
CHECK(after.loopStart == d.start && after.loopEnd == d.end);
CHECK(after.start == 512);
}
static void testANegativeCrossfadeNeverReachesTheStore() {
LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 0), kFrames);
m.crossfade = -1;
CHECK(applyLoopMarks(m).crossfade == 0);
StoredLoop s = storedSpan(true, 4000, 6000, -1);
CHECK(resolveLoopMarks(s, kFrames).crossfade == 0);
}
int main() {
testNothingSetParksOnTheDefaultBoundsAndReadsAsNeverSet();
testAValidSpanSwitchedOffKeepsItsOwnPositions();
testAnUnusableSpanParksWhateverTheEnableSays();
testTheOverrideSupersedesTheBankIntrinsic();
testEnableOnKeepsTheSpanAndCrossfade();
testEnableOffRetainsTheSpanAndCrossfade();
testOffThenOnRestoresTheLoopExactly();
testCollapsingTheSpanTurnsItOffReparksAndZeroesTheCrossfade();
testDraggingALoopMarkWhileOffTurnsItOn();
testEditingTheStartMarkerWhileOffLeavesTheEnableAndCrossfadeAlone();
testEditingTheStartMarkerWhileParkedStaysParked();
testANegativeCrossfadeNeverReachesTheStore();
if (g_fail == 0) {
std::printf("loop_marks: all tests passed\n");
return 0;
}
std::printf("loop_marks: %d failure(s)\n", g_fail);
return 1;
}
+28 -8
View File
@@ -3,7 +3,8 @@
//
// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the
// toolbar's fixed right-anchored run in order (Hold, bake, preview, velocity cell,
// Mono|Stereo, Browse) with the title taking the remainder; the velocity and Hold knobs
// Loop Off|On, Mono|Stereo, Browse) with the title taking the remainder and still holding its
// text at the editor's floor; the velocity and Hold knobs
// centred in their cells above their labels; the piano strip owning its whole row at every
// width; no rect on the
// toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview
@@ -58,7 +59,10 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
CHECK(r.navBrowse.width == kNavButtonWidth);
CHECK(r.chanStereo.right() <= r.navBrowse.x);
CHECK(r.chanMono.right() == r.chanStereo.x);
CHECK(r.velCell.right() <= r.chanMono.x);
CHECK(r.loopOn.right() <= r.chanMono.x); // the enable is immediately left of Mono|Stereo
CHECK(r.loopOff.right() == r.loopOn.x); // its two segments abut, like the channel pair
CHECK(r.loopOff.y == r.chanMono.y && r.loopOff.height == r.chanMono.height);
CHECK(r.velCell.right() <= r.loopOff.x);
CHECK(r.preview.right() <= r.velCell.x);
CHECK(r.bake.right() <= r.preview.x);
CHECK(r.bake.width == kBakeButtonWidth);
@@ -70,8 +74,8 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
CHECK(r.title.width > 0);
// Every toolbar rect sits inside the toolbar row.
const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.chanMono,
r.chanStereo, r.navBrowse};
const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.loopOff,
r.loopOn, r.chanMono, r.chanStereo, r.navBrowse};
for (const Rect& it : items) {
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
}
@@ -84,8 +88,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
// stay inside its own row, clear of every control.
CHECK(!overlaps(r.toolbar, r.rootStrip));
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.chanMono,
r.chanStereo, r.navBrowse};
const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.loopOff, r.loopOn,
r.chanMono, r.chanStereo, r.navBrowse};
for (const Rect& it : items) {
CHECK(!overlaps(it, r.rootStrip));
CHECK(!overlaps(it, r.title));
@@ -147,6 +151,21 @@ static void testHoldCellIsReservedAndFollowsTheVelocityCellGrammar() {
CHECK(wide.holdCell.width == r.holdCell.width);
}
// The enable joins a RIGHT-ANCHORED run, so it is charged to the title slot and not to the
// window. kEditorMinWidth itself is guarded by test_deck_groups' derived-floor assertion — this
// is the other half of that contract: the title must still hold its text AT that floor, because
// the agreed remedy if it cannot is to narrow the enable's segments, never to move the floor.
static void testTheControlRunLeavesTheTitleReadableAtTheEditorFloor() {
const ChromeRects r = chromeRects(chromeBand(), kKnob);
// "ReaSampler 9000" (15 chars) plus a bracketed 20-char capture name, at an assumed 7 px/char
// for the toolbar font: 38 * 7. A HEURISTIC, not a measured Segoe UI metric — no font-metric
// measurement backs this number; it gates a phase-wide acceptance criterion regardless.
constexpr int kTitleTextFloorPx = 266;
CHECK(r.title.width >= kTitleTextFloorPx);
// Nothing in the run reaches into the title's slot.
CHECK(r.title.right() <= r.holdCell.x);
}
static void testDegenerateBandYieldsNoInvertedRects() {
const ChromeRects empty = chromeRects(Rect{}, kKnob);
CHECK(empty.toolbar.empty() && empty.controls.empty());
@@ -157,8 +176,8 @@ static void testDegenerateBandYieldsNoInvertedRects() {
kKnob);
const Rect items[] = {tiny.title, tiny.holdCell, tiny.holdKnob, tiny.holdLabel,
tiny.bake, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
tiny.rootStrip};
tiny.loopOff, tiny.loopOn, tiny.chanMono, tiny.chanStereo,
tiny.navBrowse, tiny.rootStrip};
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
}
@@ -206,6 +225,7 @@ int main() {
testStripOwnsItsWholeRowAndGrowsWithTheWindow();
testVelocityKnobIsCentredInItsCellAboveTheLabel();
testHoldCellIsReservedAndFollowsTheVelocityCellGrammar();
testTheControlRunLeavesTheTitleReadableAtTheEditorFloor();
testDegenerateBandYieldsNoInvertedRects();
testPreviewGlyphSitsInsideTheButtonAndPointsRight();
testPreviewGlyphDegradesRatherThanOverflowing();
+41 -2
View File
@@ -107,7 +107,7 @@ static void testOverlayBoxIsTheWholeArea() {
// --- Smallest-target-first: resolveWaveformClaim, the shell's own comparison chain -----
//
// editor_input_waveform.cpp's mouseDownWaveform resolves a click among a contour node (a fixed
// pick box), the crossfade tab, and a marker's full-height column by calling
// pick box), a mark's CAP, and a mark's full-height column by calling
// resolveWaveformClaim with each candidate's own target area; the smallest hit wins. These
// tests build the real geometry over the pure primitives the shell composes, then feed it into
// resolveWaveformClaim itself, so a reverted node-first/marker-first/tab-first ordering fails
@@ -215,7 +215,45 @@ static void testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere() {
WaveformClaimant::kMarker);
}
// The only live tie: the crossfade tab (<=110) can equal the node (169) only off-geometry, but
// Case (d): every mark now carries a cap, which RESOLVES the long-open "staged-envelope-node
// shadow at zero-attack" wart. A zero-attack AttackEnd node sits at the canvas's top-left — the
// same pixel a START marker at frame 0 draws at — and used to win the click outright, because
// the marker's only target there was its full-height column. START's cap is the same 11x10 tab
// the crossfade always had, so the node no longer shadows it. The cap slot's nominal area is
// unchanged by the change (every mark's cap is one markerHandleRect), which is why the
// arbitration itself needed no re-tuning: cap < node < column still holds.
static void testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip() {
const Rect a = Rect{20, 10, 1000, kWaveformMinHeight};
const OverlayArea overlay = overlayOf(a);
const std::int64_t frames = 100000;
// START at frame 0: its cap clips against the band's left edge, and a zero-attack node is
// drawn on that same corner.
const Rect cap = markerHandleRect(overlay, frames, 0);
CHECK(!cap.empty());
CHECK(contains(cap, a.x, a.y));
// NOMINAL, matching what the shell feeds the arbitration — the clipped tab at frame 0 is
// the worst case for the cap, and it still wins on the nominal number the shell uses.
const std::int64_t capArea =
static_cast<std::int64_t>(2 * kMarkerHandleHalfWidth + 1) * kMarkerHandleHeight;
const std::int64_t columnArea =
static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) * a.height;
CHECK(capArea < kNodeArea);
CHECK(kNodeArea < columnArea);
const WaveformClaim node{true, kNodeArea};
const WaveformClaim capClaim{true, capArea};
const WaveformClaim column{true, columnArea};
CHECK(resolveWaveformClaim(node, capClaim, column, SplineGesture::kLeft) ==
WaveformClaimant::kTab);
// And the node keeps everything below the cap strip, which is where it is actually drawn
// for any non-degenerate envelope.
CHECK(!contains(cap, a.x, a.y + kMarkerHandleHeight));
CHECK(resolveWaveformClaim(node, WaveformClaim{}, column, SplineGesture::kLeft) ==
WaveformClaimant::kNode);
}
// The only live tie: a mark's cap (<=110) can equal the node (169) only off-geometry, but
// tab-vs-marker ties at overlay height 10 (kMarkerHandleHeight), where the tab's 11x10 strip
// (110) equals a marker column's 11 * 10 (110) — the tab wins, matching check order.
static void testTabWinsAGenuineTabVersusMarkerTie() {
@@ -269,6 +307,7 @@ int main() {
testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero();
testCrossfadeTabBeatsAContourNodeNearItsTopStrip();
testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere();
testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip();
testTabWinsAGenuineTabVersusMarkerTie();
testNoHitAnywhereFallsThroughToNone();
testAMissedCandidateNeverWinsOnADegenerateZeroArea();
+20
View File
@@ -269,6 +269,25 @@ static void testCardNameScrimClearsBodyFloorOnItsWorstBackground() {
< textFloor(TextClass::Body));
}
// The waveform band's state caption and its four mark labels (editor_paint_waveform.cpp) draw
// text/dim (the caption, the non-promoted labels) and text/primary (the promoted label) — both
// Font::Micro, both body class — over a bg/base scrim at kWaveformLabelScrimAlpha, itself drawn
// over whatever drawEnvelope left in that rect. Unlike the card name strip, the worst case here
// is text/DIM, not text/primary, so the alpha is higher than kCardNameScrimAlpha's — pin both
// roles against the worst background (a full-scale envelope peak, accent/primary) and pin the
// defect the scrim exists to close.
static void testWaveformLabelScrimClearsBodyFloorOnItsWorstBackground() {
const KitColor scrim = roleColor(Role::BgBase);
const KitColor onFill = compositeOver(scrim, roleColor(Role::AccentPrimary),
kWaveformLabelScrimAlpha);
CHECK(contrastRatio(roleColor(Role::TextDim), onFill) >= textFloor(TextClass::Body));
CHECK(contrastRatio(roleColor(Role::TextPrimary), onFill) >= textFloor(TextClass::Body));
// Without the scrim, text/dim on the bare accent-lime fill is UNDER floor (~1.58:1) — pins
// the defect the scrim exists to close, so a future removal of the scrim fails this first.
CHECK(contrastRatio(roleColor(Role::TextDim), roleColor(Role::AccentPrimary))
< textFloor(TextClass::Body));
}
// --- Single point of change (structural guarantee) ----------------------------
//
// roleColor is the ONLY color source; there is no other public accessor that yields a
@@ -366,6 +385,7 @@ int main() {
testTextOnPastelFillClearsBodyFloor();
testTextOnHoverSurfaceClearsFloor();
testCardNameScrimClearsBodyFloorOnItsWorstBackground();
testWaveformLabelScrimClearsBodyFloorOnItsWorstBackground();
testCurveTraceOnHoverSurfaceClearsFloor();
testSecondaryTertiaryAreDistinguishable();
testOverlayTraceClearsIndicatorFloorOnTheWaveform();
+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();