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:
@@ -88,9 +88,31 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Fritsch–Carlson monotone-cubic tangent for one interior knot i, given the secant slopes of the
|
||||||
|
// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the
|
||||||
|
// limited tangent that keeps the cubic Hermite piece monotone and inside the data range.
|
||||||
|
//
|
||||||
|
// The rule: a tangent whose adjacent secants have opposite signs (or either is flat) is a local
|
||||||
|
// extremum — pin the tangent to 0 so the curve does not overshoot past the knot. Otherwise use the
|
||||||
|
// weighted-harmonic-mean tangent (Fritsch–Carlson eq. 4), which for COLLINEAR knots (dPrev==dNext)
|
||||||
|
// reduces EXACTLY to that common secant — so collinear control points reproduce the straight line
|
||||||
|
// bit-for-bit (the Option-B / null-response contract that linear() must still satisfy).
|
||||||
|
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
|
||||||
|
if (dPrev * dNext <= 0.0) return 0.0; // sign change or a flat neighbour -> local extremum
|
||||||
|
// Weighted harmonic mean of the two secants (weights = the two segment widths). Collinear case:
|
||||||
|
// dPrev==dNext==d makes this (w1+w2)*d / ((w1+w2)/... ) collapse to d exactly.
|
||||||
|
const double w1 = 2.0 * spanNext + spanPrev;
|
||||||
|
const double w2 = spanNext + 2.0 * spanPrev;
|
||||||
|
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
double VelocityCurve::eval(double velocity) const {
|
double VelocityCurve::eval(double velocity) const {
|
||||||
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity
|
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity
|
||||||
if (points_.size() == 1) return clampAmp(points_[0].amp);
|
if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp
|
||||||
const double v = clampVelocity(velocity);
|
const double v = clampVelocity(velocity);
|
||||||
// At or before the first point / at or after the last, read the endpoint amp (the endpoints are
|
// At or before the first point / at or after the last, read the endpoint amp (the endpoints are
|
||||||
// at 0 and 127, so this only fires exactly at the ends for an in-range velocity).
|
// at 0 and 127, so this only fires exactly at the ends for an in-range velocity).
|
||||||
@@ -105,13 +127,50 @@ double VelocityCurve::eval(double velocity) const {
|
|||||||
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment
|
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment
|
||||||
// has zero width so there is no interior to blend.
|
// has zero width so there is no interior to blend.
|
||||||
if (span <= 0.0) return clampAmp(b.amp);
|
if (span <= 0.0) return clampAmp(b.amp);
|
||||||
// Linear interpolation between the two knots. Linear (not smoothstep) is what makes
|
|
||||||
// linear() an EXACT straight line y = velocity/127 (the Option-B / null-response contract
|
// --- Monotone cubic Hermite (Fritsch–Carlson) interpolation on segment [a,b] ---------
|
||||||
// some callers opt back into) and keeps eval trivially monotonic in X. The "curved" feel
|
// Curved (spline) response, not straight lines. The interpolant provably stays within
|
||||||
// the editor offers comes from the user placing more control points, not from bending a
|
// [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear
|
||||||
// single segment.
|
// control points its tangents reduce to the secant slope — so it IS the straight line,
|
||||||
|
// keeping linear() an EXACT y = velocity/127 (the null-response contract).
|
||||||
|
const double d = (b.amp - a.amp) / span; // secant of THIS segment
|
||||||
|
|
||||||
|
// Tangent at a: 0 if a is the first knot (endpoint), else the FC-limited tangent using
|
||||||
|
// the previous segment's secant. Same for the tangent at b (0 at the last knot).
|
||||||
|
double mA = d;
|
||||||
|
if (i > 0) {
|
||||||
|
const VelocityPoint& prev = points_[i - 1];
|
||||||
|
const double spanPrev = a.velocity - prev.velocity;
|
||||||
|
if (spanPrev > 0.0) {
|
||||||
|
const double dPrev = (a.amp - prev.amp) / spanPrev;
|
||||||
|
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
|
||||||
|
} else {
|
||||||
|
mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double mB = d;
|
||||||
|
if (i + 2 < points_.size()) {
|
||||||
|
const VelocityPoint& next = points_[i + 2];
|
||||||
|
const double spanNext = next.velocity - b.velocity;
|
||||||
|
if (spanNext > 0.0) {
|
||||||
|
const double dNext = (next.amp - b.amp) / spanNext;
|
||||||
|
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
|
||||||
|
} else {
|
||||||
|
mB = 0.0; // coincident-X successor (a step at b) -> flat tangent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cubic Hermite basis on the normalized position t across [a,b]. For collinear knots
|
||||||
|
// mA==mB==d, so h00*a + (h10*span)*d + h01*b + (h11*span)*d collapses to the exact line.
|
||||||
const double t = (v - a.velocity) / span;
|
const double t = (v - a.velocity) / span;
|
||||||
return clampAmp(a.amp + (b.amp - a.amp) * t);
|
const double t2 = t * t;
|
||||||
|
const double t3 = t2 * t;
|
||||||
|
const double h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
|
||||||
|
const double h10 = t3 - 2.0 * t2 + t;
|
||||||
|
const double h01 = -2.0 * t3 + 3.0 * t2;
|
||||||
|
const double h11 = t3 - t2;
|
||||||
|
const double y = h00 * a.amp + h10 * span * mA + h01 * b.amp + h11 * span * mB;
|
||||||
|
return clampAmp(y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return clampAmp(points_.back().amp); // unreachable (v is between the endpoints)
|
return clampAmp(points_.back().amp); // unreachable (v is between the endpoints)
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ struct VelocityPoint {
|
|||||||
inline constexpr int kCurveNodeGrabRadius = 6;
|
inline constexpr int kCurveNodeGrabRadius = 6;
|
||||||
|
|
||||||
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by
|
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by
|
||||||
// LINEAR interpolation between adjacent points (a monotonic polyline: each velocity maps to exactly
|
// a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) through the knots — a genuine
|
||||||
// one amp). Linear-between-knots is deliberate — it makes linear() an EXACT straight line y =
|
// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each
|
||||||
// velocity/127 (the Option-B / null-response contract) and keeps monotonicity trivial; the "curved"
|
// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within
|
||||||
// shape a sound wants comes from placing more control points, not from bending one segment. The two
|
// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the
|
||||||
// endpoints (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable.
|
// Fritsch–Carlson tangents reduce to the secant slope, so the spline IS the straight line — that
|
||||||
|
// keeps linear() an EXACT y = velocity/127 (the Option-B / null-response contract). The two endpoints
|
||||||
|
// (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable.
|
||||||
class VelocityCurve {
|
class VelocityCurve {
|
||||||
public:
|
public:
|
||||||
// R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity.
|
// R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity.
|
||||||
@@ -86,8 +88,11 @@ public:
|
|||||||
|
|
||||||
// Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first,
|
// Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first,
|
||||||
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent
|
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent
|
||||||
// points the amp interpolates LINEARLY across the normalized X position — monotonic in X. A
|
// points the amp follows a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) — a
|
||||||
// degenerate curve (0 or 1 point, shouldn't occur post-construction) returns kAmpMax (flat).
|
// true curve that provably stays within the two knots' amp range (no overshoot below 0 / above
|
||||||
|
// 1) and reduces to the exact straight line for collinear knots. Single-valued / monotonic in X.
|
||||||
|
// Degenerate cases (shouldn't occur post-construction): an EMPTY curve returns kAmpMax (flat
|
||||||
|
// unity); a ONE-point curve returns that point's amp.
|
||||||
double eval(double velocity) const;
|
double eval(double velocity) const;
|
||||||
|
|
||||||
// --- Editing (for the S-VIEW-10 editor UI) --------------------------------------------------
|
// --- Editing (for the S-VIEW-10 editor UI) --------------------------------------------------
|
||||||
|
|||||||
@@ -78,6 +78,71 @@ static void testEvalMonotonicInX() {
|
|||||||
CHECK(near(c.eval(64), 0.0)); // the trough sits exactly on the moved point
|
CHECK(near(c.eval(64), 0.0)); // the trough sits exactly on the moved point
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- eval: monotone cubic Hermite spline (Fritsch–Carlson) --------------------
|
||||||
|
|
||||||
|
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. Fritsch–Carlson 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 --------------------------------------------------------
|
// --- editing: addPoint --------------------------------------------------------
|
||||||
|
|
||||||
static void testAddPointKeepsXOrderAndClamps() {
|
static void testAddPointKeepsXOrderAndClamps() {
|
||||||
@@ -223,6 +288,10 @@ int main() {
|
|||||||
testLinearRamp();
|
testLinearRamp();
|
||||||
testEvalBoxClampsOutOfRangeVelocity();
|
testEvalBoxClampsOutOfRangeVelocity();
|
||||||
testEvalMonotonicInX();
|
testEvalMonotonicInX();
|
||||||
|
testCollinearControlPointsReproduceExactLinearRamp();
|
||||||
|
testNoOvershootWithSharpInteriorDip();
|
||||||
|
testSplineStaysSingleValuedMonotoneInEachSegment();
|
||||||
|
testSplinePinsEndpointsExactly();
|
||||||
testAddPointKeepsXOrderAndClamps();
|
testAddPointKeepsXOrderAndClamps();
|
||||||
testMoveInteriorClampsToNeighbours();
|
testMoveInteriorClampsToNeighbours();
|
||||||
testMoveEndpointsArePinnedInX();
|
testMoveEndpointsArePinnedInX();
|
||||||
|
|||||||
Reference in New Issue
Block a user