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