// velocity_curve.h — the velocity->modulation transfer curve, shared by all three // destinations (amp gain, pitch offset, filter cutoff offset). eval(velocity) is called once // per note-on in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit // pixel Box rather than a Rect: this module sits below sampler_core in the link graph and must // not gain a transitive dependency on editor-layout types. #pragma once #include #include namespace reasampler::instrument::engine { // The MIDI velocity domain [0,127] — the X span every point clamps into. inline constexpr double kVelMin = 0.0; inline constexpr double kVelMax = 127.0; inline constexpr double kCurveYMax = 1.0; // The curve's Y range. UNIPOLAR [0,1] is a GAIN — the amp's domain, where the do-nothing // curve is flat at 1. BIPOLAR [-1,1] is a SIGNED modulation depth — the pitch and filter // domains, where the do-nothing curve is flat at 0 and the sign picks the direction. A // bipolar curve is therefore both the shape and the amount: there is no separate depth // control behind it. enum class CurveDomain { Unipolar, Bipolar }; constexpr double curveYMin(CurveDomain d) { return d == CurveDomain::Bipolar ? -1.0 : 0.0; } // The value that changes nothing in each domain — unity gain, or zero modulation. Every // defensive fallback lands here so a corrupt blob loses the shaping rather than inventing one. constexpr double curveNeutral(CurveDomain d) { return d == CurveDomain::Bipolar ? 0.0 : 1.0; } // A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves // through the named constructors / addPoint rather than pushing raw points. struct VelocityPoint { double velocity = 0.0; // X, [0,127] double value = 0.0; // Y, in the owning curve's domain }; // Pick radius (px) around a node's drawn point for the editor hit-test. inline constexpr int kCurveNodeGrabRadius = 6; // An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite // spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never // overshoots a segment's value range. For collinear knots the tangents reduce to the secant // slope, so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints // (velocity 0 and 127) are load-bearing: they keep eval total over the domain and are never // deletable. eval is HOMOGENEOUS in y — scaling every knot's value by k scales the whole curve // by k exactly, which is what lets the codec fold a retired depth control into stored knots. class VelocityCurve { public: // flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see // velocity_curve in the directory CLAUDE.md for why this isn't bit-identical to the // pre-existing linear() response. static VelocityCurve flat(); static VelocityCurve linear(); // The bipolar default: flat at 0, so velocity modulates nothing until a curve is drawn. static VelocityCurve zero(); // Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps // each point into `domain`, stable-sorts by velocity, forces both endpoints present // (synthesized if missing), falls back to the domain's neutral curve if fewer than 2 usable // points remain. A corrupt/truncated blob yields a well-formed curve, never an // invariant-violating one. static VelocityCurve fromPoints(std::vector pts, CurveDomain domain); CurveDomain domain() const { return domain_; } const std::vector& points() const { return points_; } std::size_t size() const { return points_.size(); } // Degenerate cases (shouldn't occur post-construction): empty curve returns the domain's // neutral; a one-point curve returns that point's value. double eval(double velocity) const; // Inserted at a velocity duplicating an existing point lands immediately after it, so a // subsequent move can separate them. Returns the inserted index. std::size_t addPoint(double velocity, double value); // Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two // endpoints are pinned in X (only their value moves); out-of-range index is a no-op. VelocityPoint movePoint(std::size_t index, double velocity, double value); // Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op // returning false. bool deletePoint(std::size_t index); // The drawn box, in pixels: X = velocity across the width, Y = value UP the height (the // domain's max at top). Passed explicitly rather than a Rect — see header preamble. struct Box { int left = 0; int top = 0; int width = 0; int height = 0; }; // Index of the first point within the pick radius on both axes, or -1 for a miss. First-match // in point order for determinism. int pointAtPixel(const Box& box, int x, int y) const; // The one point->pixel mapping, exposed so drawing and hit-testing can never drift apart. struct CurvePixel { int x = 0; int y = 0; }; CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p) const; // Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space // click lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 // reads the domain's max (the top row is what a collapsed box draws). VelocityPoint pointFromPixel(const Box& box, int x, int y) const; // `grabCurve` is the curve as of mouse-down (shell snapshots it so the delta is absolute). // Maps the pixel delta to velocity/value over the box, then applies movePoint's clamp. Zero // width/height box or out-of-range index returns grabCurve unchanged. static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index, const Box& box, int dxPixels, int dyPixels); bool equals(const VelocityCurve& other, double eps = 1e-9) const; private: // Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the // invariant, mutators preserve it. std::vector points_; CurveDomain domain_ = CurveDomain::Unipolar; }; } // namespace reasampler::instrument::engine