// velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types. #include "velocity_curve.h" #include // std::max, std::min, std::abs, std::stable_sort #include // std::fabs #include // std::move namespace reasampler::vst { namespace { double clamp(double v, double lo, double hi) { if (v < lo) return lo; if (v > hi) return hi; return v; } double clampVelocity(double v) { return clamp(v, kVelMin, kVelMax); } double clampAmp(double a) { return clamp(a, kAmpMin, kAmpMax); } // Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y // spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward). double velPerPixel(const VelocityCurve::Box& box) { const int w = std::max(0, box.width); if (w <= 0) return 0.0; return (kVelMax - kVelMin) / static_cast(w); } double ampPerPixel(const VelocityCurve::Box& box) { const int h = std::max(0, box.height); if (h <= 1) return 0.0; return (kAmpMax - kAmpMin) / static_cast(h - 1); } int velToX(const VelocityCurve::Box& box, double velocity) { const int w = std::max(0, box.width); if (w <= 0) return box.left; const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin); return box.left + static_cast(frac * static_cast(w) + 0.5); } int ampToY(const VelocityCurve::Box& box, double amp) { const int h = std::max(0, box.height); if (h <= 1) return box.top; // amp 1 at top (box.top), amp 0 at bottom (box.top + h - 1). const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin); return box.top + static_cast((1.0 - frac) * static_cast(h - 1) + 0.5); } } // namespace VelocityCurve VelocityCurve::flat() { VelocityCurve c; c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A) return c; } VelocityCurve VelocityCurve::linear() { VelocityCurve c; c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127 return c; } VelocityCurve VelocityCurve::fromPoints(std::vector pts) { // Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points // keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots. for (VelocityPoint& p : pts) { p.velocity = clampVelocity(p.velocity); p.amp = clampAmp(p.amp); } std::stable_sort(pts.begin(), pts.end(), [](const VelocityPoint& a, const VelocityPoint& b) { return a.velocity < b.velocity; }); // Fewer than 2 usable points -> can't span [0,127] as a function; fall back to the flat default. if (pts.size() < 2) return flat(); // Force endpoints present at velocity 0 and 127 (they must exist for eval to be total). if (pts.front().velocity > kVelMin) { pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp}); } else { pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint } if (pts.back().velocity < kVelMax) { pts.push_back(VelocityPoint{kVelMax, pts.back().amp}); } else { pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint } VelocityCurve c; c.points_ = std::move(pts); 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 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: // 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 { if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp const double v = clampVelocity(velocity); // 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). if (v <= points_.front().velocity) return clampAmp(points_.front().amp); if (v >= points_.back().velocity) return clampAmp(points_.back().amp); // Find the segment [points_[i], points_[i+1]] containing v (X-ordered, so a linear scan). for (std::size_t i = 0; i + 1 < points_.size(); ++i) { const VelocityPoint& a = points_[i]; const VelocityPoint& b = points_[i + 1]; if (v >= a.velocity && v <= b.velocity) { const double span = b.velocity - a.velocity; // 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. if (span <= 0.0) return clampAmp(b.amp); // --- 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 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 // 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 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; 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) } std::size_t VelocityCurve::addPoint(double velocity, double amp) { const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)}; // Insert keeping X-order: first index whose velocity is STRICTLY greater than the new one, so a // duplicate-X point lands immediately after the existing one (a later move can separate them). std::size_t i = 0; while (i < points_.size() && points_[i].velocity <= p.velocity) ++i; points_.insert(points_.begin() + static_cast(i), p); return i; } VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double amp) { if (index >= points_.size()) return VelocityPoint{}; // no-op (out of range) const bool isFirst = (index == 0); const bool isLast = (index + 1 == points_.size()); double newAmp = clampAmp(amp); double newVel; if (isFirst) { newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves } else if (isLast) { newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves } else { // Interior point: clamp X strictly within its immediate neighbours so it can't cross them. const double lo = points_[index - 1].velocity; const double hi = points_[index + 1].velocity; newVel = clamp(clampVelocity(velocity), lo, hi); } points_[index] = VelocityPoint{newVel, newAmp}; return points_[index]; } bool VelocityCurve::deletePoint(std::size_t index) { if (index >= points_.size()) return false; if (index == 0 || index + 1 == points_.size()) return false; // endpoints are not deletable points_.erase(points_.begin() + static_cast(index)); return true; } VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const VelocityPoint& p) { return CurvePixel{velToX(box, p.velocity), ampToY(box, p.amp)}; } VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) { // The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate // dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0), // ampToY pins to box.top (amp 1). VelocityPoint p; const int w = std::max(0, box.width); const int h = std::max(0, box.height); p.velocity = (w <= 0) ? kVelMin : clampVelocity(kVelMin + static_cast(x - box.left) / static_cast(w) * (kVelMax - kVelMin)); p.amp = (h <= 1) ? kAmpMax : clampAmp(kAmpMax - static_cast(y - box.top) / static_cast(h - 1) * (kAmpMax - kAmpMin)); return p; } int VelocityCurve::pointAtPixel(const Box& box, int x, int y) const { for (std::size_t i = 0; i < points_.size(); ++i) { const int px = velToX(box, points_[i].velocity); const int py = ampToY(box, points_[i].amp); if (std::abs(x - px) <= kCurveNodeGrabRadius && std::abs(y - py) <= kCurveNodeGrabRadius) { return static_cast(i); } } return -1; } VelocityCurve VelocityCurve::resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index, const Box& box, int dxPixels, int dyPixels) { VelocityCurve out = grabCurve; if (index >= out.points_.size()) return out; // out of range -> no motion const double velPerPx = velPerPixel(box); const double ampPerPx = ampPerPixel(box); if (velPerPx <= 0.0 || ampPerPx <= 0.0) return out; // degenerate box -> no motion const VelocityPoint& grab = grabCurve.points_[index]; const double newVel = grab.velocity + static_cast(dxPixels) * velPerPx; // Y increases downward but amp increases upward, so a downward drag (positive dy) LOWERS amp. const double newAmp = grab.amp - static_cast(dyPixels) * ampPerPx; out.movePoint(index, newVel, newAmp); // applies box + neighbour-X + endpoint-pin clamps return out; } bool VelocityCurve::equals(const VelocityCurve& other, double eps) const { if (points_.size() != other.points_.size()) return false; for (std::size_t i = 0; i < points_.size(); ++i) { if (std::fabs(points_[i].velocity - other.points_[i].velocity) > eps) return false; if (std::fabs(points_[i].amp - other.points_[i].amp) > eps) return false; } return true; } } // namespace reasampler::vst