261 lines
12 KiB
C++
261 lines
12 KiB
C++
// Standalone tests for reasampler::instrument::ui::param_taper — no VST3, no REAPER, no
|
|
// framework. The taper is the one map the knob's needle, the AHDSR schematic axis and (later) the
|
|
// host's normalization all read, so what is asserted here is what all three obey.
|
|
//
|
|
// Covers: the modifier truth table (Shift beats Ctrl); the stage-time taper (exact endpoints,
|
|
// monotone, the two landmark bands, and the EXACT-PREIMAGE guarantee swept over the whole
|
|
// quantum grid rather than sampled at the defaults); the depth taper (exact centre and ends,
|
|
// exact symmetry, the +/-7 st landmark, whole-semitone preimages); and the four whole-unit snaps.
|
|
|
|
#include "../src/core/instrument/ui/param_taper.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
|
|
using namespace reasampler;
|
|
using namespace reasampler::instrument::ui;
|
|
|
|
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 constexpr double kDepth = 24.0; // the pitch-depth throw the deck passes in today
|
|
|
|
// --- modifiers -----------------------------------------------------------------------------
|
|
|
|
// Shift+Ctrl is SHIFT: with the output quantized to whole units a finer drag produces the same
|
|
// sequence, so Ctrl is ignored there. Asserted rather than left to a comment because the
|
|
// "obvious fix" is to compound the two.
|
|
static void testShiftBeatsCtrlForTheFineDragRate() {
|
|
CHECK(!fineDrag(DragModifiers{false, false}));
|
|
CHECK(fineDrag(DragModifiers{false, true}));
|
|
CHECK(!fineDrag(DragModifiers{true, false}));
|
|
CHECK(!fineDrag(DragModifiers{true, true}));
|
|
CHECK((DragModifiers{true, false} != DragModifiers{false, false}));
|
|
CHECK((DragModifiers{true, true} == DragModifiers{true, true}));
|
|
}
|
|
|
|
// --- the stage-time taper ------------------------------------------------------------------
|
|
|
|
// Zero is a REQUIRED value a pure log cannot express, and the ceiling has to be reachable by
|
|
// hand — both endpoints are exact, not merely close.
|
|
static void testStageTimeEndpointsAreExact() {
|
|
CHECK(timeSecondsFromNorm(0.0) == 0.0);
|
|
CHECK(timeSecondsFromNorm(1.0) == kStageTimeMaxSeconds);
|
|
CHECK(timeNormFromSeconds(0.0) == 0.0);
|
|
CHECK(timeNormFromSeconds(kStageTimeMaxSeconds) == 1.0);
|
|
// Out of domain clamps rather than extrapolating.
|
|
CHECK(timeSecondsFromNorm(-1.0) == 0.0);
|
|
CHECK(timeSecondsFromNorm(2.0) == kStageTimeMaxSeconds);
|
|
CHECK(timeNormFromSeconds(-1.0) == 0.0);
|
|
CHECK(timeNormFromSeconds(1e9) == 1.0);
|
|
}
|
|
|
|
// The ceiling this phase raised it to. Pinned as a literal: this endpoint becomes a frozen host
|
|
// normalization, so a silent change to it is exactly what a test has to refuse.
|
|
static void testStageTimeCeilingIsTenSeconds() {
|
|
CHECK(kStageTimeMaxSeconds == 10.0);
|
|
}
|
|
|
|
// The two landmarks the taper is fitted to, at the NEW ceiling. They are what make the low end
|
|
// dialable at a 10 s range, and they are also the overlay's legibility guarantee.
|
|
static void testStageTimeLandmarksLandInTheirBands() {
|
|
const double at10ms = timeNormFromSeconds(0.010);
|
|
const double at100ms = timeNormFromSeconds(0.100);
|
|
CHECK(at10ms >= 0.12 && at10ms <= 0.20);
|
|
CHECK(at100ms >= 0.42 && at100ms <= 0.52);
|
|
// And the two are ordered with real separation, not merely inside their bands.
|
|
CHECK(at100ms > at10ms + 0.2);
|
|
}
|
|
|
|
static void testStageTimeIsMonotone() {
|
|
double prev = -1.0;
|
|
for (int i = 0; i <= 200000; ++i) {
|
|
const double v = timeSecondsFromNorm(static_cast<double>(i) / 200000.0);
|
|
CHECK(v >= prev);
|
|
if (v < prev) return; // one report is enough
|
|
prev = v;
|
|
}
|
|
}
|
|
|
|
// The FINEST drag a user can make — Ctrl's 1/20 rate over the 128 px knob travel — must still
|
|
// move the value, or the output quantum would be observable as a dead zone.
|
|
static void testEveryFinestDragStepMovesTheValue() {
|
|
const int steps = static_cast<int>(1.0 / kFineDragScale) * 128;
|
|
for (int i = 0; i < steps; ++i) {
|
|
const double lo = timeSecondsFromNorm(static_cast<double>(i) / steps);
|
|
const double hi = timeSecondsFromNorm(static_cast<double>(i + 1) / steps);
|
|
CHECK(hi > lo);
|
|
if (!(hi > lo)) return;
|
|
}
|
|
}
|
|
|
|
// THE sharpest requirement in the track. A host's reset-to-default arrives as
|
|
// toPlain(defaultNorm) with no bypass available, so the preimage has to be EXACT. Swept over the
|
|
// whole quantum grid at the resolution the defaults live at, not sampled at the two the parameter
|
|
// set happens to carry today — that is what makes the guarantee structural.
|
|
static void testEveryWholeMicrosecondRoundTripsExactly() {
|
|
for (int us = 0; us <= 200000; us += 7) { // 0 .. 200 ms, a prime stride to avoid alignment
|
|
const double seconds = static_cast<double>(us) / 1e6;
|
|
CHECK(timeSecondsFromNorm(timeNormFromSeconds(seconds)) == seconds);
|
|
if (timeSecondsFromNorm(timeNormFromSeconds(seconds)) != seconds) return;
|
|
}
|
|
// And across the rest of the range, where the map is coarsest.
|
|
for (int ms = 200; ms <= 10000; ms += 13) {
|
|
const double seconds = static_cast<double>(ms) / 1e3;
|
|
CHECK(timeSecondsFromNorm(timeNormFromSeconds(seconds)) == seconds);
|
|
if (timeSecondsFromNorm(timeNormFromSeconds(seconds)) != seconds) return;
|
|
}
|
|
}
|
|
|
|
// The converse round trip is NOT required, but its residual is worth pinning: it is bounded by
|
|
// the output quantum read back through the map, which stays four orders below one drag pixel.
|
|
// Pinned so a future quantum change cannot make the needle visibly lag the hand unnoticed.
|
|
static void testNormRoundTripResidualStaysBelowOneDragPixel() {
|
|
for (int i = 0; i <= 100000; ++i) {
|
|
const double n = static_cast<double>(i) / 100000.0;
|
|
const double back = timeNormFromSeconds(timeSecondsFromNorm(n));
|
|
CHECK(std::fabs(back - n) < 1e-7);
|
|
if (!(std::fabs(back - n) < 1e-7)) return;
|
|
}
|
|
}
|
|
|
|
// The two stage-time defaults the parameter set actually carries, named so a reader can see the
|
|
// values the sweep above covers generically.
|
|
static void testTheStageTimeDefaultsRoundTripExactly() {
|
|
CHECK(timeSecondsFromNorm(timeNormFromSeconds(0.003)) == 0.003);
|
|
CHECK(timeSecondsFromNorm(timeNormFromSeconds(0.060)) == 0.060);
|
|
CHECK(timeSecondsFromNorm(timeNormFromSeconds(0.0)) == 0.0);
|
|
}
|
|
|
|
// --- the depth taper -----------------------------------------------------------------------
|
|
|
|
static void testDepthCentreAndEndsAreExact() {
|
|
CHECK(depthNormFromSemitones(0.0, kDepth) == 0.5);
|
|
CHECK(depthSemitonesFromNorm(0.5, kDepth) == 0.0);
|
|
CHECK(depthNormFromSemitones(kDepth, kDepth) == 1.0);
|
|
CHECK(depthNormFromSemitones(-kDepth, kDepth) == 0.0);
|
|
CHECK(depthSemitonesFromNorm(1.0, kDepth) == kDepth);
|
|
CHECK(depthSemitonesFromNorm(0.0, kDepth) == -kDepth);
|
|
// Beyond the throw clamps rather than extrapolating.
|
|
CHECK(depthNormFromSemitones(100.0, kDepth) == 1.0);
|
|
CHECK(depthSemitonesFromNorm(5.0, kDepth) == kDepth);
|
|
}
|
|
|
|
// Symmetric BITWISE, not approximately: a bipolar knob whose two halves disagreed by an ulp
|
|
// would read a different depth up than down at the same distance from centre.
|
|
static void testDepthIsExactlySymmetric() {
|
|
for (int i = 0; i <= 1000; ++i) {
|
|
const double n = static_cast<double>(i) / 1000.0;
|
|
CHECK(depthSemitonesFromNorm(n, kDepth) == -depthSemitonesFromNorm(1.0 - n, kDepth));
|
|
if (depthSemitonesFromNorm(n, kDepth) != -depthSemitonesFromNorm(1.0 - n, kDepth)) return;
|
|
}
|
|
}
|
|
|
|
// Centre expansion: the musically useful +/-7 st gets more than half of each half-travel.
|
|
static void testDepthLandmarkLandsInItsBand() {
|
|
const double halfTravel = (depthNormFromSemitones(7.0, kDepth) - 0.5) * 2.0;
|
|
CHECK(halfTravel >= 0.50 && halfTravel <= 0.58);
|
|
// The negative half is the same distance out. Compared with a tolerance, not bitwise: 0.5+h
|
|
// and 0.5-h round differently, and the mirror that has to be EXACT is the one in the plain
|
|
// direction (testDepthIsExactlySymmetric) — a sub-ulp difference in a needle angle is not.
|
|
CHECK(std::fabs((0.5 - depthNormFromSemitones(-7.0, kDepth)) * 2.0 - halfTravel) < 1e-15);
|
|
}
|
|
|
|
static void testDepthIsMonotone() {
|
|
double prev = -1e9;
|
|
for (int i = 0; i <= 200000; ++i) {
|
|
const double v = depthSemitonesFromNorm(static_cast<double>(i) / 200000.0, kDepth);
|
|
CHECK(v >= prev);
|
|
if (v < prev) return;
|
|
prev = v;
|
|
}
|
|
}
|
|
|
|
// Same exact-preimage guarantee as the time taper: every value on the depth quantum grid comes
|
|
// back bitwise. Whole semitones are the case a Shift-snap produces, so they are swept explicitly.
|
|
static void testEveryWholeSemitoneRoundTripsExactly() {
|
|
for (int st = -24; st <= 24; ++st) {
|
|
const double d = static_cast<double>(st);
|
|
CHECK(depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) == d);
|
|
}
|
|
for (int milli = -24000; milli <= 24000; milli += 37) {
|
|
const double d = static_cast<double>(milli) / 1000.0;
|
|
CHECK(depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) == d);
|
|
if (depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) != d) return;
|
|
}
|
|
}
|
|
|
|
// A degenerate throw is a caller bug, not a crash: the map collapses to the centre.
|
|
static void testDegenerateThrowCollapsesToCentre() {
|
|
CHECK(depthNormFromSemitones(3.0, 0.0) == 0.5);
|
|
CHECK(depthSemitonesFromNorm(0.9, 0.0) == 0.0);
|
|
}
|
|
|
|
// --- the whole-unit snaps -------------------------------------------------------------------
|
|
|
|
static void testMillisecondSnap() {
|
|
CHECK(snapSecondsToWholeMs(0.0124) == 0.012);
|
|
CHECK(snapSecondsToWholeMs(0.0126) == 0.013);
|
|
CHECK(snapSecondsToWholeMs(0.0004) == 0.0);
|
|
CHECK(snapSecondsToWholeMs(-1.0) == 0.0);
|
|
CHECK(snapSecondsToWholeMs(9.9996) == 10.0);
|
|
// The snapped value is itself on the taper's grid, so a snap followed by a round trip holds.
|
|
CHECK(timeSecondsFromNorm(timeNormFromSeconds(snapSecondsToWholeMs(0.0347))) == 0.035);
|
|
}
|
|
|
|
static void testPercentSnap() {
|
|
CHECK(snapFractionToWholePercent(0.514) == 0.51);
|
|
CHECK(snapFractionToWholePercent(0.516) == 0.52);
|
|
CHECK(snapFractionToWholePercent(-0.514) == -0.51);
|
|
CHECK(snapFractionToWholePercent(1.0) == 1.0);
|
|
CHECK(snapFractionToWholePercent(0.0) == 0.0);
|
|
}
|
|
|
|
static void testSemitoneSnap() {
|
|
CHECK(snapSemitonesToWhole(6.6) == 7.0);
|
|
CHECK(snapSemitonesToWhole(-6.6) == -7.0);
|
|
CHECK(snapSemitonesToWhole(0.4) == 0.0);
|
|
CHECK(depthSemitonesFromNorm(depthNormFromSemitones(snapSemitonesToWhole(6.6), kDepth),
|
|
kDepth) == 7.0);
|
|
}
|
|
|
|
// The exponent snap reaches 1.0, the linear neutral — one snap from the dial's centre — and
|
|
// clamps into curve_law's own domain rather than rounding to a zero that is not an exponent.
|
|
static void testExponentSnap() {
|
|
CHECK(snapExponentToWhole(1.4) == 1.0);
|
|
CHECK(snapExponentToWhole(2.6) == 3.0);
|
|
CHECK(snapExponentToWhole(0.3) == util::kCurveMin);
|
|
CHECK(snapExponentToWhole(0.6) == 1.0);
|
|
CHECK(snapExponentToWhole(1e9) == util::kCurveMax);
|
|
}
|
|
|
|
int main() {
|
|
testShiftBeatsCtrlForTheFineDragRate();
|
|
|
|
testStageTimeEndpointsAreExact();
|
|
testStageTimeCeilingIsTenSeconds();
|
|
testStageTimeLandmarksLandInTheirBands();
|
|
testStageTimeIsMonotone();
|
|
testEveryFinestDragStepMovesTheValue();
|
|
testEveryWholeMicrosecondRoundTripsExactly();
|
|
testNormRoundTripResidualStaysBelowOneDragPixel();
|
|
testTheStageTimeDefaultsRoundTripExactly();
|
|
|
|
testDepthCentreAndEndsAreExact();
|
|
testDepthIsExactlySymmetric();
|
|
testDepthLandmarkLandsInItsBand();
|
|
testDepthIsMonotone();
|
|
testEveryWholeSemitoneRoundTripsExactly();
|
|
testDegenerateThrowCollapsesToCentre();
|
|
|
|
testMillisecondSnap();
|
|
testPercentSnap();
|
|
testSemitoneSnap();
|
|
testExponentSnap();
|
|
|
|
if (g_fail == 0) std::printf("param_taper: all tests passed\n");
|
|
else std::printf("param_taper: %d FAILED\n", g_fail);
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|