381 lines
17 KiB
C++
381 lines
17 KiB
C++
// 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:
|
||
//
|
||
// * eval — flat y=1 default (R10-F1 Option A: 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.
|
||
// * 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.
|
||
// * 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.
|
||
|
||
#include "../src/core/instrument/engine/velocity_curve.h"
|
||
|
||
#include <cmath>
|
||
#include <cstdio>
|
||
|
||
using namespace reasampler;
|
||
using namespace reasampler::instrument::engine;
|
||
|
||
static int g_fail = 0;
|
||
#define CHECK(cond) do { if(!(cond)) { \
|
||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||
|
||
static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }
|
||
|
||
using Box = VelocityCurve::Box;
|
||
|
||
// --- eval ---------------------------------------------------------------------
|
||
|
||
static void testFlatIsUnityEverywhere() {
|
||
const VelocityCurve c = VelocityCurve::flat();
|
||
// R10-F1 Option A: every velocity plays at full level. Sweep the whole domain.
|
||
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 1.0));
|
||
// Two endpoints only.
|
||
CHECK(c.size() == 2);
|
||
}
|
||
|
||
static void testLinearRamp() {
|
||
const VelocityCurve c = VelocityCurve::linear();
|
||
CHECK(near(c.eval(0), 0.0));
|
||
CHECK(near(c.eval(127), 1.0));
|
||
// linear() is an EXACT straight line y = velocity/127: at any velocity the amp equals v/127.
|
||
CHECK(near(c.eval(63.5), 0.5)); // the exact midpoint
|
||
CHECK(near(c.eval(64.0), 64.0 / 127.0));
|
||
CHECK(near(c.eval(100.0), 100.0 / 127.0));
|
||
}
|
||
|
||
static void testEvalBoxClampsOutOfRangeVelocity() {
|
||
const VelocityCurve c = VelocityCurve::linear();
|
||
CHECK(near(c.eval(-10.0), 0.0)); // below 0 -> reads velocity-0 endpoint amp
|
||
CHECK(near(c.eval(200.0), 1.0)); // above 127 -> reads velocity-127 endpoint amp
|
||
}
|
||
|
||
static void testEvalMonotonicInX() {
|
||
// A curve that dips then rises must still be a well-defined FUNCTION (one amp per velocity) and
|
||
// monotonic WITHIN each segment. Build (0,1)->(64,0)->(127,1): eval sweeps must be single-valued
|
||
// and each half monotonic (down then up), never oscillating within a segment.
|
||
VelocityCurve c = VelocityCurve::flat();
|
||
c.movePoint(0, 0, 1.0);
|
||
c.addPoint(64.0, 0.0);
|
||
c.movePoint(2, 127, 1.0); // index 2 is the last endpoint after the insert
|
||
CHECK(c.size() == 3);
|
||
// Descending half [0,64]: non-increasing.
|
||
double prev = c.eval(0);
|
||
for (int v = 1; v <= 64; ++v) {
|
||
const double cur = c.eval(v);
|
||
CHECK(cur <= prev + 1e-9);
|
||
prev = cur;
|
||
}
|
||
// Ascending half [64,127]: non-decreasing.
|
||
prev = c.eval(64);
|
||
for (int v = 65; v <= 127; ++v) {
|
||
const double cur = c.eval(v);
|
||
CHECK(cur >= prev - 1e-9);
|
||
prev = cur;
|
||
}
|
||
CHECK(near(c.eval(64), 0.0)); // the trough sits exactly on the moved point
|
||
}
|
||
|
||
// --- eval: monotone cubic Hermite spline (Fritsch–Carlson) --------------------
|
||
|
||
static void testCollinearControlPointsReproduceExactLinearRamp() {
|
||
// The Option-B guarantee: for COLLINEAR knots the FC tangents reduce to the secant slope, so the
|
||
// spline IS the straight line y = velocity/127 — bit-exact, not merely close. Add an interior
|
||
// point that sits EXACTLY on the linear ramp so all three knots are collinear.
|
||
VelocityCurve c = VelocityCurve::linear(); // (0,0),(127,1)
|
||
c.addPoint(60.0, 60.0 / 127.0); // on the line -> still collinear
|
||
// Every velocity must equal velocity/127 to full double precision (bit-exact reproduction).
|
||
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), v / 127.0, 1e-12));
|
||
// And the untouched linear() with only its two endpoints, too.
|
||
const VelocityCurve line = VelocityCurve::linear();
|
||
for (int v = 0; v <= 127; ++v) CHECK(near(line.eval(v), v / 127.0, 1e-12));
|
||
}
|
||
|
||
static void testNoOvershootWithSharpInteriorDip() {
|
||
// A sharp interior dip is the classic overshoot trap: a NON-monotone interpolant (Catmull-Rom /
|
||
// natural cubic) would bulge the curve below 0 near the trough. Fritsch–Carlson must keep every
|
||
// sampled amp inside [0,1] across the whole domain. Build (0,1)->(64,0)->(127,1).
|
||
VelocityCurve c = VelocityCurve::flat();
|
||
c.movePoint(0, 0, 1.0);
|
||
c.addPoint(64.0, 0.0);
|
||
c.movePoint(2, 127, 1.0);
|
||
for (int v = 0; v <= 127; ++v) {
|
||
const double y = c.eval(v);
|
||
CHECK(y >= 0.0 - 1e-12 && y <= 1.0 + 1e-12);
|
||
}
|
||
// A dense sub-integer sweep too (the spline could overshoot between integer velocities).
|
||
for (int k = 0; k <= 1270; ++k) {
|
||
const double y = c.eval(k / 10.0);
|
||
CHECK(y >= 0.0 - 1e-12 && y <= 1.0 + 1e-12);
|
||
}
|
||
CHECK(near(c.eval(64), 0.0)); // knot honored exactly
|
||
}
|
||
|
||
static void testSplineStaysSingleValuedMonotoneInEachSegment() {
|
||
// A rising staircase of knots: the spline must be non-decreasing across the whole domain (the FC
|
||
// limiter forbids overshoot, so a monotone-increasing knot set yields a monotone-increasing
|
||
// curve — no local wiggles that would make eval multi-valued in feel).
|
||
VelocityCurve c = VelocityCurve::linear();
|
||
c.addPoint(30.0, 0.1);
|
||
c.addPoint(60.0, 0.15); // a near-flat run then a steep rise: overshoot bait for a plain cubic
|
||
c.addPoint(90.0, 0.9);
|
||
double prev = c.eval(0);
|
||
for (int k = 1; k <= 1270; ++k) {
|
||
const double cur = c.eval(k / 10.0);
|
||
CHECK(cur >= prev - 1e-9); // non-decreasing everywhere -> single-valued, no wiggle
|
||
CHECK(cur >= 0.0 - 1e-12 && cur <= 1.0 + 1e-12);
|
||
prev = cur;
|
||
}
|
||
}
|
||
|
||
static void testSplinePinsEndpointsExactly() {
|
||
// The curve must pass THROUGH every control point, endpoints included, regardless of curvature.
|
||
VelocityCurve c = VelocityCurve::flat();
|
||
c.movePoint(0, 0, 0.2); // first endpoint amp 0.2
|
||
c.addPoint(40.0, 0.9);
|
||
c.addPoint(80.0, 0.1);
|
||
c.movePoint(3, 127, 0.7); // last endpoint amp 0.7
|
||
CHECK(near(c.eval(0), 0.2));
|
||
CHECK(near(c.eval(40), 0.9));
|
||
CHECK(near(c.eval(80), 0.1));
|
||
CHECK(near(c.eval(127), 0.7));
|
||
}
|
||
|
||
// --- editing: addPoint --------------------------------------------------------
|
||
|
||
static void testAddPointKeepsXOrderAndClamps() {
|
||
VelocityCurve c = VelocityCurve::linear(); // (0,0), (127,1)
|
||
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));
|
||
// 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));
|
||
// Points remain X-ordered.
|
||
for (std::size_t k = 1; k < c.size(); ++k)
|
||
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
|
||
}
|
||
|
||
// --- editing: movePoint -------------------------------------------------------
|
||
|
||
static void testMoveInteriorClampsToNeighbours() {
|
||
VelocityCurve c = VelocityCurve::linear();
|
||
c.addPoint(40.0, 0.4); // idx 1
|
||
c.addPoint(80.0, 0.8); // idx 2
|
||
CHECK(c.size() == 4); // (0,0)(40,.4)(80,.8)(127,1)
|
||
// 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)
|
||
// 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));
|
||
}
|
||
|
||
static void testMoveEndpointsArePinnedInX() {
|
||
VelocityCurve c = VelocityCurve::linear();
|
||
// 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));
|
||
// 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));
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// --- editing: deletePoint -----------------------------------------------------
|
||
|
||
static void testDeleteRemovesInteriorRefusesEndpoints() {
|
||
VelocityCurve c = VelocityCurve::linear();
|
||
c.addPoint(60.0, 0.5); // idx 1
|
||
CHECK(c.size() == 3);
|
||
// Endpoints refuse deletion.
|
||
CHECK(!c.deletePoint(0));
|
||
CHECK(!c.deletePoint(2));
|
||
CHECK(c.size() == 3);
|
||
// Interior deletes.
|
||
CHECK(c.deletePoint(1));
|
||
CHECK(c.size() == 2);
|
||
// Out-of-range refuses.
|
||
CHECK(!c.deletePoint(9));
|
||
}
|
||
|
||
// --- hit-test + inverse map ---------------------------------------------------
|
||
|
||
// A 127px-wide, 101px-tall box at origin: velocity->x is 1px/unit, amp->y spans 100 rows (1 px per
|
||
// 0.01 amp), amp 1 at top (y=0), amp 0 at bottom (y=100).
|
||
static Box wideBox() { return Box{0, 0, 127, 101}; }
|
||
|
||
static void testPointAtPixelGrabsDrawnNode() {
|
||
VelocityCurve c = VelocityCurve::linear(); // (0,0) at (0,100); (127,1) at (127,0)
|
||
const Box b = wideBox();
|
||
// Grab near the first endpoint's drawn point (x=0, y=100).
|
||
CHECK(c.pointAtPixel(b, 0, 100) == 0);
|
||
// Grab near the last endpoint (x=127, y=0).
|
||
CHECK(c.pointAtPixel(b, 127, 0) == 1);
|
||
// A point far from any node misses.
|
||
CHECK(c.pointAtPixel(b, 63, 50) == -1);
|
||
}
|
||
|
||
static void testResolveDragMovesAndClamps() {
|
||
VelocityCurve grab = VelocityCurve::linear();
|
||
grab.addPoint(60.0, 0.5); // idx 1, drawn at x=60, y=50
|
||
const Box b = wideBox();
|
||
// 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));
|
||
// 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
|
||
}
|
||
|
||
static void testResolveDragDegenerateBoxNoMotion() {
|
||
const VelocityCurve grab = VelocityCurve::linear();
|
||
const VelocityCurve r = VelocityCurve::resolvePointDrag(grab, 1, Box{0, 0, 0, 0}, 50, 50);
|
||
CHECK(r.equals(grab)); // zero-size box -> unchanged
|
||
}
|
||
|
||
// --- fromPoints (deserialization repair) --------------------------------------
|
||
|
||
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);
|
||
// X-ordered.
|
||
for (std::size_t k = 1; k < c.size(); ++k)
|
||
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
|
||
// Endpoints forced present at 0 and 127.
|
||
CHECK(near(c.points().front().velocity, 0.0));
|
||
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);
|
||
}
|
||
}
|
||
|
||
static void testFromPointsSubTwoFallsBackToFlat() {
|
||
const VelocityCurve c0 = VelocityCurve::fromPoints({});
|
||
CHECK(c0.equals(VelocityCurve::flat()));
|
||
const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}});
|
||
CHECK(c1.equals(VelocityCurve::flat()));
|
||
}
|
||
|
||
// --- S-VIEW-10 pixel maps (the editor draw/add seam) -----------------------------
|
||
|
||
static void testPixelFromPointMapsCornersAndMidpoint() {
|
||
// Box 100 px wide, 51 px tall at (10, 20). velToX spans the WIDTH (frac * w); ampToY spans
|
||
// (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});
|
||
CHECK(tl.x == 10 && tl.y == 20);
|
||
const auto br = VelocityCurve::pixelFromPoint(box, {127.0, 0.0});
|
||
CHECK(br.x == 110 && br.y == 70);
|
||
const auto mid = VelocityCurve::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});
|
||
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));
|
||
// 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));
|
||
}
|
||
|
||
static void testPixelMapsRoundTripWithinOnePixelQuantum() {
|
||
// Forward-then-inverse must agree within one pixel's worth of value (the rounding quantum) —
|
||
// this is what keeps an added point under the cursor and a drawn node grabbable where drawn.
|
||
const Box box{3, 7, 160, 120};
|
||
const double velQuantum = 127.0 / 160.0;
|
||
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);
|
||
CHECK(std::fabs(back.velocity - p.velocity) <= velQuantum);
|
||
CHECK(std::fabs(back.amp - p.amp) <= ampQuantum);
|
||
}
|
||
}
|
||
|
||
static void testPixelFromPointAgreesWithPointAtPixel() {
|
||
// A node drawn at pixelFromPoint's coordinates must hit-test back to that same node — the
|
||
// draw/grab no-drift contract the two helpers exist to guarantee.
|
||
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]);
|
||
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));
|
||
}
|
||
|
||
static void testFromPointsRoundTripsAValidCurve() {
|
||
VelocityCurve orig = VelocityCurve::linear();
|
||
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());
|
||
CHECK(rebuilt.equals(orig));
|
||
}
|
||
|
||
int main() {
|
||
testFlatIsUnityEverywhere();
|
||
testLinearRamp();
|
||
testEvalBoxClampsOutOfRangeVelocity();
|
||
testEvalMonotonicInX();
|
||
testCollinearControlPointsReproduceExactLinearRamp();
|
||
testNoOvershootWithSharpInteriorDip();
|
||
testSplineStaysSingleValuedMonotoneInEachSegment();
|
||
testSplinePinsEndpointsExactly();
|
||
testAddPointKeepsXOrderAndClamps();
|
||
testMoveInteriorClampsToNeighbours();
|
||
testMoveEndpointsArePinnedInX();
|
||
testMoveOutOfRangeIndexIsNoOp();
|
||
testDeleteRemovesInteriorRefusesEndpoints();
|
||
testPointAtPixelGrabsDrawnNode();
|
||
testResolveDragMovesAndClamps();
|
||
testResolveDragDegenerateBoxNoMotion();
|
||
testFromPointsSortsClampsAndForcesEndpoints();
|
||
testFromPointsSubTwoFallsBackToFlat();
|
||
testPixelFromPointMapsCornersAndMidpoint();
|
||
testPointFromPixelInvertsAndClamps();
|
||
testPixelMapsRoundTripWithinOnePixelQuantum();
|
||
testPixelFromPointAgreesWithPointAtPixel();
|
||
testPointFromPixelDegenerateBox();
|
||
testFromPointsRoundTripsAValidCurve();
|
||
|
||
if (g_fail == 0) std::printf("velocity_curve: all tests passed\n");
|
||
else std::printf("velocity_curve: %d FAILURES\n", g_fail);
|
||
return g_fail == 0 ? 0 : 1;
|
||
}
|