Files
reasampler/src/vst/velocity_curve.h
T

172 lines
11 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.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 (FritschCarlson 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
// FritschCarlson 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 (FritschCarlson 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