// velocity_curve.h — PURE velocity->amp transfer curve (S-VIEW-9, r10). NO VST3, NO REAPER, NO // SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the // eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future // editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's // pixel delta back through here, committing the result to the zone through the same off-audio-thread // path a slider edit uses. // // WHAT IT IS. A monotonic-in-x transfer function mapping MIDI velocity (X: 0..127) to an amp scalar // (Y: 0..1), authored as an ordered list of control points. eval(velocity) is called ONCE per // note-on in Voice::start() (never per frame) to set the voice's velocityGain_, replacing the fixed // linear velocity/127 map. The curve is a per-PerformanceZone performance characteristic (D-B) — a // sibling of the AHDSR envelope, pitch engine, and keyTrack scalar — so it varies per sound, stored // on PerformanceZone and resolved onto the KeyZone at keymap build (mirror of keyTrack). // // DEFAULT — flat y=1 (fork R10-F1 Option A, Daniel 2026-07-27). VelocityCurve::flat() is the seeded // default: EVERY velocity plays at unity amp. This is a DELIBERATE, Daniel-approved behavior change // vs. the shipped linear velocity/127 map — soft hits are now full level until a curve is drawn. // NOT bit-identical to the pre-r10 engine, by design; do not "preserve" the linear response. // // THE INVARIANT (mirror of envelope_edit's S-VIEW-F2). A drag/edit can NEVER produce a curve eval // couldn't handle: // * X-ORDERED — a point clamps between its predecessor's and successor's velocity, so control // points never cross in X. This is what makes eval a well-defined FUNCTION (one amp per // velocity): each X falls in exactly one [p_i, p_{i+1}] segment. // * BOX-CLAMPED — velocity clamps to [0,127], amp clamps to [0,1] (the drawn box). // Both endpoints (velocity 0 and 127) are always present so eval is total over [0,127]; delete // refuses to remove them, and the constructors seed them. #pragma once #include #include // DELIBERATELY dependency-free at the boundary (no editor_geometry / Rect). This module sits BELOW // sampler_core in the link graph (KeyZone carries a VelocityCurve; Voice::start calls eval), and the // engine must not gain a transitive dependency on the editor's layout types. The editor hit-test / // inverse-map therefore takes an explicit pixel box (boxLeft/boxTop/boxWidth/boxHeight) rather than a // Rect — the future editor shell (S-VIEW-10) passes its box coords directly. Mirror of envelope_edit's // role, but one layer lower, so the coupling stays out of the engine core. namespace reasampler::vst { // The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into. inline constexpr double kVelMin = 0.0; inline constexpr double kVelMax = 127.0; inline constexpr double kAmpMin = 0.0; inline constexpr double kAmpMax = 1.0; // One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped // by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so // build curves through the named constructors / addPoint rather than pushing raw points. struct VelocityPoint { double velocity = 0.0; // X, [0,127] double amp = 0.0; // Y, [0,1] }; // The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors // envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth. inline constexpr int kCurveNodeGrabRadius = 6; // A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by // a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) through the knots — a genuine // 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 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: // R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity. static VelocityCurve flat(); // The classic linear ramp y = velocity/127 — endpoints (0,0) and (127,1). Retained for tests // and as the Option-B seed; NOT the default (see R10-F1). static VelocityCurve linear(); // Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the // deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is // stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present // (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list). // A list with fewer than 2 usable points falls back to flat(). Never trusts the wire blindly — // a corrupt/truncated blob yields a well-formed curve, never an invariant-violating one. static VelocityCurve fromPoints(std::vector pts); // The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant). const std::vector& points() const { return points_; } std::size_t size() const { return points_.size(); } // Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first, // 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 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; // --- Editing (for the S-VIEW-10 editor UI) -------------------------------------------------- // Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the // index of the inserted point. A new point at a velocity that duplicates an existing one is // inserted immediately AFTER it (so a subsequent move can separate them); the endpoints are not // special-cased on insert (a point at exactly 0 or 127 inserts adjacent to that endpoint). std::size_t addPoint(double velocity, double amp); // Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate // neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X // (index 0 stays at velocity 0, the last stays at 127) — only their AMP moves; their velocity // argument is ignored. An out-of-range index is a no-op. Returns the (possibly clamped) // resulting point. VelocityPoint movePoint(std::size_t index, double velocity, double amp); // Delete point `index`. The two endpoints (index 0 and the last) are NOT deletable — a request // to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a // point was removed. bool deletePoint(std::size_t index); // --- Editor hit-test + inverse map (mirror of envelope_edit) -------------------------------- // The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall. // X = velocity across the width (0 at boxLeft, 127 at boxLeft+boxWidth); Y = amp UP the height // (amp 1 at boxTop, amp 0 at boxTop+boxHeight-1). Passed explicitly (not a Rect) so this module // stays free of editor-layout types — see the header preamble. struct Box { int left = 0; int top = 0; int width = 0; int height = 0; }; // Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the // first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order // for determinism (mirror of nodeAtPoint). int pointAtPixel(const Box& box, int x, int y) const; // A node's drawn pixel position (S-VIEW-10). The ONE point->pixel mapping — the same mapping // pointAtPixel hit-tests against — exposed so the editor shell draws the trace + node handles // at exactly the coordinates the hit-test expects (draw and grab can never drift). struct CurvePixel { int x = 0; int y = 0; }; static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p); // The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands // as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within // the one-pixel quantum), so an added point appears under the cursor. Degenerate box: a // zero-width box reads velocity 0; a height <= 1 box reads amp 1 (the top row), mirroring // pixelFromPoint's degenerate collapse. static VelocityPoint pointFromPixel(const Box& box, int x, int y); // Resolve a drag of point `index` by a pixel delta since grab, given the curve AS OF GRAB TIME // (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box. // Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp // (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns // `grabCurve` unchanged. Pure — mirror of resolveNodeDrag. static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index, const Box& box, int dxPixels, int dyPixels); // Equality (for tests + round-trip assertions): same point count + each point equal within a // tight epsilon. bool equals(const VelocityCurve& other, double eps = 1e-9) const; private: // Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named // constructors + deserialize (see sample_map), which establish that invariant; the mutators // preserve it. std::vector points_; }; } // namespace reasampler::vst