Merge Ω-W2-T5: bound the waveform zero-crossing snap to a pixel radius

Single-cycle loop marks stop teleporting to the one interior crossing. Ctrl on a
marker drag defeats the snap, and no longer loses the grab to a coincident node.
This commit is contained in:
2026-08-03 16:09:27 -04:00
11 changed files with 316 additions and 33 deletions
+17 -5
View File
@@ -280,15 +280,27 @@ static void testAMissedCandidateNeverWinsOnADegenerateZeroArea() {
WaveformClaimant::kTab);
}
// A control-click has no tab/marker meaning (only the node's hard/smooth toggle answers it), so
// it resolves to the node whenever the node is in the running, even where a plain left-click at
// the same pixel would hand the tab or marker the win on area alone.
static void testControlClickAlwaysTakesTheNodeOverASmallerTabOrMarker() {
// A control-click has no tab/marker meaning (only the node's hard/smooth toggle answers it), but
// that must not let Ctrl steal a mark grab out from under the cursor: when a cap or column is
// ALSO in the running, control-click defers to the ordinary smallest-area arbitration exactly
// like a plain left-click would, so pressing Ctrl before or after the button gives the same
// answer. Only with no cap/column in the running at all does control-click claim the node
// outright regardless of area.
static void testControlClickDefersToACoincidentCapOrColumnLikeAPlainClick() {
const WaveformClaim node{true, kNodeArea};
const WaveformClaim smallerTab{true, 50}; // would beat the node on a plain left-click
CHECK(resolveWaveformClaim(node, smallerTab, WaveformClaim{}, SplineGesture::kLeft) ==
WaveformClaimant::kTab);
// Ctrl pressed before the click must not out-rank the cap that a plain click already gives
// the win — the exact regression this pins.
CHECK(resolveWaveformClaim(node, smallerTab, WaveformClaim{}, SplineGesture::kControlLeft) ==
WaveformClaimant::kTab);
const WaveformClaim smallerMarker{true, 80}; // still smaller than the node, no tab present
CHECK(resolveWaveformClaim(node, WaveformClaim{}, smallerMarker,
SplineGesture::kControlLeft) == WaveformClaimant::kMarker);
// No cap or column at all: control-click still claims the node outright, regardless of area
// — there is nothing else for it to defer to.
CHECK(resolveWaveformClaim(node, WaveformClaim{}, WaveformClaim{}, SplineGesture::kControlLeft) ==
WaveformClaimant::kNode);
// No node in the running: control-click has nothing to fall back to, so the tab still wins.
CHECK(resolveWaveformClaim(WaveformClaim{}, smallerTab, WaveformClaim{},
@@ -311,7 +323,7 @@ int main() {
testTabWinsAGenuineTabVersusMarkerTie();
testNoHitAnywhereFallsThroughToNone();
testAMissedCandidateNeverWinsOnADegenerateZeroArea();
testControlClickAlwaysTakesTheNodeOverASmallerTabOrMarker();
testControlClickDefersToACoincidentCapOrColumnLikeAPlainClick();
if (g_fail == 0) std::printf("spline_edit: all tests passed\n");
return g_fail == 0 ? 0 : 1;
+163 -1
View File
@@ -13,7 +13,10 @@
// resolveDragFrame (drag lands on the frameToX/xToFrame column under the cursor, 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); the four marks (per-mark cap
// buffers); snapToZeroCrossing + zeroCrossingSnapFrames (the radius: a single-cycle mark stays
// where it was dropped, dense material answers exactly what the unbounded search did, the
// boundary either side, the tie rule inside it, a sub-frame-per-pixel radius, clamps and
// 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
@@ -24,6 +27,7 @@
#include "../src/core/instrument/ui/sample_bands.h" // kWaveformMinHeight, kLaneGap
#include "../src/core/ui/component_geometry.h" // waveformColumnCount (the draw chain's own)
#include <cmath>
#include <cstddef>
#include <cstdio>
#include <vector>
@@ -376,6 +380,155 @@ static void testZeroCrossingDegenerate() {
CHECK(nearestZeroCrossing(one.data(), 1, 0) == 0); // <2 frames -> clamped target
}
// --- snapToZeroCrossing: the radius -------------------------------------------
// The radius in frames is the frame span kZeroCrossingSnapPx pixels cover, so it tracks the
// capture's length against a fixed band — read off xToFrame, never a second ratio.
static void testSnapRadiusIsThePixelBandsOwnFrameSpan() {
const Rect a = wideArea(); // width 1000
CHECK(zeroCrossingSnapFrames(overlayOf(a), 100000) == kZeroCrossingSnapPx * 100);
CHECK(zeroCrossingSnapFrames(overlayOf(a), 1000) == kZeroCrossingSnapPx); // 1 frame per px
// Below one frame per pixel the radius is 0: the user is placing individual frames.
CHECK(zeroCrossingSnapFrames(overlayOf(a), 100) == 0);
CHECK(zeroCrossingSnapFrames(overlayOf(a), 0) == 0);
CHECK(zeroCrossingSnapFrames(overlayOf(Rect::ltrb(0, 0, 0, 60)), 1000) == 0); // zero width
}
// A narrower-than-kZeroCrossingSnapPx overlay pushes `area.x + kZeroCrossingSnapPx` past
// area.right(), so xToFrame answers frameCount (its own past-the-edge clamp) and the "radius"
// becomes the WHOLE buffer — the original unbounded-snap defect, on a width the band-stack
// allocator's kEditorMinWidth floor never actually produces in the shipped editor. Documented as
// a fixture rather than left implicit, since this is a public pure API and the width sweep
// elsewhere in this file jumps straight from 0 to 1000.
static void testNarrowOverlayLosesTheBoundBelowTheSnapWidth() {
const Rect a = Rect::ltrb(0, 0, 3, 60); // narrower than kZeroCrossingSnapPx (5)
CHECK(zeroCrossingSnapFrames(overlayOf(a), 100000) == 100000);
}
// Long SPARSE material: a 1 s / 48 kHz 40 Hz square wave, crossings ~600 frames apart, drawn
// 1000 px wide (r = 240). The dense sweep above holds every crossing well inside the radius by
// construction, so it can never observe the bound; this is the only fixture where the radius
// sits strictly BETWEEN two crossings on genuinely long material, so the bounded and unbounded
// searches can actually disagree.
static void testSnapBoundsALongSparseCaptureBetweenCrossings() {
constexpr std::int64_t n = 48000, kHalfPeriod = 600; // 40 Hz square wave at 48 kHz
std::vector<AudioSample> pcm(static_cast<std::size_t>(n));
for (std::int64_t i = 0; i < n; ++i) {
pcm[static_cast<std::size_t>(i)] = ((i / kHalfPeriod) % 2 == 0) ? 1.0f : -1.0f;
}
const Rect a = wideArea(); // width 1000 -> 48 frames per px
const std::int64_t r = zeroCrossingSnapFrames(overlayOf(a), n);
CHECK(r == kZeroCrossingSnapPx * 48); // 240
CHECK(r < kHalfPeriod); // strictly between two crossings, not covering either
// Equidistant midpoint between the crossings at 600 and 1200: the unbounded search still
// finds one (the tie rule picks the lower, 600), while the bounded snap correctly leaves the
// mark where it was dropped — this pair IS the observable difference on long material.
const std::int64_t crossing = kHalfPeriod, midpoint = crossing + kHalfPeriod / 2;
CHECK(nearestZeroCrossing(pcm.data(), n, midpoint) == crossing);
CHECK(snapToZeroCrossing(pcm.data(), n, midpoint, r) == midpoint);
// Inside the radius the snap still reaches its crossing, same as ever.
CHECK(snapToZeroCrossing(pcm.data(), n, crossing + r, r) == crossing);
CHECK(snapToZeroCrossing(pcm.data(), n, crossing - r, r) == crossing);
}
// One cycle of a 60 Hz sine at 48 kHz — 800 frames, and exactly ONE interior sign change, at the
// midpoint (frame 0 is on zero, which is not a crossing, and the up-crossing is the wrap). That
// single crossing IS the reported defect: an unbounded search resolves every drop in the buffer
// to it, so the loop can only ever be half a cycle.
static std::vector<AudioSample> singleCycleSine() {
constexpr std::int64_t n = 800;
constexpr double kTwoPi = 6.283185307179586;
std::vector<AudioSample> pcm(static_cast<std::size_t>(n));
for (std::int64_t i = 0; i < n; ++i) {
const double phase = kTwoPi * static_cast<double>(i) / static_cast<double>(n);
pcm[static_cast<std::size_t>(i)] = static_cast<AudioSample>(std::sin(phase));
}
return pcm;
}
static void testSnapLeavesASingleCycleMarkWhereItWasDropped() {
const std::vector<AudioSample> pcm = singleCycleSine();
const std::int64_t n = static_cast<std::int64_t>(pcm.size());
// The fixture really does teleport under the unbounded search — both quadrant peaks land on
// the one midpoint crossing, hundreds of frames away.
CHECK(nearestZeroCrossing(pcm.data(), n, 200) == 401);
CHECK(nearestZeroCrossing(pcm.data(), n, 600) == 401);
const Rect a = Rect::ltrb(20, 10, 820, 90); // 800 px for 800 frames -> 1 frame per px
const std::int64_t r = zeroCrossingSnapFrames(overlayOf(a), n);
CHECK(r == kZeroCrossingSnapPx);
// ...and with the radius the marks stay put, which is what makes the loop draggable at all.
CHECK(snapToZeroCrossing(pcm.data(), n, 200, r) == 200);
CHECK(snapToZeroCrossing(pcm.data(), n, 600, r) == 600);
// The snap is not dead here — aimed at the crossing it still takes it.
CHECK(snapToZeroCrossing(pcm.data(), n, 401 - r, r) == 401);
CHECK(snapToZeroCrossing(pcm.data(), n, 401 - r - 1, r) == 401 - r - 1);
}
// Dense material: 48000 frames flipping sign every 24 (a 1 kHz square), drawn 1000 px wide, so
// the radius is 240 frames and every crossing is within 12. The snap must therefore answer
// exactly what the unbounded search always did, at every target — long material does not change.
static void testSnapIsUnchangedOnDenseMaterial() {
constexpr std::int64_t n = 48000, kHalfPeriod = 24;
std::vector<AudioSample> pcm(static_cast<std::size_t>(n));
for (std::int64_t i = 0; i < n; ++i) {
pcm[static_cast<std::size_t>(i)] = ((i / kHalfPeriod) % 2 == 0) ? 1.0f : -1.0f;
}
const Rect a = wideArea(); // width 1000 -> 48 frames per px
const std::int64_t r = zeroCrossingSnapFrames(overlayOf(a), n);
CHECK(r == kZeroCrossingSnapPx * 48);
// A mark dropped one frame off a crossing still snaps onto it.
CHECK(snapToZeroCrossing(pcm.data(), n, kHalfPeriod + 1, r) == kHalfPeriod);
CHECK(snapToZeroCrossing(pcm.data(), n, kHalfPeriod - 1, r) == kHalfPeriod);
for (std::int64_t t = 0; t < n; t += 7) {
CHECK(snapToZeroCrossing(pcm.data(), n, t, r) == nearestZeroCrossing(pcm.data(), n, t));
}
}
// The boundary, both sides: exactly at the radius is inside it, one past it is not. A lone 0.0
// sample is its own isolated crossing (the sample-on-zero rule), so each buffer has exactly one.
static void testSnapTakesACrossingAtTheRadiusAndRefusesOnePastIt() {
const std::int64_t n = 400, t = 200, r = 10;
for (const std::int64_t at : {t + r, t + r + 1, t - r, t - r - 1}) {
std::vector<AudioSample> pcm(static_cast<std::size_t>(n), 1.0f);
pcm[static_cast<std::size_t>(at)] = 0.0f;
const std::int64_t want = (at == t + r || at == t - r) ? at : t;
CHECK(snapToZeroCrossing(pcm.data(), n, t, r) == want);
}
}
// The tie rule is the radius's too: inside it, the fan-out order still decides, and it still
// resolves to the LOWER frame at every distance.
static void testSnapKeepsTheTieRuleInsideTheRadius() {
const std::int64_t n = 200, t = 100, r = 40;
for (std::int64_t d = 1; d <= r; ++d) {
std::vector<AudioSample> pcm(static_cast<std::size_t>(n), 1.0f);
pcm[static_cast<std::size_t>(t - d)] = 0.0f;
pcm[static_cast<std::size_t>(t + d)] = 0.0f;
CHECK(snapToZeroCrossing(pcm.data(), n, t, r) == t - d);
}
}
static void testSnapAtZeroRadiusMovesNothingButAnExactHit() {
std::vector<AudioSample> pcm = {1, 1, -1, -1}; // crossing at 2
CHECK(snapToZeroCrossing(pcm.data(), 4, 2, 0) == 2);
CHECK(snapToZeroCrossing(pcm.data(), 4, 1, 0) == 1);
CHECK(snapToZeroCrossing(pcm.data(), 4, 3, 0) == 3);
}
static void testSnapClampsAndTakesDegenerateInputs() {
std::vector<AudioSample> pcm = {1, -1, 1, -1}; // crossings at 1,2,3
CHECK(snapToZeroCrossing(pcm.data(), 4, 999, 100) == 3); // clamped, then found
CHECK(snapToZeroCrossing(pcm.data(), 4, -999, 100) == 1);
CHECK(snapToZeroCrossing(pcm.data(), 4, 0, 100) == 1); // frame 0 is never a crossing
CHECK(snapToZeroCrossing(pcm.data(), 4, 0, -1) == 0); // negative radius -> no snap
CHECK(snapToZeroCrossing(nullptr, 0, 5, 100) == 0);
std::vector<AudioSample> one = {1};
CHECK(snapToZeroCrossing(one.data(), 1, 0, 100) == 0); // <2 frames -> clamped target
}
// --- waveformSurface: the lane split + the overlay contract --------------------
// A realistic waveform band: full-width, taller than the two-lane floor.
@@ -829,6 +982,15 @@ int main() {
testZeroCrossingNoneKeepsTarget();
testZeroCrossingClampsTarget();
testZeroCrossingDegenerate();
testSnapRadiusIsThePixelBandsOwnFrameSpan();
testNarrowOverlayLosesTheBoundBelowTheSnapWidth();
testSnapLeavesASingleCycleMarkWhereItWasDropped();
testSnapIsUnchangedOnDenseMaterial();
testSnapBoundsALongSparseCaptureBetweenCrossings();
testSnapTakesACrossingAtTheRadiusAndRefusesOnePastIt();
testSnapKeepsTheTieRuleInsideTheRadius();
testSnapAtZeroRadiusMovesNothingButAnExactHit();
testSnapClampsAndTakesDegenerateInputs();
testSurfaceStereoStacksTwoLanes();
testSurfaceMonoIsOneLane();