// 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 velocity transfer curve HARD: // // * 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, knots inside [0,1] evaluate identically in either domain (what the codec's v12 // re-tag rests on), and the pixel maps put value 0 on the box's centre line, not its floor. // * editing — addPoint keeps X-order + box-clamp; movePoint clamps an interior point between its // 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 each domain's OWN neutral for a sub-2-point list. #include "../src/core/instrument/engine/velocity_curve.h" #include #include #include 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; // 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() { 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].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.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); } // --- 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.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)); } 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.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.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].value, 0.0) && near(c.points()[1].value, 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].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].value, 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 raw = {{80.0, 0.9}, {20.0, -1.0}, {50.0, 2.0}}; 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); // 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.value >= 0.0 - 1e-12 && p.value <= 1.0 + 1e-12); } } static void testFromPointsSubTwoFallsBackToFlat() { const VelocityCurve c0 = VelocityCurve::fromPoints({}, CurveDomain::Unipolar); CHECK(c0.equals(VelocityCurve::flat())); 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 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 testTheSameKnotsEvaluateIdenticallyInEitherDomain() { // What the codec's v12 domain re-tag rests on: a curve whose y values all lie in [0,1] is // read the same way in either domain — the domain governs the CLAMP, not the evaluation. // Asserted against a CURVED (non-collinear) knot set, where the tangents are doing work, // and with ==: the re-tag is bit-identical, not merely close. const std::vector knots = { {0.0, 0.1}, {30.0, 0.15}, {64.0, 0.9}, {100.0, 0.4}, {127.0, 1.0}}; const VelocityCurve u = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar); const VelocityCurve b = VelocityCurve::fromPoints(knots, CurveDomain::Bipolar); for (int v = 0; v <= 127; ++v) CHECK(b.eval(v) == u.eval(v)); } 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) ----------------------------- 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 = uni().pixelFromPoint(box, {0.0, 1.0}); CHECK(tl.x == 10 && tl.y == 20); const auto br = uni().pixelFromPoint(box, {127.0, 0.0}); CHECK(br.x == 110 && br.y == 70); 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 = 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 = 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 = 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() { // 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 = 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.value - p.value) <= 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 = uni().pixelFromPoint(box, c.points()[idx]); CHECK(c.pointAtPixel(box, px.x, px.y) == static_cast(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 = uni().pointFromPixel(flat, 50, 50); CHECK(near(p.velocity, 0.0) && near(p.value, 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(), CurveDomain::Unipolar); CHECK(rebuilt.equals(orig)); } int main() { testFlatIsUnityEverywhere(); testLinearRamp(); testEvalBoxClampsOutOfRangeVelocity(); testEvalMonotonicInX(); testCollinearControlPointsReproduceExactLinearRamp(); testNoOvershootWithSharpInteriorDip(); testSplineStaysSingleValuedMonotoneInEachSegment(); testSplinePinsEndpointsExactly(); testAddPointKeepsXOrderAndClamps(); testMoveInteriorClampsToNeighbours(); testMoveEndpointsArePinnedInX(); testMoveOutOfRangeIndexIsNoOp(); testDeleteRemovesInteriorRefusesEndpoints(); testPointAtPixelGrabsDrawnNode(); testResolveDragMovesAndClamps(); testResolveDragDegenerateBoxNoMotion(); testFromPointsSortsClampsAndForcesEndpoints(); testFromPointsSubTwoFallsBackToFlat(); testZeroIsExactlyZeroAtEveryVelocity(); testBipolarEvalSpansTheNegativeHalf(); testUnipolarClampsAtZeroWhereBipolarDoesNot(); testTheSameKnotsEvaluateIdenticallyInEitherDomain(); testBipolarPixelMapPutsZeroOnTheCentreLine(); testBipolarDragCoversTwiceTheValueRange(); 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; }