9d38f87a2d
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.
127 lines
6.3 KiB
C++
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
|