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.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
// velocity_curve.h — velocity->amp transfer curve. 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.
|
||||
// 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
|
||||
|
||||
@@ -10,17 +11,29 @@
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
|
||||
// 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 kAmpMin = 0.0;
|
||||
inline constexpr double kAmpMax = 1.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 amp = 0.0; // Y, [0,1]
|
||||
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.
|
||||
@@ -28,44 +41,50 @@ 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 amp 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.
|
||||
// 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 default — see
|
||||
// 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, stable-sorts by velocity, forces both endpoints present (synthesized if
|
||||
// missing), falls back to flat() 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);
|
||||
// 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 kAmpMax; a
|
||||
// one-point curve returns that point's amp.
|
||||
// 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 amp);
|
||||
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 amp moves); out-of-range index is a no-op.
|
||||
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
|
||||
// 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 = amp UP the height (amp 1 at
|
||||
// top). Passed explicitly rather than a Rect — see header preamble.
|
||||
// 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;
|
||||
@@ -82,14 +101,15 @@ public:
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
};
|
||||
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
|
||||
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 amp 1.
|
||||
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
|
||||
// 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/amp over the box, then applies movePoint's clamp. Zero
|
||||
// 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);
|
||||
@@ -100,6 +120,7 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user