Fix envelope-overlay knot/trace disagreement on odd pixel spans

Generalize curveMidLevel/curveFromMidLevel to curveLevelAt/curveFromLevelAt at
arbitrary phi; knotVtx and its drag inverse now read the phi a knot's truncated
x actually implies, not always 0.5.
This commit is contained in:
2026-08-01 21:31:40 -04:00
parent ee8a956fbd
commit a1b42ed1a8
7 changed files with 252 additions and 32 deletions
+37
View File
@@ -14,6 +14,7 @@
#include <cmath>
#include <cstdio>
#include <initializer_list>
using namespace reasampler::util;
@@ -107,6 +108,39 @@ static void testMidLevelInverseSaturates() {
CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12);
}
// curveLevelAt/curveFromLevelAt is the general form a knot's own (possibly off-centre) phi
// needs — curveMidLevel/curveFromMidLevel is the phi = 0.5 case, not a second law.
static void testMidLevelIsThePhiHalfSpecialCase() {
for (double e : {kCurveMin, 0.3, kCurveNeutral, 2.0, kCurveMax}) {
CHECK(curveLevelAt(0.5, e) == curveMidLevel(e));
}
for (double u : {0.0, 0.2, 0.5, 0.8, 1.0}) {
CHECK(curveFromLevelAt(0.5, u) == curveFromMidLevel(u));
}
}
// The round trip must hold at an arbitrary phi, not only 0.5 — this is what a knot whose
// integer x lands off its segment's true midpoint (an odd pixel span) actually exercises.
static void testLevelAtRoundTripsAtArbitraryPhi() {
for (double phi : {0.1, 0.3, 0.42, 0.5, 0.63, 0.9}) {
for (int i = 0; i <= 50; ++i) {
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 50.0);
const double level = curveLevelAt(phi, e);
CHECK(level > 0.0 && level < 1.0);
CHECK(std::fabs(curveFromLevelAt(phi, level) - e) < 1e-9);
}
}
}
// Saturation holds at an arbitrary phi too, not only the mid-level special case.
static void testLevelAtInverseSaturatesAtArbitraryPhi() {
for (double phi : {0.2, 0.5, 0.8}) {
CHECK(curveFromLevelAt(phi, 0.0) == kCurveMax);
CHECK(curveFromLevelAt(phi, 1.0) == kCurveMin);
CHECK(curveFromLevelAt(phi, std::nan("")) == kCurveMax);
}
}
// --- The inner dial's travel ---------------------------------------------------
// The knob drag delivers `start - dy/kKnobDragRangePixels`. param_slider owns that constant and
@@ -184,6 +218,9 @@ int main() {
testClampCurveHoldsTheDomain();
testMidLevelRoundTripsAgainstTheExponent();
testMidLevelInverseSaturates();
testMidLevelIsThePhiHalfSpecialCase();
testLevelAtRoundTripsAtArbitraryPhi();
testLevelAtInverseSaturatesAtArbitraryPhi();
testKnobLawIsExactAtTheNeutralCentre();
testADialSweptThroughNeutralLandsOnTheIdentity();
testKnobLawRoundTripsOutsideTheDetent();
+35
View File
@@ -373,6 +373,40 @@ static void testKnotOnANearLevelSegmentIsANoOp() {
CHECK(out.decayCurve == 2.5);
}
// The knot drag must read the SAME phi the draw used even off the segment midpoint (an odd
// pixel span), not the fixed phi = 0.5 wideArea()'s AttackCurve span happens to land on above.
// Checked two ways: a zero-delta grab reproduces the stored exponent, and a real one-pixel drag
// moves the knot's own drawn y by the same one pixel every other node axis tracks 1:1.
static void testKnotDragTracksTheDrawOnAnOddPixelSpan() {
bool found = false;
for (int width = 24; width <= 260 && !found; ++width) {
const Rect a = Rect::ltrb(0, 0, width, 100);
StageEnvelope e = ahdsrEnv();
e.attackCurve = 3.0;
EnvVertex origin, attackEnd, knot;
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), kTotal);
if (!findNode(poly, EnvNode::Origin, origin)) continue;
if (!findNode(poly, EnvNode::AttackEnd, attackEnd)) continue;
if (!findNode(poly, EnvNode::AttackCurve, knot)) continue;
const int span = attackEnd.x - origin.x;
if (span <= 0 || span % 2 == 0) continue;
found = true;
const StageEnvelope same =
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 0);
CHECK(std::fabs(same.attackCurve - e.attackCurve) < 1e-9);
const StageEnvelope dragged =
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 1);
EnvVertex knotAfter;
CHECK(findNode(buildEnvelopePolyline(dragged, overlayOf(a), kTotal), EnvNode::AttackCurve,
knotAfter));
CHECK(knotAfter.x == knot.x); // a curve drag never moves the knot's x
CHECK(std::abs(knotAfter.y - (knot.y + 1)) <= 1);
}
CHECK(found); // the sweep must actually land on an odd span
}
// --- the interaction law on the overlay ----------------------------------------
// Ctrl scales the PIXEL delta, so it composes with every axis — the tapered schematic, the 1:1
@@ -476,6 +510,7 @@ int main() {
testKnotAndModelCannotDiverge();
testKnotOnALevelSegmentIsANoOp();
testKnotOnANearLevelSegmentIsANoOp();
testKnotDragTracksTheDrawOnAnOddPixelSpan();
testDegenerateInputsAreNoOps();
+94 -1
View File
@@ -10,7 +10,9 @@
// per-segment separation at the tier-0 defaults, overrun compression, every vertex in-bounds);
// splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the AHD polyline (1:1 with
// the time axis, origin offset); curve knots (present only on sloped non-zero segments, height
// following the exponent); the degenerate flat baseline.
// following the exponent, and — swept across ODD and EVEN pixel spans, not one fixture's width —
// sitting on the curve its own vertices imply rather than always the segment's exact midpoint);
// the degenerate flat baseline.
#include "../src/core/instrument/ui/envelope_overlay.h"
@@ -422,6 +424,95 @@ static void testKnotHeightTracksTheExponent() {
CHECK(steep.y >= a.y && steep.y <= a.bottom() - 1);
}
// --- the knot sits ON its own curve (the reported defect, stated as the gate) -------------
// The general (non-truncated-phi) reading of a knot's level, computed from the vertices
// `buildEnvelopePolyline` actually returned — x0/x1/knotX are all int pixels a caller can read
// off the polyline, so this is a check ON the output, not a restatement of knotVtx's own
// formula. x0 == x1 has no interior (no knot is ever built there).
static double expectedKnotLevel(int x0, int x1, int knotX, double startLevel, double endLevel,
double exponent) {
const double phi = (x1 != x0)
? static_cast<double>(knotX - x0) / static_cast<double>(x1 - x0)
: 0.5;
return startLevel + (endLevel - startLevel) * reasampler::util::curveMap(phi, exponent);
}
// The reported defect, stated as the gate: at every exponent the knot's centre lies on the
// trace, within 1 px. Swept over a range of canvas widths (down to a few pixels of stage span)
// so the check actually exercises ODD pixel spans, where the segment's true midpoint falls
// between two pixels — testKnotHeightTracksTheExponent above sits at a width whose span happens
// to be even, which is exactly the kind of fixture that missed this defect.
static void testKnotSitsOnItsOwnCurveAcrossOddAndEvenSpans() {
bool sawOdd = false, sawEven = false;
int worstAhdsr = 0, worstAhd = 0;
for (int width = 24; width <= 260; width += 3) {
const Rect a = Rect::ltrb(0, 0, width, 100);
for (double exp : {util::kCurveMin, 0.3, 1.0, 3.0, util::kCurveMax}) {
StageEnvelope e = ahdsr(0.4, 0.0, 0.0, 1.0, 0.0);
e.attackCurve = exp;
EnvVertex origin, attackEnd, knot;
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), 4.0);
if (findNode(poly, EnvNode::Origin, origin) &&
findNode(poly, EnvNode::AttackEnd, attackEnd) &&
findNode(poly, EnvNode::AttackCurve, knot)) {
const int span = attackEnd.x - origin.x;
if (span > 0) {
if (span % 2 == 0) sawEven = true; else sawOdd = true;
const double expected =
expectedKnotLevel(origin.x, attackEnd.x, knot.x, 0.0, 1.0, exp);
const int expectedY = levelToY(a, expected);
worstAhdsr = (std::max)(worstAhdsr, std::abs(knot.y - expectedY));
CHECK(std::abs(knot.y - expectedY) <= 1);
}
}
StageEnvelope f = ahd(0.4, 0.6, 0.5, 0.0, 3.0);
f.attackCurve = exp;
EnvVertex originAhd, attackEndAhd, knotAhd;
const std::vector<EnvVertex> polyAhd = buildEnvelopePolyline(f, overlayOf(a), 4.0);
if (findNode(polyAhd, EnvNode::Origin, originAhd) &&
findNode(polyAhd, EnvNode::AttackEnd, attackEndAhd) &&
findNode(polyAhd, EnvNode::AttackCurve, knotAhd)) {
const int span = attackEndAhd.x - originAhd.x;
if (span > 0) {
if (span % 2 == 0) sawEven = true; else sawOdd = true;
const double expected = expectedKnotLevel(originAhd.x, attackEndAhd.x,
knotAhd.x, 0.0, 1.0, exp);
const int expectedY = levelToY(a, expected);
worstAhd = (std::max)(worstAhd, std::abs(knotAhd.y - expectedY));
CHECK(std::abs(knotAhd.y - expectedY) <= 1);
}
}
}
}
CHECK(sawOdd); // the sweep actually exercised an odd-pixel span...
CHECK(sawEven); // ...and an even one, so this isn't resting on one fixture's luck.
std::printf(" worst knot/curve separation: AHDSR %d px, AHD %d px\n", worstAhdsr, worstAhd);
}
// Exponent 1.0 is still a plain straight line even off the segment's exact midpoint — checked
// at a deliberately ODD span so the linear case isn't only proven at the symmetric one.
static void testNeutralExponentIsAStraightLineOffCentre() {
bool found = false;
for (int width = 24; width <= 200 && !found; ++width) {
const Rect a = Rect::ltrb(0, 0, width, 100);
StageEnvelope e = ahdsr(0.4, 0.0, 0.0, 1.0, 0.0);
e.attackCurve = util::kCurveNeutral;
EnvVertex origin, attackEnd, knot;
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), 4.0);
if (!findNode(poly, EnvNode::Origin, origin)) continue;
if (!findNode(poly, EnvNode::AttackEnd, attackEnd)) continue;
if (!findNode(poly, EnvNode::AttackCurve, knot)) continue;
const int span = attackEnd.x - origin.x;
if (span <= 0 || span % 2 == 0) continue;
found = true;
const double phi = static_cast<double>(knot.x - origin.x) / static_cast<double>(span);
CHECK(std::fabs(knot.level - phi) < 1e-12); // linear: level == phi, exactly
}
CHECK(found); // the sweep must actually land on an odd span
}
// --- degenerate ---------------------------------------------------------------
static void testDegenerateSurfaceYieldsFlatBaseline() {
@@ -458,6 +549,8 @@ int main() {
testKnotsRideOnlySlopedNonZeroSegments();
testKnotHeightTracksTheExponent();
testKnotSitsOnItsOwnCurveAcrossOddAndEvenSpans();
testNeutralExponentIsAStraightLineOffCentre();
testDegenerateSurfaceYieldsFlatBaseline();