172 lines
11 KiB
C++
172 lines
11 KiB
C++
// 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 <cstdint>
|
||
#include <vector>
|
||
|
||
// 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<VelocityPoint> pts);
|
||
|
||
// The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant).
|
||
const std::vector<VelocityPoint>& 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<VelocityPoint> points_;
|
||
};
|
||
|
||
} // namespace reasampler::vst
|