Files
reasampler/src/vst/velocity_curve.cpp
T

278 lines
13 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types.
#include "velocity_curve.h"
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort
#include <cmath> // std::fabs
#include <utility> // 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<double>(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<double>(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<int>(frac * static_cast<double>(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<int>((1.0 - frac) * static_cast<double>(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<VelocityPoint> 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 {
// FritschCarlson 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 (FritschCarlson 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 (FritschCarlson) 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<std::ptrdiff_t>(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<std::ptrdiff_t>(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<double>(x - box.left) / static_cast<double>(w) *
(kVelMax - kVelMin));
p.amp = (h <= 1)
? kAmpMax
: clampAmp(kAmpMax - static_cast<double>(y - box.top) / static_cast<double>(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<int>(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<double>(dxPixels) * velPerPx;
// Y increases downward but amp increases upward, so a downward drag (positive dy) LOWERS amp.
const double newAmp = grab.amp - static_cast<double>(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