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:
2026-07-31 19:15:17 -04:00
parent 4fecb58c0a
commit 9d38f87a2d
37 changed files with 1020 additions and 320 deletions
+143 -36
View File
@@ -1,20 +1,24 @@
// Standalone tests for reasampler::instrument::engine::velocity_curve — no VST3, no REAPER, no framework. Same fast
// assert loop as the sibling pure tests. Assert the S-VIEW-9 velocity->amp transfer curve HARD:
// assert loop as the sibling pure tests. Assert the velocity transfer curve HARD:
//
// * eval — flat y=1 default (R10-F1 Option A: EVERY velocity -> 1.0), linear ramp, curved shape
// * eval — flat y=1 unipolar default (EVERY velocity -> 1.0), linear ramp, curved shape
// between points, box-clamp of an out-of-range velocity, monotonic-in-x over the whole domain.
// * the BIPOLAR domain — zero() is exactly 0 everywhere, the negative half evaluates and clamps
// at -1, eval is homogeneous in y (what the pre-v12 filter lift rests on), and the pixel maps
// put value 0 on the box's centre line rather than its floor.
// * editing — addPoint keeps X-order + box-clamp; movePoint clamps an interior point between its
// neighbours (can't cross) and box-clamps amp; endpoints are X-pinned (velocity 0 / 127) with
// only amp mobile; deletePoint removes interior points but REFUSES the two endpoints.
// neighbours (can't cross) and box-clamps the value; endpoints are X-pinned (velocity 0 / 127)
// with only the value mobile; deletePoint removes interior points but REFUSES the two endpoints.
// * hit-test + inverse map — pointAtPixel grabs a drawn node; resolvePointDrag maps pixel delta to
// a clamped point (endpoint X-pinned; interior clamped to neighbours); degenerate box -> no motion.
// * fromPoints — the deserialization repair: sorts by X, box-clamps, forces endpoints, and falls
// back to flat() for a sub-2-point list.
// back to each domain's OWN neutral for a sub-2-point list.
#include "../src/core/instrument/engine/velocity_curve.h"
#include <cmath>
#include <cstdio>
#include <vector>
using namespace reasampler;
using namespace reasampler::instrument::engine;
@@ -27,6 +31,17 @@ static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b
using Box = VelocityCurve::Box;
// The pixel maps are members (the y domain lives on the curve), so a mapping test speaks
// through a curve of the domain under test rather than a free function.
static const VelocityCurve& uni() {
static const VelocityCurve c = VelocityCurve::flat();
return c;
}
static const VelocityCurve& bip() {
static const VelocityCurve c = VelocityCurve::zero();
return c;
}
// --- eval ---------------------------------------------------------------------
static void testFlatIsUnityEverywhere() {
@@ -151,11 +166,11 @@ static void testAddPointKeepsXOrderAndClamps() {
const std::size_t i = c.addPoint(60.0, 0.3);
CHECK(i == 1); // inserted between the two endpoints
CHECK(c.size() == 3);
CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].amp, 0.3));
CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].value, 0.3));
// Out-of-box add clamps into [0,127] x [0,1].
c.addPoint(500.0, 5.0);
const VelocityPoint& last = c.points().back();
CHECK(near(last.velocity, 127.0) && near(last.amp, 1.0));
CHECK(near(last.velocity, 127.0) && near(last.value, 1.0));
// Points remain X-ordered.
for (std::size_t k = 1; k < c.size(); ++k)
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
@@ -171,7 +186,7 @@ static void testMoveInteriorClampsToNeighbours() {
// Try to drag idx 1 PAST idx 2 (velocity 200): clamps to idx 2's velocity (80), not beyond.
const VelocityPoint r = c.movePoint(1, 200.0, 0.5);
CHECK(near(r.velocity, 80.0));
CHECK(near(r.amp, 0.5)); // amp is free (box-clamped only)
CHECK(near(r.value, 0.5)); // amp is free (box-clamped only)
// Try to drag idx 1 BELOW idx 0 (velocity -5): clamps to idx 0's velocity (0).
const VelocityPoint r2 = c.movePoint(1, -5.0, 0.5);
CHECK(near(r2.velocity, 0.0));
@@ -182,18 +197,18 @@ static void testMoveEndpointsArePinnedInX() {
// Move the first endpoint: velocity argument ignored (pinned at 0), amp moves.
const VelocityPoint f = c.movePoint(0, 50.0, 0.25);
CHECK(near(f.velocity, 0.0));
CHECK(near(f.amp, 0.25));
CHECK(near(f.value, 0.25));
// Move the last endpoint: pinned at 127, amp moves, and amp box-clamps.
const VelocityPoint l = c.movePoint(1, 10.0, 5.0);
CHECK(near(l.velocity, 127.0));
CHECK(near(l.amp, 1.0));
CHECK(near(l.value, 1.0));
}
static void testMoveOutOfRangeIndexIsNoOp() {
VelocityCurve c = VelocityCurve::linear();
c.movePoint(99, 50.0, 0.5);
CHECK(c.size() == 2);
CHECK(near(c.points()[0].amp, 0.0) && near(c.points()[1].amp, 1.0)); // unchanged
CHECK(near(c.points()[0].value, 0.0) && near(c.points()[1].value, 1.0)); // unchanged
}
// --- editing: deletePoint -----------------------------------------------------
@@ -237,11 +252,11 @@ static void testResolveDragMovesAndClamps() {
// Drag idx 1 right 10px, up 10px: velocity +10 (->70), amp +0.10 (up = higher amp -> 0.60).
const VelocityCurve moved = VelocityCurve::resolvePointDrag(grab, 1, b, 10, -10);
CHECK(near(moved.points()[1].velocity, 70.0, 1e-6));
CHECK(near(moved.points()[1].amp, 0.60, 1e-6));
CHECK(near(moved.points()[1].value, 0.60, 1e-6));
// Dragging the first endpoint horizontally does not move it in X (pinned), only amp.
const VelocityCurve movedEnd = VelocityCurve::resolvePointDrag(grab, 0, b, 40, -20);
CHECK(near(movedEnd.points()[0].velocity, 0.0));
CHECK(near(movedEnd.points()[0].amp, 0.20, 1e-6)); // dragged up 20px = +0.20 from 0
CHECK(near(movedEnd.points()[0].value, 0.20, 1e-6)); // dragged up 20px = +0.20 from 0
}
static void testResolveDragDegenerateBoxNoMotion() {
@@ -255,7 +270,7 @@ static void testResolveDragDegenerateBoxNoMotion() {
static void testFromPointsSortsClampsAndForcesEndpoints() {
// Unsorted, out-of-box, missing endpoints -> repaired to a valid curve.
std::vector<VelocityPoint> raw = {{80.0, 0.9}, {20.0, -1.0}, {50.0, 2.0}};
const VelocityCurve c = VelocityCurve::fromPoints(raw);
const VelocityCurve c = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar);
// X-ordered.
for (std::size_t k = 1; k < c.size(); ++k)
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
@@ -264,15 +279,101 @@ static void testFromPointsSortsClampsAndForcesEndpoints() {
CHECK(near(c.points().back().velocity, 127.0));
// Interior amps box-clamped (the -1 became 0, the 2 became 1).
for (const VelocityPoint& p : c.points()) {
CHECK(p.amp >= 0.0 - 1e-12 && p.amp <= 1.0 + 1e-12);
CHECK(p.value >= 0.0 - 1e-12 && p.value <= 1.0 + 1e-12);
}
}
static void testFromPointsSubTwoFallsBackToFlat() {
const VelocityCurve c0 = VelocityCurve::fromPoints({});
const VelocityCurve c0 = VelocityCurve::fromPoints({}, CurveDomain::Unipolar);
CHECK(c0.equals(VelocityCurve::flat()));
const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}});
const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Unipolar);
CHECK(c1.equals(VelocityCurve::flat()));
// The bipolar fallback is the domain's OWN neutral, not the unipolar one: degrading a
// corrupt pitch/filter curve to flat-at-unity would transpose or open the filter fully.
const VelocityCurve b0 = VelocityCurve::fromPoints({}, CurveDomain::Bipolar);
CHECK(b0.equals(VelocityCurve::zero()));
const VelocityCurve b1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Bipolar);
CHECK(b1.equals(VelocityCurve::zero()));
}
// --- the bipolar domain -------------------------------------------------------
static void testZeroIsExactlyZeroAtEveryVelocity() {
// The off-by-default contract: not "approximately zero" — EXACTLY zero, so a pitch or
// cutoff offset derived from it cannot nudge anything.
const VelocityCurve c = VelocityCurve::zero();
CHECK(c.domain() == CurveDomain::Bipolar);
for (int v = -20; v <= 200; ++v) CHECK(c.eval(v) == 0.0);
CHECK(c.size() == 2);
}
static void testBipolarEvalSpansTheNegativeHalf() {
// A ramp from -1 at velocity 0 to +1 at 127: collinear knots, so the spline is the exact
// straight line through zero — the whole point of the widened domain.
const VelocityCurve c =
VelocityCurve::fromPoints({{0.0, -1.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
CHECK(near(c.eval(0), -1.0));
CHECK(near(c.eval(127), 1.0));
CHECK(near(c.eval(63.5), 0.0, 1e-12));
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 2.0 * v / 127.0 - 1.0, 1e-12));
}
static void testUnipolarClampsAtZeroWhereBipolarDoesNot() {
// The same negative knot, read in the two domains: unipolar floors it at 0 (an amp gain
// cannot be negative), bipolar keeps it.
const std::vector<VelocityPoint> raw = {{0.0, -0.5}, {127.0, 0.5}};
const VelocityCurve u = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar);
const VelocityCurve b = VelocityCurve::fromPoints(raw, CurveDomain::Bipolar);
CHECK(near(u.eval(0), 0.0));
CHECK(near(b.eval(0), -0.5));
// Out-of-domain magnitudes clamp to each domain's own floor.
const VelocityCurve b2 =
VelocityCurve::fromPoints({{0.0, -9.0}, {127.0, 9.0}}, CurveDomain::Bipolar);
CHECK(near(b2.eval(0), -1.0));
CHECK(near(b2.eval(127), 1.0));
}
static void testEvalIsHomogeneousInY() {
// The property the pre-v12 filter lift rests on: scaling every knot's y by k scales the
// whole evaluated curve by k. Asserted against a CURVED (non-collinear) knot set, where
// the Fritsch-Carlson tangents are actually doing work.
const std::vector<VelocityPoint> knots = {
{0.0, 0.1}, {30.0, 0.15}, {64.0, 0.9}, {100.0, 0.4}, {127.0, 1.0}};
const VelocityCurve base = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar);
for (const double k : {0.75, -0.4, 1.0}) {
std::vector<VelocityPoint> scaled = knots;
for (VelocityPoint& p : scaled) p.value *= k;
const VelocityCurve s = VelocityCurve::fromPoints(scaled, CurveDomain::Bipolar);
for (int v = 0; v <= 127; ++v) CHECK(near(s.eval(v), k * base.eval(v), 1e-12));
}
}
static void testBipolarPixelMapPutsZeroOnTheCentreLine() {
// The same box, read in the two domains: value 0 sits at the vertical centre for a bipolar
// curve and at the bottom row for a unipolar one — the one mapping difference the shared
// popup code path has to get right.
const Box box{10, 20, 100, 101}; // 100 value rows: centre is 50 rows down
CHECK(bip().pixelFromPoint(box, {0.0, 0.0}).y == 70);
CHECK(uni().pixelFromPoint(box, {0.0, 0.0}).y == 120);
// Each domain's floor lands on the bottom row, its ceiling on the top.
CHECK(bip().pixelFromPoint(box, {0.0, -1.0}).y == 120);
CHECK(bip().pixelFromPoint(box, {0.0, 1.0}).y == 20);
// And the inverse agrees: the centre row reads back as 0 in bipolar, mid-scale in unipolar.
CHECK(near(bip().pointFromPixel(box, 10, 70).value, 0.0, 1e-12));
CHECK(near(uni().pointFromPixel(box, 10, 70).value, 0.5, 1e-12));
}
static void testBipolarDragCoversTwiceTheValueRange() {
// A drag of N pixels moves twice as much value in bipolar as in unipolar over the same box
// — the domain spans 2.0, not 1.0. Both still land inside their own domain.
const Box box{0, 0, 127, 101}; // 100 value rows
VelocityCurve u = VelocityCurve::flat();
u.movePoint(0, 0.0, 0.5);
VelocityCurve b = VelocityCurve::zero();
const VelocityCurve uMoved = VelocityCurve::resolvePointDrag(u, 0, box, 0, -10);
const VelocityCurve bMoved = VelocityCurve::resolvePointDrag(b, 0, box, 0, -10);
CHECK(near(uMoved.points()[0].value, 0.60, 1e-6));
CHECK(near(bMoved.points()[0].value, 0.20, 1e-6));
}
// --- S-VIEW-10 pixel maps (the editor draw/add seam) -----------------------------
@@ -282,29 +383,29 @@ static void testPixelFromPointMapsCornersAndMidpoint() {
// (h - 1) rows with amp 1 at the top — assert the drawn corners land where the module's own
// hit-test mapping puts them.
const Box box{10, 20, 100, 51};
const auto tl = VelocityCurve::pixelFromPoint(box, {0.0, 1.0});
const auto tl = uni().pixelFromPoint(box, {0.0, 1.0});
CHECK(tl.x == 10 && tl.y == 20);
const auto br = VelocityCurve::pixelFromPoint(box, {127.0, 0.0});
const auto br = uni().pixelFromPoint(box, {127.0, 0.0});
CHECK(br.x == 110 && br.y == 70);
const auto mid = VelocityCurve::pixelFromPoint(box, {63.5, 0.5});
const auto mid = uni().pixelFromPoint(box, {63.5, 0.5});
CHECK(mid.x == 60 && mid.y == 45);
// Out-of-box values are clamped by the mapping (velocity 200 draws at the right edge).
const auto clamped = VelocityCurve::pixelFromPoint(box, {200.0, 2.0});
const auto clamped = uni().pixelFromPoint(box, {200.0, 2.0});
CHECK(clamped.x == 110 && clamped.y == 20);
}
static void testPointFromPixelInvertsAndClamps() {
const Box box{10, 20, 100, 51};
// Exact corners invert exactly.
const VelocityPoint tl = VelocityCurve::pointFromPixel(box, 10, 20);
CHECK(near(tl.velocity, 0.0) && near(tl.amp, 1.0));
const VelocityPoint br = VelocityCurve::pointFromPixel(box, 110, 70);
CHECK(near(br.velocity, 127.0) && near(br.amp, 0.0));
const VelocityPoint tl = uni().pointFromPixel(box, 10, 20);
CHECK(near(tl.velocity, 0.0) && near(tl.value, 1.0));
const VelocityPoint br = uni().pointFromPixel(box, 110, 70);
CHECK(near(br.velocity, 127.0) && near(br.value, 0.0));
// A pixel OUTSIDE the box clamps into the domain (never an invariant-violating point).
const VelocityPoint out = VelocityCurve::pointFromPixel(box, -50, 500);
CHECK(near(out.velocity, 0.0) && near(out.amp, 0.0));
const VelocityPoint out2 = VelocityCurve::pointFromPixel(box, 500, -50);
CHECK(near(out2.velocity, 127.0) && near(out2.amp, 1.0));
const VelocityPoint out = uni().pointFromPixel(box, -50, 500);
CHECK(near(out.velocity, 0.0) && near(out.value, 0.0));
const VelocityPoint out2 = uni().pointFromPixel(box, 500, -50);
CHECK(near(out2.velocity, 127.0) && near(out2.value, 1.0));
}
static void testPixelMapsRoundTripWithinOnePixelQuantum() {
@@ -315,10 +416,10 @@ static void testPixelMapsRoundTripWithinOnePixelQuantum() {
const double ampQuantum = 1.0 / 119.0;
const VelocityPoint pts[] = {{0.0, 1.0}, {127.0, 0.0}, {40.0, 0.25}, {90.5, 0.66}, {63.5, 0.5}};
for (const VelocityPoint& p : pts) {
const auto px = VelocityCurve::pixelFromPoint(box, p);
const VelocityPoint back = VelocityCurve::pointFromPixel(box, px.x, px.y);
const auto px = uni().pixelFromPoint(box, p);
const VelocityPoint back = uni().pointFromPixel(box, px.x, px.y);
CHECK(std::fabs(back.velocity - p.velocity) <= velQuantum);
CHECK(std::fabs(back.amp - p.amp) <= ampQuantum);
CHECK(std::fabs(back.value - p.value) <= ampQuantum);
}
}
@@ -328,15 +429,15 @@ static void testPixelFromPointAgreesWithPointAtPixel() {
VelocityCurve c = VelocityCurve::linear();
const std::size_t idx = c.addPoint(70.0, 0.3);
const Box box{0, 0, 200, 100};
const auto px = VelocityCurve::pixelFromPoint(box, c.points()[idx]);
const auto px = uni().pixelFromPoint(box, c.points()[idx]);
CHECK(c.pointAtPixel(box, px.x, px.y) == static_cast<int>(idx));
}
static void testPointFromPixelDegenerateBox() {
// Zero width -> velocity 0; height <= 1 -> amp 1 (mirrors the forward map's degenerate pins).
const Box flat{5, 5, 0, 0};
const VelocityPoint p = VelocityCurve::pointFromPixel(flat, 50, 50);
CHECK(near(p.velocity, 0.0) && near(p.amp, 1.0));
const VelocityPoint p = uni().pointFromPixel(flat, 50, 50);
CHECK(near(p.velocity, 0.0) && near(p.value, 1.0));
}
static void testFromPointsRoundTripsAValidCurve() {
@@ -344,7 +445,7 @@ static void testFromPointsRoundTripsAValidCurve() {
orig.addPoint(40.0, 0.2);
orig.addPoint(90.0, 0.7);
// fromPoints over its OWN points reproduces it exactly (already valid, sort is stable no-op).
const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points());
const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points(), CurveDomain::Unipolar);
CHECK(rebuilt.equals(orig));
}
@@ -367,6 +468,12 @@ int main() {
testResolveDragDegenerateBoxNoMotion();
testFromPointsSortsClampsAndForcesEndpoints();
testFromPointsSubTwoFallsBackToFlat();
testZeroIsExactlyZeroAtEveryVelocity();
testBipolarEvalSpansTheNegativeHalf();
testUnipolarClampsAtZeroWhereBipolarDoesNot();
testEvalIsHomogeneousInY();
testBipolarPixelMapPutsZeroOnTheCentreLine();
testBipolarDragCoversTwiceTheValueRange();
testPixelFromPointMapsCornersAndMidpoint();
testPointFromPixelInvertsAndClamps();
testPixelMapsRoundTripWithinOnePixelQuantum();