Files
reasampler/tests/test_velocity_curve.cpp
T

381 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (FritschCarlson) --------------------
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. FritschCarlson 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;
}