diff --git a/src/vst/velocity_curve.cpp b/src/vst/velocity_curve.cpp index 87d3840..c625ddf 100644 --- a/src/vst/velocity_curve.cpp +++ b/src/vst/velocity_curve.cpp @@ -97,8 +97,8 @@ namespace { // 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). +// reduces to that common secant — so collinear control points reproduce the straight line to within +// floating-point rounding (~1e-15), preserving the Option-B / null-response contract for linear(). 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: @@ -131,8 +131,9 @@ double VelocityCurve::eval(double velocity) const { // --- Monotone cubic Hermite (Fritsch–Carlson) interpolation on segment [a,b] --------- // Curved (spline) response, not straight lines. The interpolant provably stays within // [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear - // 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). + // control points its tangents reduce to the secant slope — so it reproduces the straight + // line to within floating-point rounding (~1e-15), preserving linear()'s null-response + // contract (y = velocity/127 to ~1e-15; the test tolerance of 1e-12 is appropriate). 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 @@ -161,7 +162,8 @@ double VelocityCurve::eval(double velocity) const { } // 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. + // mA==mB==d, so h00*a + (h10*span)*d + h01*b + (h11*span)*d collapses to the straight + // line to within floating-point rounding (~1e-15). const double t = (v - a.velocity) / span; const double t2 = t * t; const double t3 = t2 * t; diff --git a/src/vst/velocity_curve.h b/src/vst/velocity_curve.h index e6c1232..19309b2 100644 --- a/src/vst/velocity_curve.h +++ b/src/vst/velocity_curve.h @@ -63,8 +63,9 @@ inline constexpr int kCurveNodeGrabRadius = 6; // curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each // velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within // each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the -// 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 +// Fritsch–Carlson tangents reduce to the secant slope, so the spline reproduces the straight line to +// within floating-point rounding (~1e-15) — that preserves linear()'s null-response contract +// (y = velocity/127 to ~1e-15; the 1e-12 test tolerance is deliberately conservative). The two endpoints // (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable. class VelocityCurve { public: @@ -90,7 +91,8 @@ public: // so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent // points the amp follows a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) — a // 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. + // 1) and reproduces the straight line to within floating-point rounding (~1e-15) 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;