instrument: one staged-envelope system — per-segment curves, the sustain-less AHD, and a shared overlay for all three envelopes
Trigger's fade pair folds into the AHD (and goes live); the release anchors right; Preserve rings its synthetic tail out instead of cutting it. Payload v10.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// Standalone tests for reasampler::util::curve_law — no VST3, no REAPER, no framework. Same
|
||||
// fast assert loop as the sibling pure tests. This is the ONE law behind the engine's segment
|
||||
// evaluator, the overlay's knot geometry, and the deck's inner dial, so what it guarantees is
|
||||
// what all three inherit.
|
||||
//
|
||||
// Covers: the LINEAR NEUTRAL (exponent 1.0 returns its input BIT-IDENTICALLY, which is what
|
||||
// makes a pre-existing instance play unchanged); endpoint exactness at every exponent (no
|
||||
// segment can overshoot its own endpoint levels); monotonicity and finiteness across the full
|
||||
// 0.1..10 domain including both endpoints; the mid-level inverse the overlay knot drags
|
||||
// through, and its round trip against the exponent.
|
||||
|
||||
#include "../src/core/util/curve_law.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::util;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// The neutral is not merely "close to linear" — it must be the identity, bit for bit, or a
|
||||
// blob that loaded at 1.0 would render differently from the engine that wrote it.
|
||||
static void testNeutralExponentIsTheIdentity() {
|
||||
for (int i = 0; i <= 1000; ++i) {
|
||||
const double phi = static_cast<double>(i) / 1000.0;
|
||||
CHECK(curveMap(phi, kCurveNeutral) == phi);
|
||||
}
|
||||
// Including the values a fractional stage position actually takes.
|
||||
CHECK(curveMap(1.0 / 3.0, 1.0) == 1.0 / 3.0);
|
||||
CHECK(curveMap(0.1234567890123, 1.0) == 0.1234567890123);
|
||||
}
|
||||
|
||||
// Both endpoints are exact at every exponent, which is the whole overshoot guarantee: a curved
|
||||
// stage starts where the previous one ended and ends where the next one starts.
|
||||
static void testEndpointsAreExactAtEveryExponent() {
|
||||
for (int i = 0; i <= 100; ++i) {
|
||||
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 100.0);
|
||||
CHECK(curveMap(0.0, e) == 0.0);
|
||||
CHECK(curveMap(1.0, e) == 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// The full domain, both endpoints included: finite, in range, and strictly rising.
|
||||
static void testSweepIsFiniteMonotoneAndInRange() {
|
||||
const double exps[] = {kCurveMin, 0.25, 0.5, kCurveNeutral, 2.0, 4.0, kCurveMax};
|
||||
for (double e : exps) {
|
||||
double prev = -1.0;
|
||||
for (int i = 0; i <= 500; ++i) {
|
||||
const double phi = static_cast<double>(i) / 500.0;
|
||||
const double v = curveMap(phi, e);
|
||||
CHECK(std::isfinite(v));
|
||||
CHECK(v >= 0.0 && v <= 1.0);
|
||||
CHECK(v > prev - 1e-15); // non-decreasing
|
||||
prev = v;
|
||||
}
|
||||
CHECK(std::fabs(prev - 1.0) < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
// Which side of the neutral an exponent falls on is the SHAPE, and the two directions must not
|
||||
// collapse into each other.
|
||||
static void testExponentDirectionShapesTheSegment() {
|
||||
CHECK(curveMap(0.5, 4.0) < curveMap(0.5, kCurveNeutral));
|
||||
CHECK(curveMap(0.5, 0.25) > curveMap(0.5, kCurveNeutral));
|
||||
CHECK(std::fabs(curveMap(0.5, kCurveNeutral) - 0.5) < 1e-15);
|
||||
}
|
||||
|
||||
static void testClampCurveHoldsTheDomain() {
|
||||
CHECK(clampCurve(-5.0) == kCurveMin);
|
||||
CHECK(clampCurve(0.0) == kCurveMin);
|
||||
CHECK(clampCurve(1e9) == kCurveMax);
|
||||
CHECK(clampCurve(std::nan("")) == kCurveMin); // a corrupt blob degrades, never propagates
|
||||
CHECK(clampCurve(2.5) == 2.5);
|
||||
}
|
||||
|
||||
// The mid-level inverse is what a knot drag resolves through: it must be the exact inverse of
|
||||
// the forward reading over the whole domain, or the knot and the dial could drift.
|
||||
static void testMidLevelRoundTripsAgainstTheExponent() {
|
||||
for (int i = 0; i <= 200; ++i) {
|
||||
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 200.0);
|
||||
const double mid = curveMidLevel(e);
|
||||
CHECK(mid > 0.0 && mid < 1.0);
|
||||
CHECK(std::fabs(curveFromMidLevel(mid) - e) < 1e-9);
|
||||
}
|
||||
// The mid-level is strictly DECREASING in the exponent, so a drag has one unambiguous
|
||||
// direction at every point of the domain.
|
||||
double prev = 1.0;
|
||||
for (int i = 0; i <= 200; ++i) {
|
||||
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 200.0);
|
||||
const double mid = curveMidLevel(e);
|
||||
CHECK(mid < prev);
|
||||
prev = mid;
|
||||
}
|
||||
}
|
||||
|
||||
// A knot dragged past what the domain can express saturates rather than producing a
|
||||
// non-finite exponent.
|
||||
static void testMidLevelInverseSaturates() {
|
||||
CHECK(curveFromMidLevel(0.0) == kCurveMax);
|
||||
CHECK(curveFromMidLevel(-1.0) == kCurveMax);
|
||||
CHECK(curveFromMidLevel(1.0) == kCurveMin);
|
||||
CHECK(curveFromMidLevel(5.0) == kCurveMin);
|
||||
CHECK(curveFromMidLevel(std::nan("")) == kCurveMax);
|
||||
CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testNeutralExponentIsTheIdentity();
|
||||
testEndpointsAreExactAtEveryExponent();
|
||||
testSweepIsFiniteMonotoneAndInRange();
|
||||
testExponentDirectionShapesTheSegment();
|
||||
testClampCurveHoldsTheDomain();
|
||||
testMidLevelRoundTripsAgainstTheExponent();
|
||||
testMidLevelInverseSaturates();
|
||||
if (g_fail == 0) std::printf("curve_law: all tests passed\n");
|
||||
else std::printf("curve_law: %d FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user