PITCH/RATE deck: Rate and Pitch knobs compounded into one read increment, on a three-state commit predicate and payload v16
This commit is contained in:
@@ -24,6 +24,11 @@ static int g_fail = 0;
|
||||
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
|
||||
// Rate's bounds, as the deck passes them in — the stretcher's own measured range. Written as
|
||||
// literals HERE on purpose: this is the module's test, and reading the engine constant would
|
||||
// make the test agree with the taper by construction rather than pin the numbers.
|
||||
static constexpr double kRateMin = 0.5;
|
||||
static constexpr double kRateMax = 2.0;
|
||||
|
||||
// --- modifiers -----------------------------------------------------------------------------
|
||||
|
||||
@@ -227,6 +232,76 @@ static void testDegenerateThrowCollapsesToCentre() {
|
||||
CHECK(depthSemitonesFromNorm(0.9, 0.0) == 0.0);
|
||||
}
|
||||
|
||||
// --- the rate taper -------------------------------------------------------------------------
|
||||
|
||||
// The three landmarks the range is specified by, all EXACT: half rate at norm 0, double at norm
|
||||
// 1, and unity at TRUE knob centre — the last is what a detent has to be, and a map that merely
|
||||
// came close to 1.0 there would persist a hair of transposition on an untouched knob.
|
||||
static void testRateEndpointsAndCentreAreExact() {
|
||||
CHECK(rateRatioFromNorm(0.0, kRateMin, kRateMax) == 0.5);
|
||||
CHECK(rateRatioFromNorm(1.0, kRateMin, kRateMax) == 2.0);
|
||||
CHECK(rateRatioFromNorm(0.5, kRateMin, kRateMax) == 1.0);
|
||||
CHECK(rateNormFromRatio(0.5, kRateMin, kRateMax) == 0.0);
|
||||
CHECK(rateNormFromRatio(2.0, kRateMin, kRateMax) == 1.0);
|
||||
CHECK(rateNormFromRatio(1.0, kRateMin, kRateMax) == 0.5);
|
||||
// Out of domain clamps rather than extrapolating — the map cannot reach a ratio the
|
||||
// engine's own clamp would then have to move.
|
||||
CHECK(rateRatioFromNorm(-1.0, kRateMin, kRateMax) == 0.5);
|
||||
CHECK(rateRatioFromNorm(2.0, kRateMin, kRateMax) == 2.0);
|
||||
CHECK(rateNormFromRatio(0.1, kRateMin, kRateMax) == 0.0);
|
||||
CHECK(rateNormFromRatio(9.0, kRateMin, kRateMax) == 1.0);
|
||||
}
|
||||
|
||||
// The taper's defining property, and the reason it is the exception to centre expansion: equal
|
||||
// travel buys equal SEMITONES, everywhere. Checked as a constant ratio-of-ratios across the
|
||||
// travel rather than at the two ends, which a centre-expanded map would also pass.
|
||||
static void testRateIsLinearInSemitonesAcrossTheWholeTravel() {
|
||||
const double step = 1.0 / 24.0; // 24 equal steps over 24 semitones
|
||||
for (int i = 0; i < 24; ++i) {
|
||||
const double lo = rateRatioFromNorm(static_cast<double>(i) * step, kRateMin, kRateMax);
|
||||
const double hi = rateRatioFromNorm(static_cast<double>(i + 1) * step, kRateMin, kRateMax);
|
||||
CHECK(std::fabs(hi / lo - std::exp2(1.0 / 12.0)) < 1e-12);
|
||||
if (!(std::fabs(hi / lo - std::exp2(1.0 / 12.0)) < 1e-12)) return;
|
||||
}
|
||||
// The named musical landmarks that buys: an octave at each end, a fifth seven steps out.
|
||||
CHECK(std::fabs(rateRatioFromNorm(0.5 + 7.0 / 24.0, kRateMin, kRateMax) -
|
||||
std::exp2(7.0 / 12.0)) < 1e-12);
|
||||
}
|
||||
|
||||
static void testRateIsMonotone() {
|
||||
double prev = -1.0;
|
||||
for (int i = 0; i <= 200000; ++i) {
|
||||
const double v = rateRatioFromNorm(static_cast<double>(i) / 200000.0, kRateMin, kRateMax);
|
||||
CHECK(v >= prev);
|
||||
if (v < prev) return;
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
|
||||
// The preimage obligation this control actually carries: its ONE default, bitwise, because a
|
||||
// host's reset-to-default arrives as toPlain(defaultNorm) with no editor bypass to intercept it.
|
||||
// Both endpoints are exact for the same reason. Everything between round-trips to within an ulp
|
||||
// rather than bitwise — the map carries no output quantum, and the header says why.
|
||||
static void testRateDefaultAndEndpointsRoundTripBitwise() {
|
||||
CHECK(rateRatioFromNorm(rateNormFromRatio(1.0, kRateMin, kRateMax), kRateMin, kRateMax) == 1.0);
|
||||
CHECK(rateRatioFromNorm(rateNormFromRatio(0.5, kRateMin, kRateMax), kRateMin, kRateMax) == 0.5);
|
||||
CHECK(rateRatioFromNorm(rateNormFromRatio(2.0, kRateMin, kRateMax), kRateMin, kRateMax) == 2.0);
|
||||
for (int milli = 500; milli <= 2000; milli += 7) {
|
||||
const double ratio = static_cast<double>(milli) / 1000.0;
|
||||
const double back =
|
||||
rateRatioFromNorm(rateNormFromRatio(ratio, kRateMin, kRateMax), kRateMin, kRateMax);
|
||||
CHECK(std::fabs(back - ratio) < 1e-14 * ratio);
|
||||
if (!(std::fabs(back - ratio) < 1e-14 * ratio)) return;
|
||||
}
|
||||
}
|
||||
|
||||
// Degenerate bounds are a caller bug, not a crash: the map collapses to unity.
|
||||
static void testDegenerateRateBoundsCollapseToUnity() {
|
||||
CHECK(rateRatioFromNorm(0.3, 2.0, 0.5) == 1.0);
|
||||
CHECK(rateNormFromRatio(0.9, 2.0, 0.5) == 0.5);
|
||||
CHECK(rateRatioFromNorm(0.3, 0.0, 2.0) == 1.0);
|
||||
}
|
||||
|
||||
// --- the whole-unit snaps -------------------------------------------------------------------
|
||||
|
||||
static void testMillisecondSnap() {
|
||||
@@ -255,6 +330,36 @@ static void testSemitoneSnap() {
|
||||
kDepth) == 7.0);
|
||||
}
|
||||
|
||||
// Rate's unit is the semitone though it displays as a percent, so Shift lands on the 25 steps
|
||||
// between the bounds — which is what puts an octave and a fifth under the hand. The detent and
|
||||
// both ends are reached EXACTLY, so a snap cannot leave the knob a hair off its own endpoint.
|
||||
static void testRateSemitoneSnap() {
|
||||
CHECK(snapRateRatioToWholeSemitone(1.0) == 1.0);
|
||||
CHECK(snapRateRatioToWholeSemitone(0.5) == 0.5);
|
||||
CHECK(snapRateRatioToWholeSemitone(2.0) == 2.0);
|
||||
CHECK(std::fabs(snapRateRatioToWholeSemitone(1.5) - std::exp2(7.0 / 12.0)) < 1e-15);
|
||||
// Just off a step in each direction resolves back onto it.
|
||||
CHECK(std::fabs(snapRateRatioToWholeSemitone(std::exp2(7.0 / 12.0) * 1.005) -
|
||||
std::exp2(7.0 / 12.0)) < 1e-15);
|
||||
CHECK(std::fabs(snapRateRatioToWholeSemitone(std::exp2(7.0 / 12.0) * 0.995) -
|
||||
std::exp2(7.0 / 12.0)) < 1e-15);
|
||||
// Within a quarter-semitone of unity snaps to unity, not to a neighbouring step.
|
||||
CHECK(snapRateRatioToWholeSemitone(std::exp2(0.25 / 12.0)) == 1.0);
|
||||
CHECK(snapRateRatioToWholeSemitone(0.0) == 1.0); // unusable input parks at unity
|
||||
CHECK(snapRateRatioToWholeSemitone(-1.0) == 1.0);
|
||||
// What the knob actually stores after a Shift-drag is the snapped norm mapped back through
|
||||
// the taper — so the property that matters is that THAT value is still a whole semitone.
|
||||
// Measured in semitones, which is the unit the criterion is stated in.
|
||||
for (int st = -12; st <= 12; ++st) {
|
||||
const double norm =
|
||||
rateNormFromRatio(std::exp2(static_cast<double>(st) / 12.0), kRateMin, kRateMax);
|
||||
const double stored = rateRatioFromNorm(norm, kRateMin, kRateMax);
|
||||
const double semis = 12.0 * std::log2(stored);
|
||||
CHECK(std::fabs(semis - static_cast<double>(st)) < 1e-9);
|
||||
if (!(std::fabs(semis - static_cast<double>(st)) < 1e-9)) return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -285,9 +390,16 @@ int main() {
|
||||
testEveryWholeSemitoneRoundTripsExactly();
|
||||
testDegenerateThrowCollapsesToCentre();
|
||||
|
||||
testRateEndpointsAndCentreAreExact();
|
||||
testRateIsLinearInSemitonesAcrossTheWholeTravel();
|
||||
testRateIsMonotone();
|
||||
testRateDefaultAndEndpointsRoundTripBitwise();
|
||||
testDegenerateRateBoundsCollapseToUnity();
|
||||
|
||||
testMillisecondSnap();
|
||||
testPercentSnap();
|
||||
testSemitoneSnap();
|
||||
testRateSemitoneSnap();
|
||||
testExponentSnap();
|
||||
|
||||
if (g_fail == 0) std::printf("param_taper: all tests passed\n");
|
||||
|
||||
Reference in New Issue
Block a user