vst: curve velocity_curve eval with a monotone cubic Hermite (Fritsch-Carlson) spline

True curved response, provably no overshoot outside [0,1]; collinear knots reproduce the exact linear ramp so linear()'s null-response stays bit-exact. Eval-only; v7 wire-format untouched. Adds spline tests.
This commit is contained in:
2026-07-27 14:45:50 -04:00
parent a1f9dcf6f8
commit 026ca90217
3 changed files with 147 additions and 14 deletions
+69
View File
@@ -78,6 +78,71 @@ static void testEvalMonotonicInX() {
CHECK(near(c.eval(64), 0.0)); // the trough sits exactly on the moved point
}
// --- eval: monotone cubic Hermite spline (FritschCarlson) --------------------
static void testCollinearControlPointsReproduceExactLinearRamp() {
// The Option-B guarantee: for COLLINEAR knots the FC tangents reduce to the secant slope, so the
// spline IS the straight line y = velocity/127 — bit-exact, not merely close. Add an interior
// point that sits EXACTLY on the linear ramp so all three knots are collinear.
VelocityCurve c = VelocityCurve::linear(); // (0,0),(127,1)
c.addPoint(60.0, 60.0 / 127.0); // on the line -> still collinear
// Every velocity must equal velocity/127 to full double precision (bit-exact reproduction).
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), v / 127.0, 1e-12));
// And the untouched linear() with only its two endpoints, too.
const VelocityCurve line = VelocityCurve::linear();
for (int v = 0; v <= 127; ++v) CHECK(near(line.eval(v), v / 127.0, 1e-12));
}
static void testNoOvershootWithSharpInteriorDip() {
// A sharp interior dip is the classic overshoot trap: a NON-monotone interpolant (Catmull-Rom /
// natural cubic) would bulge the curve below 0 near the trough. FritschCarlson must keep every
// sampled amp inside [0,1] across the whole domain. Build (0,1)->(64,0)->(127,1).
VelocityCurve c = VelocityCurve::flat();
c.movePoint(0, 0, 1.0);
c.addPoint(64.0, 0.0);
c.movePoint(2, 127, 1.0);
for (int v = 0; v <= 127; ++v) {
const double y = c.eval(v);
CHECK(y >= 0.0 - 1e-12 && y <= 1.0 + 1e-12);
}
// A dense sub-integer sweep too (the spline could overshoot between integer velocities).
for (int k = 0; k <= 1270; ++k) {
const double y = c.eval(k / 10.0);
CHECK(y >= 0.0 - 1e-12 && y <= 1.0 + 1e-12);
}
CHECK(near(c.eval(64), 0.0)); // knot honored exactly
}
static void testSplineStaysSingleValuedMonotoneInEachSegment() {
// A rising staircase of knots: the spline must be non-decreasing across the whole domain (the FC
// limiter forbids overshoot, so a monotone-increasing knot set yields a monotone-increasing
// curve — no local wiggles that would make eval multi-valued in feel).
VelocityCurve c = VelocityCurve::linear();
c.addPoint(30.0, 0.1);
c.addPoint(60.0, 0.15); // a near-flat run then a steep rise: overshoot bait for a plain cubic
c.addPoint(90.0, 0.9);
double prev = c.eval(0);
for (int k = 1; k <= 1270; ++k) {
const double cur = c.eval(k / 10.0);
CHECK(cur >= prev - 1e-9); // non-decreasing everywhere -> single-valued, no wiggle
CHECK(cur >= 0.0 - 1e-12 && cur <= 1.0 + 1e-12);
prev = cur;
}
}
static void testSplinePinsEndpointsExactly() {
// The curve must pass THROUGH every control point, endpoints included, regardless of curvature.
VelocityCurve c = VelocityCurve::flat();
c.movePoint(0, 0, 0.2); // first endpoint amp 0.2
c.addPoint(40.0, 0.9);
c.addPoint(80.0, 0.1);
c.movePoint(3, 127, 0.7); // last endpoint amp 0.7
CHECK(near(c.eval(0), 0.2));
CHECK(near(c.eval(40), 0.9));
CHECK(near(c.eval(80), 0.1));
CHECK(near(c.eval(127), 0.7));
}
// --- editing: addPoint --------------------------------------------------------
static void testAddPointKeepsXOrderAndClamps() {
@@ -223,6 +288,10 @@ int main() {
testLinearRamp();
testEvalBoxClampsOutOfRangeVelocity();
testEvalMonotonicInX();
testCollinearControlPointsReproduceExactLinearRamp();
testNoOvershootWithSharpInteriorDip();
testSplineStaysSingleValuedMonotoneInEachSegment();
testSplinePinsEndpointsExactly();
testAddPointKeepsXOrderAndClamps();
testMoveInteriorClampsToNeighbours();
testMoveEndpointsArePinnedInX();