Files
reasampler/src/core/instrument/engine/velocity_curve.h
T
daniel 9d38f87a2d instrument: one VELOCITY deck for all three velocity curves, bipolar and off by default for pitch and filter
Payload v12 appends the new velocity->pitch curve and folds the retired filter velAmount into its now-bipolar curve, so pre-v12 projects reopen sounding identical. Preview button takes a drawn play triangle.
2026-07-31 19:15:17 -04:00

127 lines
6.3 KiB
C++

// 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 <cstdint>
#include <vector>
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<VelocityPoint> pts, CurveDomain domain);
CurveDomain domain() const { return domain_; }
const std::vector<VelocityPoint>& 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<VelocityPoint> points_;
CurveDomain domain_ = CurveDomain::Unipolar;
};
} // namespace reasampler::instrument::engine