// velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types. #include "core/instrument/engine/velocity_curve.h" #include #include #include namespace reasampler::instrument::engine { namespace { double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); } double clampValue(double a, CurveDomain d) { return std::clamp(a, curveYMin(d), kCurveYMax); } // X spans the width for [0,127]; Y spans (height-1) rows for the domain's range with its max at // the TOP (pixel y increases downward, so this axis is inverted relative to the value). 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 valuePerPixel(const VelocityCurve::Box& box, CurveDomain d) { const int h = std::max(0, box.height); if (h <= 1) return 0.0; return (kCurveYMax - curveYMin(d)) / static_cast(h - 1); } // The integer maps are these rounded — ONE mapping, so a sub-pixel trace and an integer hit-test // cannot drift. Rounding the OFFSET (not the absolute coordinate) keeps the int results identical // to what they were before the sub-pixel form existed: the offset is non-negative, so truncation // is floor regardless of where the box sits. double velToXf(const VelocityCurve::Box& box, double velocity) { const int w = std::max(0, box.width); if (w <= 0) return static_cast(box.left); const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin); return static_cast(box.left) + frac * static_cast(w); } double valueToYf(const VelocityCurve::Box& box, double value, CurveDomain d) { const int h = std::max(0, box.height); if (h <= 1) return static_cast(box.top); const double lo = curveYMin(d); const double frac = (clampValue(value, d) - lo) / (kCurveYMax - lo); return static_cast(box.top) + (1.0 - frac) * static_cast(h - 1); } int velToX(const VelocityCurve::Box& box, double velocity) { return box.left + static_cast(velToXf(box, velocity) - static_cast(box.left) + 0.5); } int valueToY(const VelocityCurve::Box& box, double value, CurveDomain d) { return box.top + static_cast(valueToYf(box, value, d) - static_cast(box.top) + 0.5); } } // namespace VelocityCurve VelocityCurve::flat() { VelocityCurve c; const double n = curveNeutral(CurveDomain::Unipolar); c.points_ = {{kVelMin, n}, {kVelMax, n}}; return c; } VelocityCurve VelocityCurve::linear() { VelocityCurve c; c.points_ = {{kVelMin, 0.0}, {kVelMax, kCurveYMax}}; return c; } VelocityCurve VelocityCurve::zero() { VelocityCurve c; c.domain_ = CurveDomain::Bipolar; const double n = curveNeutral(CurveDomain::Bipolar); c.points_ = {{kVelMin, n}, {kVelMax, n}}; return c; } VelocityCurve VelocityCurve::rampDown() { VelocityCurve c; c.points_ = {{kVelMin, kCurveYMax, false}, {kVelMax, 0.0, false}}; return c; } VelocityCurve VelocityCurve::fromPoints(std::vector pts, CurveDomain domain) { // Stable sort so coincident-X points keep their wire order (eval stays well-defined for // duplicate-X knots). // Trim before the endpoint synthesis below can add up to two more, then again after, so a // corrupt over-long blob lands at exactly the ceiling with its two endpoints intact. if (pts.size() > kMaxCurvePoints) pts.resize(kMaxCurvePoints); for (VelocityPoint& p : pts) { p.velocity = clampVelocity(p.velocity); p.value = clampValue(p.value, domain); } std::stable_sort(pts.begin(), pts.end(), [](const VelocityPoint& a, const VelocityPoint& b) { return a.velocity < b.velocity; }); if (pts.size() < 2) { return domain == CurveDomain::Bipolar ? zero() : flat(); } if (pts.front().velocity > kVelMin) { pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().value, pts.front().hard}); } else { pts.front().velocity = kVelMin; } if (pts.back().velocity < kVelMax) { pts.push_back(VelocityPoint{kVelMax, pts.back().value, pts.back().hard}); } else { pts.back().velocity = kVelMax; } if (pts.size() > kMaxCurvePoints) { // Drop the interior points nearest the end, never an endpoint. pts.erase(pts.begin() + static_cast(kMaxCurvePoints) - 1, pts.end() - 1); } VelocityCurve c; c.domain_ = domain; c.points_ = std::move(pts); return c; } double VelocityCurve::eval(double velocity) const { if (points_.empty()) return curveNeutral(domain_); if (points_.size() == 1) return clampValue(points_[0].value, domain_); const double v = clampVelocity(velocity); if (v <= points_.front().velocity) return clampValue(points_.front().value, domain_); if (v >= points_.back().velocity) return clampValue(points_.back().value, domain_); // Linear walk: this overload is the COLD one (a note-on, a paint column). The per-sample // reader is SplineCursor, which shares the same tangent + Hermite functions. 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): zero-width segment, no interior to blend. if (span <= 0.0) return clampValue(b.value, domain_); const double d = (b.value - a.value) / span; const SegmentTangents m = segmentTangents(points_.data(), points_.size(), i, d, span); const double y = hermiteAt(a.value, b.value, span, m.mA, m.mB, (v - a.velocity) / span); return clampValue(y, domain_); } } return clampValue(points_.back().value, domain_); // unreachable (v is between the endpoints) } int VelocityCurve::addPoint(double velocity, double value) { // At the ceiling the add is REFUSED outright rather than trading a point away — the existing // contour must come through an over-add bit-identical. if (points_.size() >= kMaxCurvePoints) return -1; const VelocityPoint p{clampVelocity(velocity), clampValue(value, domain_), false}; // First index strictly greater, so a duplicate-X point lands immediately after the existing one. std::size_t i = 0; while (i < points_.size() && points_[i].velocity <= p.velocity) ++i; points_.insert(points_.begin() + static_cast(i), p); return static_cast(i); } bool VelocityCurve::toggleHard(std::size_t index) { if (index >= points_.size()) return false; points_[index].hard = !points_[index].hard; return true; } bool VelocityCurve::setHard(std::size_t index, bool hard) { if (index >= points_.size()) return false; points_[index].hard = hard; return true; } VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double value) { if (index >= points_.size()) return VelocityPoint{}; // no-op (out of range) const bool isFirst = (index == 0); const bool isLast = (index + 1 == points_.size()); double newValue = clampValue(value, domain_); double newVel; if (isFirst) { newVel = kVelMin; } else if (isLast) { newVel = kVelMax; } else { const double lo = points_[index - 1].velocity; const double hi = points_[index + 1].velocity; newVel = std::clamp(clampVelocity(velocity), lo, hi); } points_[index] = VelocityPoint{newVel, newValue, points_[index].hard}; 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) const { return CurvePixel{velToX(box, p.velocity), valueToY(box, p.value, domain_)}; } VelocityCurve::CurvePixelF VelocityCurve::subpixelFromPoint(const Box& box, const VelocityPoint& p) const { return CurvePixelF{velToXf(box, p.velocity), valueToYf(box, p.value, domain_)}; } VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) const { // Exact inverse of velToX/valueToY (within one pixel); degenerate dims collapse the same way. 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)); const double lo = curveYMin(domain_); p.value = (h <= 1) ? kCurveYMax : clampValue(kCurveYMax - static_cast(y - box.top) / static_cast(h - 1) * (kCurveYMax - lo), domain_); 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 = valueToY(box, points_[i].value, domain_); 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 valPerPx = valuePerPixel(box, grabCurve.domain_); if (velPerPx <= 0.0 || valPerPx <= 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 the value increases upward, so a downward drag (positive dy) // LOWERS the value. const double newValue = grab.value - static_cast(dyPixels) * valPerPx; out.movePoint(index, newVel, newValue); // applies box + neighbour-X + endpoint-pin clamps return out; } bool VelocityCurve::equals(const VelocityCurve& other, double eps) const { if (domain_ != other.domain_) return false; 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].value - other.points_[i].value) > eps) return false; if (points_[i].hard != other.points_[i].hard) return false; } return true; } } // namespace reasampler::instrument::engine