Merge Γ-W1-T1: one taper, one modifier law, the 10 s stage ceiling
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <initializer_list>
|
||||
|
||||
using namespace reasampler::util;
|
||||
|
||||
@@ -107,6 +108,39 @@ static void testMidLevelInverseSaturates() {
|
||||
CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12);
|
||||
}
|
||||
|
||||
// curveLevelAt/curveFromLevelAt is the general form a knot's own (possibly off-centre) phi
|
||||
// needs — curveMidLevel/curveFromMidLevel is the phi = 0.5 case, not a second law.
|
||||
static void testMidLevelIsThePhiHalfSpecialCase() {
|
||||
for (double e : {kCurveMin, 0.3, kCurveNeutral, 2.0, kCurveMax}) {
|
||||
CHECK(curveLevelAt(0.5, e) == curveMidLevel(e));
|
||||
}
|
||||
for (double u : {0.0, 0.2, 0.5, 0.8, 1.0}) {
|
||||
CHECK(curveFromLevelAt(0.5, u) == curveFromMidLevel(u));
|
||||
}
|
||||
}
|
||||
|
||||
// The round trip must hold at an arbitrary phi, not only 0.5 — this is what a knot whose
|
||||
// integer x lands off its segment's true midpoint (an odd pixel span) actually exercises.
|
||||
static void testLevelAtRoundTripsAtArbitraryPhi() {
|
||||
for (double phi : {0.1, 0.3, 0.42, 0.5, 0.63, 0.9}) {
|
||||
for (int i = 0; i <= 50; ++i) {
|
||||
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 50.0);
|
||||
const double level = curveLevelAt(phi, e);
|
||||
CHECK(level > 0.0 && level < 1.0);
|
||||
CHECK(std::fabs(curveFromLevelAt(phi, level) - e) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Saturation holds at an arbitrary phi too, not only the mid-level special case.
|
||||
static void testLevelAtInverseSaturatesAtArbitraryPhi() {
|
||||
for (double phi : {0.2, 0.5, 0.8}) {
|
||||
CHECK(curveFromLevelAt(phi, 0.0) == kCurveMax);
|
||||
CHECK(curveFromLevelAt(phi, 1.0) == kCurveMin);
|
||||
CHECK(curveFromLevelAt(phi, std::nan("")) == kCurveMax);
|
||||
}
|
||||
}
|
||||
|
||||
// --- The inner dial's travel ---------------------------------------------------
|
||||
|
||||
// The knob drag delivers `start - dy/kKnobDragRangePixels`. param_slider owns that constant and
|
||||
@@ -184,6 +218,9 @@ int main() {
|
||||
testClampCurveHoldsTheDomain();
|
||||
testMidLevelRoundTripsAgainstTheExponent();
|
||||
testMidLevelInverseSaturates();
|
||||
testMidLevelIsThePhiHalfSpecialCase();
|
||||
testLevelAtRoundTripsAtArbitraryPhi();
|
||||
testLevelAtInverseSaturatesAtArbitraryPhi();
|
||||
testKnobLawIsExactAtTheNeutralCentre();
|
||||
testADialSweptThroughNeutralLandsOnTheIdentity();
|
||||
testKnobLawRoundTripsOutsideTheDetent();
|
||||
|
||||
+178
-7
@@ -5,6 +5,9 @@
|
||||
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include "../src/core/instrument/engine/master_gain.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
@@ -22,13 +25,31 @@ static std::string msLabel(double seconds) {
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Every domain the binding maps: a stage time over the seconds ceiling, a level, a fraction,
|
||||
// The stage-time ceiling has TWO names — the overlay's schematic domain and the knob's — and they
|
||||
// must be the same number or a maxed knob stops landing on the canvas edge. Asserted, not assumed.
|
||||
static void testTheTwoCeilingNamesAreOneNumber() {
|
||||
CHECK(kEnvTimeMaxSeconds == kGateStageMaxSeconds);
|
||||
CHECK(kEnvTimeMaxSeconds == kStageTimeMaxSeconds);
|
||||
CHECK(kEnvTimeMaxSeconds == 10.0);
|
||||
}
|
||||
|
||||
// Every domain the binding maps: a stage time through the shared taper, a level, a fraction,
|
||||
// a normalized filter position, a bipolar depth, and a curve exponent over its log travel.
|
||||
static void testNormRoundTripsThroughEveryValueDomain() {
|
||||
PlaySeconds p;
|
||||
setDeckParam(DeckParam::kAttack, p, 0.25, 0);
|
||||
CHECK(p.adsr.attackSeconds == 0.25 * kEnvTimeMaxSeconds);
|
||||
CHECK(deckParamNorm(DeckParam::kAttack, p) == 0.25);
|
||||
CHECK(p.adsr.attackSeconds == timeSecondsFromNorm(0.25));
|
||||
// The VALUE round trip is what has to be exact (param_taper.h); the needle returning to the
|
||||
// very same norm double is explicitly NOT required of a log map. The residual is bounded by
|
||||
// the taper's output quantum read back through the map — under 1e-7 of the travel across the
|
||||
// whole domain, which is four orders below one drag pixel.
|
||||
CHECK(std::fabs(deckParamNorm(DeckParam::kAttack, p) - 0.25) < 1e-7);
|
||||
// The raised ceiling costs the low end nothing: a several-second stage is reachable by hand,
|
||||
// AND everything under 100 ms still gets more than 40 % of the knob's travel to itself.
|
||||
setDeckParam(DeckParam::kDecay, p, 0.95, 0);
|
||||
CHECK(p.adsr.decaySeconds > 5.0 && p.adsr.decaySeconds < kEnvTimeMaxSeconds);
|
||||
setDeckParam(DeckParam::kDecay, p, 0.42, 0);
|
||||
CHECK(p.adsr.decaySeconds < 0.100);
|
||||
|
||||
setDeckParam(DeckParam::kSustain, p, 0.4, 0);
|
||||
CHECK(p.adsr.sustainLevel == 0.4);
|
||||
@@ -122,9 +143,8 @@ static void testInnerResetLandsOnTheExactLinearNeutral() {
|
||||
}
|
||||
|
||||
// A reset lands on the field's own stored default, EXACTLY — the defaults are read off a fresh
|
||||
// PlaySeconds and arrive through the norm round trip, so the two stage times whose defaults are
|
||||
// neither 0 nor 1 are the cases that actually exercise that exactness (see resetDeckParam's
|
||||
// note on what the seconds ceiling has to be for it to hold).
|
||||
// PlaySeconds and COPIED rather than round-tripped, which is what makes the two stage times whose
|
||||
// defaults are neither 0 nor 1 land bit for bit at a non-power-of-two ceiling.
|
||||
static void testResetLandsOnTheStoredDefaultOfEachControl() {
|
||||
const PlaySeconds defaults;
|
||||
PlaySeconds p;
|
||||
@@ -152,6 +172,151 @@ static void testResetLandsOnTheStoredDefaultOfEachControl() {
|
||||
CHECK(p.adsr.releaseSeconds == defaults.adsr.releaseSeconds);
|
||||
}
|
||||
|
||||
// EVERY knob resets to its own stored default, not just the six dual-ring pairs above. Swept
|
||||
// over the whole control-id space so a control added later cannot quietly miss the reset table:
|
||||
// perturb, reset, and require the control to read exactly what a fresh PlaySeconds reads.
|
||||
// Compared against the STORED FIELD directly (deckDoubleField/deckFloatField), not the
|
||||
// normalized read-back: deckParamNorm is not guaranteed injective, so a norm match is weaker
|
||||
// than the criterion — verification against a default-constructed PlaySeconds.
|
||||
static void testEveryKnobIdResetsToItsDefault() {
|
||||
PlaySeconds defaults;
|
||||
for (int i = 0; i < static_cast<int>(DeckParam::kCount); ++i) {
|
||||
const DeckParam id = static_cast<DeckParam>(i);
|
||||
if (deckParamUnit(id) == UnitCategory::None) continue; // no reset gesture
|
||||
if (id == DeckParam::kMasterGain || id == DeckParam::kKeyTrack) continue; // not in PlaySeconds
|
||||
PlaySeconds p;
|
||||
setDeckParam(id, p, 0.37, 0);
|
||||
setDeckParam(id, p, 0.83, 0); // two writes: one of the two is off every default
|
||||
if (double* pd = deckDoubleField(id, p)) {
|
||||
CHECK(*pd != *deckDoubleField(id, defaults));
|
||||
resetDeckParam(id, p);
|
||||
CHECK(*pd == *deckDoubleField(id, defaults));
|
||||
} else if (float* pf = deckFloatField(id, p)) {
|
||||
CHECK(*pf != *deckFloatField(id, defaults));
|
||||
resetDeckParam(id, p);
|
||||
CHECK(*pf == *deckFloatField(id, defaults));
|
||||
} else {
|
||||
CHECK(false); // every non-None, non-excluded id must own a reset field
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE exact-preimage criterion, per unit category, against a default-constructed PlaySeconds and
|
||||
// against master gain's unity. A host's reset-to-default arrives as toPlain(defaultNorm) with no
|
||||
// bypass available, so this is the assertion the reset bypass CANNOT stand in for.
|
||||
static void testEveryDefaultHasAnExactNormalizedPreimage() {
|
||||
const PlaySeconds d;
|
||||
const struct { DeckParam id; double stored; } msKnobs[] = {
|
||||
{DeckParam::kAttack, d.adsr.attackSeconds},
|
||||
{DeckParam::kHold, d.adsr.holdSeconds},
|
||||
{DeckParam::kDecay, d.adsr.decaySeconds},
|
||||
{DeckParam::kRelease, d.adsr.releaseSeconds},
|
||||
{DeckParam::kTrigAttack, d.trigAhd.attackSeconds},
|
||||
{DeckParam::kTrigDecay, d.trigAhd.decaySeconds},
|
||||
{DeckParam::kPitchEnvAttack, d.pitchEnv.shape.attackSeconds},
|
||||
{DeckParam::kPitchEnvDecay, d.pitchEnv.shape.decaySeconds},
|
||||
{DeckParam::kFilterEnvAttack, d.filter.env.attackSeconds},
|
||||
{DeckParam::kFilterEnvHold, d.filter.env.holdSeconds},
|
||||
{DeckParam::kFilterEnvDecay, d.filter.env.decaySeconds},
|
||||
{DeckParam::kFilterEnvRelease, d.filter.env.releaseSeconds},
|
||||
{DeckParam::kFilterTrigAttack, d.filter.trigEnv.attackSeconds},
|
||||
{DeckParam::kFilterTrigDecay, d.filter.trigEnv.decaySeconds},
|
||||
};
|
||||
for (const auto& k : msKnobs) {
|
||||
CHECK(timeSecondsFromNorm(deckParamNorm(k.id, d)) == k.stored);
|
||||
}
|
||||
// The two whose defaults are neither 0 nor the ceiling are the ones that can actually fail.
|
||||
CHECK(d.adsr.attackSeconds == 0.003 && d.adsr.releaseSeconds == 0.060);
|
||||
|
||||
CHECK(depthSemitonesFromNorm(deckParamNorm(DeckParam::kPitchEnvDepth, d),
|
||||
kPitchDepthMaxSemis) == d.pitchEnv.peakSemitones);
|
||||
CHECK(deckParamNorm(DeckParam::kSustain, d) == d.adsr.sustainLevel);
|
||||
CHECK(deckParamNorm(DeckParam::kTrigLength, d) == d.trigger.lengthFraction);
|
||||
CHECK(deckParamNorm(DeckParam::kTrigHold, d) == d.trigAhd.holdFraction);
|
||||
CHECK(deckBipolarFromNorm(deckParamNorm(DeckParam::kFilterModAmt, d)) == d.filter.modAmount);
|
||||
CHECK(util::curveFromKnobNorm(deckParamNorm(DeckParam::kAttackCurve, d)) ==
|
||||
d.adsr.attackCurve);
|
||||
// Master gain's unity: the case where a hair off is an audible gain error rather than a
|
||||
// cosmetic one. Its taper is engine/master_gain's — consumed here, not defined here.
|
||||
CHECK(instrument::engine::masterGainLinearFromNorm(instrument::engine::masterGainNormFromLinear(1.0)) == 1.0);
|
||||
}
|
||||
|
||||
// Shift's snap unit is a property of the control's UNIT and lands on a whole unit of what the
|
||||
// control DISPLAYS — which is why three controls sharing the Percent category take three
|
||||
// different norm steps.
|
||||
static void testShiftSnapsToAWholeUnitOfTheDisplayedValue() {
|
||||
// Milliseconds: the snapped norm reads back as an exact whole millisecond.
|
||||
const double ms = timeSecondsFromNorm(snapDeckParamNorm(DeckParam::kAttack,
|
||||
timeNormFromSeconds(0.03472)));
|
||||
CHECK(ms == 0.035);
|
||||
// Semitones.
|
||||
CHECK(depthSemitonesFromNorm(
|
||||
snapDeckParamNorm(DeckParam::kPitchEnvDepth,
|
||||
depthNormFromSemitones(6.6, kPitchDepthMaxSemis)),
|
||||
kPitchDepthMaxSemis) == 7.0);
|
||||
// Percent, 0..100 %: the norm IS the fraction.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kSustain, 0.4162) == 0.42);
|
||||
// Percent, 0..200 %: a whole DISPLAYED percent is half a norm percent.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kFilterKeyTrack, 0.4162) == 0.4150);
|
||||
// Percent, +/-100 %: likewise, measured on the bipolar value.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kFilterVel, deckNormFromBipolar(-0.4162)) ==
|
||||
deckNormFromBipolar(-0.42));
|
||||
// Exponent: whole numbers, which puts the linear neutral one snap from centre. Compared as
|
||||
// the norm the snap RETURNS — the exponent's own log travel is not an exact round trip.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kAttackCurve, util::knobNormFromCurve(2.6)) ==
|
||||
util::knobNormFromCurve(3.0));
|
||||
CHECK(snapDeckParamNorm(DeckParam::kAttackCurve, util::knobNormFromCurve(1.4)) ==
|
||||
util::knobNormFromCurve(util::kCurveNeutral));
|
||||
// Decibels, likewise compared as the returned norm.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kMasterGain,
|
||||
instrument::engine::masterGainNormFromDb(-6.4)) ==
|
||||
instrument::engine::masterGainNormFromDb(-6.0));
|
||||
// Already-integer and discrete controls are untouched.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kVoiceCount, 0.4162) == 0.4162);
|
||||
CHECK(snapDeckParamNorm(DeckParam::kPlayMode, 0.4162) == 0.4162);
|
||||
CHECK(deckParamUnit(DeckParam::kVoiceCount) == UnitCategory::None);
|
||||
CHECK(deckParamUnit(DeckParam::kAmpVelCurve) == UnitCategory::None);
|
||||
}
|
||||
|
||||
// The taper and the raised ceiling are persistence-neutral BY CONSTRUCTION: the binding only
|
||||
// READS the stored seconds, so a value dialled under the old 2 s ceiling reloads bit-identical
|
||||
// and simply sits somewhere else on the knob. Nothing on the load path rewrites it.
|
||||
static void testAValueStoredUnderTheOldCeilingIsReadNotRewritten() {
|
||||
PlaySeconds p;
|
||||
p.adsr.decaySeconds = 1.75; // reachable by hand at the retired 2 s ceiling
|
||||
p.adsr.releaseSeconds = 2.0;
|
||||
const double normDecay = deckParamNorm(DeckParam::kDecay, p);
|
||||
CHECK(p.adsr.decaySeconds == 1.75); // reading the norm mutated nothing
|
||||
CHECK(p.adsr.releaseSeconds == 2.0);
|
||||
CHECK(normDecay > 0.0 && normDecay < 1.0); // still on the knob, just at a new angle
|
||||
CHECK(deckParamNorm(DeckParam::kRelease, p) > normDecay);
|
||||
// And a no-op touch survives the norm the knob would hand back — for THIS value, which is
|
||||
// exactly on the taper's output quantum grid (1.75 s parses to a grid-aligned double). A
|
||||
// legacy value off the grid (e.g. 1.2345678912345) WOULD be re-quantized on first touch;
|
||||
// that is correct, intended behaviour, not a gap this test is claiming to cover.
|
||||
setDeckParam(DeckParam::kDecay, p, normDecay, 0);
|
||||
CHECK(p.adsr.decaySeconds == 1.75);
|
||||
}
|
||||
|
||||
// The filter's four tone controls are wire-frozen in the payload: their stored value IS their
|
||||
// normalized position, and nothing in the taper pass may re-map it. Their snap is display-side
|
||||
// only, which is what this separates.
|
||||
static void testTheFilterFourKeepTheirIdentityTaper() {
|
||||
PlaySeconds p;
|
||||
const double positions[] = {0.0, 0.125, 0.5, 0.73, 1.0};
|
||||
for (double n : positions) {
|
||||
setDeckParam(DeckParam::kFilterCutoff, p, n, 0);
|
||||
setDeckParam(DeckParam::kFilterQ, p, n, 0);
|
||||
setDeckParam(DeckParam::kFilterMorph, p, n, 0);
|
||||
setDeckParam(DeckParam::kFilterDrive, p, n, 0);
|
||||
CHECK(p.filter.settings.cutoffNorm == static_cast<float>(n));
|
||||
CHECK(p.filter.settings.resonanceNorm == static_cast<float>(n));
|
||||
CHECK(p.filter.settings.morphNorm == static_cast<float>(n));
|
||||
CHECK(p.filter.settings.driveNorm == static_cast<float>(n));
|
||||
CHECK(deckParamNorm(DeckParam::kFilterCutoff, p) == static_cast<double>(static_cast<float>(n)));
|
||||
}
|
||||
}
|
||||
|
||||
// One unit, everywhere, across the formatter's whole range: a sub-millisecond value keeps a
|
||||
// decimal rather than reading as a bare zero, and a multi-second one stays in ms rather than
|
||||
// switching units mid-deck.
|
||||
@@ -162,7 +327,7 @@ static void testTimeConstantsAlwaysReadInMilliseconds() {
|
||||
CHECK(msLabel(0.012) == "12 ms"); // the use case's own reading
|
||||
CHECK(msLabel(0.25) == "250 ms");
|
||||
CHECK(msLabel(1.5) == "1500 ms"); // multi-second, still ms
|
||||
CHECK(msLabel(kEnvTimeMaxSeconds) == "2000 ms");
|
||||
CHECK(msLabel(kEnvTimeMaxSeconds) == "10000 ms");
|
||||
// The 10 ms hinge belongs to the integer form, not the decimal one.
|
||||
CHECK(msLabel(0.01) == "10 ms");
|
||||
CHECK(msLabel(0.0099) == "9.9 ms");
|
||||
@@ -175,10 +340,16 @@ static void testTimeConstantsAlwaysReadInMilliseconds() {
|
||||
}
|
||||
|
||||
int main() {
|
||||
testTheTwoCeilingNamesAreOneNumber();
|
||||
testNormRoundTripsThroughEveryValueDomain();
|
||||
testResetTouchesOnlyItsOwnRingOnADualRingKnob();
|
||||
testInnerResetLandsOnTheExactLinearNeutral();
|
||||
testResetLandsOnTheStoredDefaultOfEachControl();
|
||||
testEveryKnobIdResetsToItsDefault();
|
||||
testEveryDefaultHasAnExactNormalizedPreimage();
|
||||
testShiftSnapsToAWholeUnitOfTheDisplayedValue();
|
||||
testAValueStoredUnderTheOldCeilingIsReadNotRewritten();
|
||||
testTheFilterFourKeepTheirIdentityTaper();
|
||||
testTimeConstantsAlwaysReadInMilliseconds();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
|
||||
+175
-23
@@ -5,11 +5,12 @@
|
||||
//
|
||||
// Covers: nodeAtPoint (every drawn handle grabbable, the anchored ReleaseEnd and the Origin
|
||||
// never grabbed, other-kind nodes rejected, misses outside the radius, a dead coincident AHD
|
||||
// DecayEnd excluded while a functional one stays grabbable); resolveNodeDrag
|
||||
// (AHDSR stage times at the schematic scale, the sustain level on Y, the release dragged from
|
||||
// its START with the inverted sign, the caller's clamp domain, AHD stage times at the 1:1
|
||||
// scale, the hold FRACTION); curve-knot drags (the exponent domain, its endpoints, and the
|
||||
// round trip through the shared law that keeps knot and dial on one value); degenerate no-ops.
|
||||
// DecayEnd excluded while a functional one stays grabbable); resolveNodeDrag (AHDSR stage nodes
|
||||
// tracking the cursor across the TAPERED schematic and being its exact inverse, the sustain level
|
||||
// on Y, the release dragged from its START with the inverted sign, the caller's clamp domain, AHD
|
||||
// stage times at the 1:1 scale, the hold FRACTION); curve-knot drags (the exponent domain, its
|
||||
// endpoints, and the round trip through the shared law that keeps knot and dial on one value);
|
||||
// the interaction law (Ctrl's rate on every axis, Shift's per-category snap); degenerate no-ops.
|
||||
|
||||
#include "../src/core/instrument/ui/envelope_edit.h"
|
||||
|
||||
@@ -28,12 +29,14 @@ static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
|
||||
static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } // width 1000, height 100
|
||||
static constexpr double kTotal = 4.0;
|
||||
|
||||
// The shell's own domain (editor_controls' envClampBounds), so a drag here is clamped exactly
|
||||
// where a knob is.
|
||||
static EnvClampBounds bounds() {
|
||||
EnvClampBounds b;
|
||||
b.maxAttackSeconds = 2.0;
|
||||
b.maxHoldSeconds = 2.0;
|
||||
b.maxDecaySeconds = 2.0;
|
||||
b.maxReleaseSeconds = 2.0;
|
||||
b.maxAttackSeconds = kGateStageMaxSeconds;
|
||||
b.maxHoldSeconds = kGateStageMaxSeconds;
|
||||
b.maxDecaySeconds = kGateStageMaxSeconds;
|
||||
b.maxReleaseSeconds = kGateStageMaxSeconds;
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -160,23 +163,64 @@ static void testMissOutsideTheRadius() {
|
||||
|
||||
// --- AHDSR drags ---------------------------------------------------------------
|
||||
|
||||
static void testAhdsrStageTimesTrackTheSchematicScale() {
|
||||
// The x position of node `n` as the FORWARD map draws it — the only thing a tapered-axis drag can
|
||||
// be measured against, since there is no longer a fixed seconds-per-pixel rate to restate.
|
||||
static int drawnX(const StageEnvelope& e, EnvNode n) {
|
||||
EnvVertex v;
|
||||
return findNode(buildEnvelopePolyline(e, overlayOf(wideArea()), kTotal), n, v) ? v.x : -1;
|
||||
}
|
||||
|
||||
// The schematic axis IS the knob's taper, so what a stage node tracks is the CURSOR — at both
|
||||
// ends of the range, which a fixed-rate inverse could not manage once the axis stopped being
|
||||
// linear in seconds. Swept across four decades of stage time for exactly that reason.
|
||||
static void testAhdsrStageNodesTrackTheCursorAcrossTheWholeRange() {
|
||||
const Rect a = wideArea();
|
||||
const double startTimes[] = {0.0, 0.003, 0.25, 2.0};
|
||||
for (double t : startTimes) {
|
||||
StageEnvelope e = ahdsrEnv();
|
||||
e.attackSeconds = t;
|
||||
const StageEnvelope moved =
|
||||
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 40, 0);
|
||||
CHECK(std::abs((drawnX(moved, EnvNode::AttackEnd) - drawnX(e, EnvNode::AttackEnd)) - 40)
|
||||
<= 1);
|
||||
CHECK(moved.attackSeconds > t);
|
||||
CHECK(moved.holdSeconds == e.holdSeconds); // only the dragged param moves
|
||||
}
|
||||
// Hold and decay ride the same axis, in both directions.
|
||||
const StageEnvelope e = ahdsrEnv();
|
||||
const double secPerPx = 1.0 / gatePxPerSecond(a);
|
||||
|
||||
const StageEnvelope attack =
|
||||
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 50, 0);
|
||||
CHECK(std::fabs(attack.attackSeconds - (e.attackSeconds + 50 * secPerPx)) < 1e-9);
|
||||
CHECK(attack.holdSeconds == e.holdSeconds); // only the dragged param moves
|
||||
|
||||
const StageEnvelope hold =
|
||||
resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), -20, 0);
|
||||
CHECK(std::fabs(hold.holdSeconds - (e.holdSeconds - 20 * secPerPx)) < 1e-9);
|
||||
|
||||
CHECK(std::abs((drawnX(hold, EnvNode::HoldEnd) - drawnX(e, EnvNode::HoldEnd)) + 20) <= 1);
|
||||
CHECK(hold.holdSeconds < e.holdSeconds);
|
||||
const StageEnvelope decay =
|
||||
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 30, 0);
|
||||
CHECK(std::fabs(decay.decaySeconds - (e.decaySeconds + 30 * secPerPx)) < 1e-9);
|
||||
CHECK(std::abs((drawnX(decay, EnvNode::DecayEnd) - drawnX(e, EnvNode::DecayEnd)) - 30) <= 1);
|
||||
CHECK(decay.decaySeconds > e.decaySeconds);
|
||||
}
|
||||
|
||||
// The one-model rule, at the tapered axis: a node dragged to a pixel and the knob's value at that
|
||||
// pixel are ONE number, so the inverse has to be EXACT and not merely close. A zero-delta drag
|
||||
// reproduces the grab value bit for bit, and a drag out and straight back lands where it started.
|
||||
static void testDrawAndDragAreExactInverses() {
|
||||
const Rect a = wideArea();
|
||||
// Four decades of stage time, stopping short of the clamp: a drag that saturates at the
|
||||
// domain end deliberately does NOT come back (testStageTimesClampToTheKnobDomain owns that).
|
||||
const double startTimes[] = {0.0, 0.003, 0.060, 1.0};
|
||||
for (double t : startTimes) {
|
||||
StageEnvelope e = ahdsrEnv();
|
||||
e.attackSeconds = t;
|
||||
CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 0, 0)
|
||||
.attackSeconds == t);
|
||||
const StageEnvelope out =
|
||||
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 30, 0);
|
||||
const StageEnvelope back =
|
||||
resolveNodeDrag(out, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), -30, 0);
|
||||
// The DRAWN node returns to the exact pixel it left, which is the property the one-model
|
||||
// rule actually needs; the underlying seconds return to within the taper's own quantum
|
||||
// read back through the map, which is proportional to the value.
|
||||
CHECK(drawnX(back, EnvNode::AttackEnd) == drawnX(e, EnvNode::AttackEnd));
|
||||
CHECK(std::fabs(back.attackSeconds - t) < 1e-6 * (t + 0.01));
|
||||
}
|
||||
}
|
||||
|
||||
// The release is dragged from its TOP node and its end is anchored to the canvas edge, so
|
||||
@@ -185,13 +229,15 @@ static void testAhdsrStageTimesTrackTheSchematicScale() {
|
||||
static void testReleaseDragsFromItsStartWithInvertedSign() {
|
||||
const Rect a = wideArea();
|
||||
const StageEnvelope e = ahdsrEnv();
|
||||
const double secPerPx = 1.0 / gatePxPerSecond(a);
|
||||
const StageEnvelope longer =
|
||||
resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), -40, 0);
|
||||
CHECK(std::fabs(longer.releaseSeconds - (e.releaseSeconds + 40 * secPerPx)) < 1e-9);
|
||||
CHECK(longer.releaseSeconds > e.releaseSeconds);
|
||||
const StageEnvelope shorter =
|
||||
resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), 40, 0);
|
||||
CHECK(shorter.releaseSeconds < e.releaseSeconds);
|
||||
// The node still tracks the cursor, inverted sign notwithstanding.
|
||||
CHECK(std::abs((drawnX(longer, EnvNode::ReleaseStart) -
|
||||
drawnX(e, EnvNode::ReleaseStart)) + 40) <= 1);
|
||||
}
|
||||
|
||||
static void testSustainLevelOnTheDecayNodesYAxis() {
|
||||
@@ -327,6 +373,107 @@ static void testKnotOnANearLevelSegmentIsANoOp() {
|
||||
CHECK(out.decayCurve == 2.5);
|
||||
}
|
||||
|
||||
// The knot drag must read the SAME phi the draw used even off the segment midpoint (an odd
|
||||
// pixel span), not the fixed phi = 0.5 wideArea()'s AttackCurve span happens to land on above.
|
||||
// Checked two ways: a zero-delta grab reproduces the stored exponent, and a real one-pixel drag
|
||||
// moves the knot's own drawn y by the same one pixel every other node axis tracks 1:1.
|
||||
static void testKnotDragTracksTheDrawOnAnOddPixelSpan() {
|
||||
bool found = false;
|
||||
for (int width = 24; width <= 260 && !found; ++width) {
|
||||
const Rect a = Rect::ltrb(0, 0, width, 100);
|
||||
StageEnvelope e = ahdsrEnv();
|
||||
e.attackCurve = 3.0;
|
||||
EnvVertex origin, attackEnd, knot;
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), kTotal);
|
||||
if (!findNode(poly, EnvNode::Origin, origin)) continue;
|
||||
if (!findNode(poly, EnvNode::AttackEnd, attackEnd)) continue;
|
||||
if (!findNode(poly, EnvNode::AttackCurve, knot)) continue;
|
||||
const int span = attackEnd.x - origin.x;
|
||||
if (span <= 0 || span % 2 == 0) continue;
|
||||
found = true;
|
||||
|
||||
const StageEnvelope same =
|
||||
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 0);
|
||||
CHECK(std::fabs(same.attackCurve - e.attackCurve) < 1e-9);
|
||||
|
||||
const StageEnvelope dragged =
|
||||
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 1);
|
||||
EnvVertex knotAfter;
|
||||
CHECK(findNode(buildEnvelopePolyline(dragged, overlayOf(a), kTotal), EnvNode::AttackCurve,
|
||||
knotAfter));
|
||||
CHECK(knotAfter.x == knot.x); // a curve drag never moves the knot's x
|
||||
CHECK(std::abs(knotAfter.y - (knot.y + 1)) <= 1);
|
||||
}
|
||||
CHECK(found); // the sweep must actually land on an odd span
|
||||
}
|
||||
|
||||
// --- the interaction law on the overlay ----------------------------------------
|
||||
|
||||
// Ctrl scales the PIXEL delta, so it composes with every axis — the tapered schematic, the 1:1
|
||||
// wall clock, the level and the exponent — instead of each getting its own rule.
|
||||
static void testCtrlScalesEveryAxisOfANodeDrag() {
|
||||
const Rect a = wideArea();
|
||||
const StageEnvelope e = ahdsrEnv();
|
||||
const DragModifiers fine{false, true};
|
||||
const int coarse = 10;
|
||||
const int equivalent = static_cast<int>(coarse / kFineDragScale); // 200 fine px == 10 coarse
|
||||
CHECK(std::fabs(
|
||||
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), equivalent,
|
||||
0, fine).attackSeconds -
|
||||
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), coarse, 0)
|
||||
.attackSeconds) < 1e-9);
|
||||
CHECK(std::fabs(
|
||||
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0,
|
||||
equivalent, fine).sustainLevel -
|
||||
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, coarse)
|
||||
.sustainLevel) < 1e-9);
|
||||
// A zero delta is identical under either rate — the state the shell's re-anchor establishes
|
||||
// at every modifier transition, and why the value cannot jump across one.
|
||||
CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 0, 0, fine)
|
||||
.attackSeconds == e.attackSeconds);
|
||||
}
|
||||
|
||||
// Shift reaches the overlay because node, knot and knob are surfaces onto ONE model: a snap
|
||||
// available on the knob and not on the node would be exactly the divergence that rule forbids.
|
||||
// Each axis is asserted against the snap of ITS OWN category applied to the free drag's result —
|
||||
// a node that routed a level through the millisecond snap, or snapped before the axis map rather
|
||||
// than after it, fails here. The snaps themselves are param_taper's own tests.
|
||||
static void testShiftSnapsEachAxisToItsOwnWholeUnit() {
|
||||
const Rect a = wideArea();
|
||||
const StageEnvelope e = ahdsrEnv();
|
||||
const DragModifiers shift{true, false};
|
||||
|
||||
const StageEnvelope freeMs =
|
||||
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 37, 0);
|
||||
const StageEnvelope snapMs =
|
||||
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 37, 0, shift);
|
||||
CHECK(snapMs.attackSeconds == snapSecondsToWholeMs(freeMs.attackSeconds));
|
||||
CHECK(snapMs.attackSeconds != freeMs.attackSeconds); // the drag really did move to the grid
|
||||
CHECK(std::fabs(snapMs.attackSeconds - freeMs.attackSeconds) <= 0.0005 + 1e-12);
|
||||
|
||||
const StageEnvelope freeLevel =
|
||||
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -13);
|
||||
const StageEnvelope snapLevel =
|
||||
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -13, shift);
|
||||
CHECK(snapLevel.sustainLevel == snapFractionToWholePercent(freeLevel.sustainLevel));
|
||||
CHECK(std::fabs(snapLevel.sustainLevel - freeLevel.sustainLevel) <= 0.005 + 1e-12);
|
||||
|
||||
const StageEnvelope freeKnot =
|
||||
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 9);
|
||||
const StageEnvelope snapKnot =
|
||||
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 9, shift);
|
||||
CHECK(snapKnot.attackCurve == snapExponentToWhole(freeKnot.attackCurve));
|
||||
CHECK(snapKnot.attackCurve != freeKnot.attackCurve);
|
||||
|
||||
// An AHD's Hold node edits a FRACTION, so its whole unit is a percent, not a millisecond.
|
||||
const StageEnvelope freeFrac =
|
||||
resolveNodeDrag(ahdEnv(), EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), 37, 0);
|
||||
const StageEnvelope snapFrac = resolveNodeDrag(ahdEnv(), EnvNode::HoldEnd, overlayOf(a),
|
||||
kTotal, bounds(), 37, 0, shift);
|
||||
CHECK(snapFrac.holdFraction == snapFractionToWholePercent(freeFrac.holdFraction));
|
||||
CHECK(snapFrac.holdFraction != freeFrac.holdFraction);
|
||||
}
|
||||
|
||||
// --- degenerate ----------------------------------------------------------------
|
||||
|
||||
static void testDegenerateInputsAreNoOps() {
|
||||
@@ -347,7 +494,8 @@ int main() {
|
||||
testFunctionalCoincidentDecayEndStaysGrabbable();
|
||||
testMissOutsideTheRadius();
|
||||
|
||||
testAhdsrStageTimesTrackTheSchematicScale();
|
||||
testAhdsrStageNodesTrackTheCursorAcrossTheWholeRange();
|
||||
testDrawAndDragAreExactInverses();
|
||||
testReleaseDragsFromItsStartWithInvertedSign();
|
||||
testSustainLevelOnTheDecayNodesYAxis();
|
||||
testStageTimesClampToTheKnobDomain();
|
||||
@@ -355,10 +503,14 @@ int main() {
|
||||
testAhdStageTimesTrackTheWallClockScale();
|
||||
testAhdHoldNodeEditsTheFraction();
|
||||
|
||||
testCtrlScalesEveryAxisOfANodeDrag();
|
||||
testShiftSnapsEachAxisToItsOwnWholeUnit();
|
||||
|
||||
testKnotDragMovesTheExponentWithinItsDomain();
|
||||
testKnotAndModelCannotDiverge();
|
||||
testKnotOnALevelSegmentIsANoOp();
|
||||
testKnotOnANearLevelSegmentIsANoOp();
|
||||
testKnotDragTracksTheDrawOnAnOddPixelSpan();
|
||||
|
||||
testDegenerateInputsAreNoOps();
|
||||
|
||||
|
||||
+154
-30
@@ -4,15 +4,20 @@
|
||||
// RIGHT-ANCHORED release, and the sustain-less AHD laid 1:1 over the waveform's time axis.
|
||||
//
|
||||
// Covers: timeToX / levelToY (linear maps, edge clamps, past-end clamped to right-1, no 32-bit
|
||||
// overflow on huge times, degenerate area/duration); gatePxPerSecond; the AHDSR polyline (node
|
||||
// order, levels, release anchored at the right edge, the sustain plateau reaching the edge at
|
||||
// zero release, per-segment separation at the tier-0 defaults, overrun compression, every
|
||||
// vertex in-bounds); splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the
|
||||
// AHD polyline (1:1 with the time axis, origin offset); curve knots (present only on sloped
|
||||
// non-zero segments, height following the exponent); the degenerate flat baseline.
|
||||
// overflow on huge times, degenerate area/duration); gateStageSlotPx; the AHDSR polyline (node
|
||||
// order, levels, the TAPERED stage placement and its legibility at both ends of the range,
|
||||
// release anchored at the right edge, the sustain plateau reaching the edge at zero release,
|
||||
// per-segment separation at the tier-0 defaults, overrun compression, every vertex in-bounds);
|
||||
// splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the AHD polyline (1:1 with
|
||||
// the time axis, origin offset); curve knots (present only on sloped non-zero segments, height
|
||||
// following the exponent, and — swept across ODD and EVEN pixel spans, not one fixture's width —
|
||||
// sitting on the curve its own vertices imply rather than always the segment's exact midpoint);
|
||||
// the degenerate flat baseline.
|
||||
|
||||
#include "../src/core/instrument/ui/envelope_overlay.h"
|
||||
|
||||
#include "../src/core/instrument/ui/sample_bands.h" // the editor floor the legibility test uses
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
@@ -94,20 +99,19 @@ static void testDegenerateAreaAndDuration() {
|
||||
CHECK(timeToX(Rect{}, 2.0, 1.0) == 0);
|
||||
CHECK(timeToX(wideArea(), 0.0, 1.0) == wideArea().x);
|
||||
CHECK(levelToY(Rect{}, 0.5) == 0);
|
||||
CHECK(gatePxPerSecond(Rect{}) == 0.0);
|
||||
CHECK(gateStageSlotPx(Rect{}) == 0.0);
|
||||
}
|
||||
|
||||
// The literal PARAM-DOMAIN scale, independent of any sample duration: usable px = canvas width
|
||||
// minus the last column minus 4 node-separation bases, spread over 4 x kGateStageMaxSeconds.
|
||||
// This is what makes a dragged handle track the cursor 1:1 (envelope_edit's own inverse reads
|
||||
// this same function) — a scale regression here is exactly what a relational-only check misses.
|
||||
static void testGatePxPerSecond() {
|
||||
// 967 / 8 px/s, pinned as a literal — restating the formula with the same named constants
|
||||
// would let a change to kGateNodeSepPx or kGateStageMaxSeconds move both sides and pass
|
||||
// silently.
|
||||
CHECK(gatePxPerSecond(wideArea()) == 120.875);
|
||||
CHECK(gatePxPerSecond(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0
|
||||
CHECK(gatePxPerSecond(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0
|
||||
// The literal slot width, independent of any sample duration: usable px = canvas width minus the
|
||||
// last column minus 4 node-separation bases, split four ways. A stage then occupies its own
|
||||
// TAPERED fraction of that slot, which is what makes a dragged handle track the cursor at both
|
||||
// ends of the range — a scale regression here is exactly what a relational-only check misses.
|
||||
static void testGateStageSlotPx() {
|
||||
// 967 / 4 px, pinned as a literal — restating the formula with the same named constants
|
||||
// would let a change to kGateNodeSepPx move both sides and pass silently.
|
||||
CHECK(gateStageSlotPx(wideArea()) == 241.75);
|
||||
CHECK(gateStageSlotPx(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0
|
||||
CHECK(gateStageSlotPx(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0
|
||||
}
|
||||
|
||||
// --- the AHDSR schematic ------------------------------------------------------
|
||||
@@ -135,24 +139,52 @@ static void testAhdsrNodeOrderAndLevels() {
|
||||
CHECK(v.x == a.right() - 1); // ANCHORED, whatever the release is
|
||||
}
|
||||
|
||||
// The literal per-node x placement, hand-derived from the documented formula (pps = 120.875
|
||||
// px/s per testGatePxPerSecond; each timed stage is prefixed by the kGateNodeSepPx=8 base):
|
||||
// attack .2s -> raw 8+24.175=32.175 -> px 32; hold .1s -> raw 32.175+8+12.0875=52.2625 -> px 52;
|
||||
// decay .3s -> raw 52.2625+8+36.2625=96.525 -> px 97; plateau -> raw 999-8-48.35=942.65 -> px
|
||||
// 943; release end pinned at the last column, 999. A literal regression pin — no relational or
|
||||
// bounds-only check catches a formula-shape change the way an exact pixel count does.
|
||||
// The literal per-node x placement, hand-derived from the documented formula (slot = 241.75 px
|
||||
// per testGateStageSlotPx; each timed stage is prefixed by the kGateNodeSepPx=8 base and occupies
|
||||
// slot x timeNormFromSeconds(t) of its own slot; L = ln(1 + 10/0.003) = 8.112028):
|
||||
// attack .25s -> norm ln(84.3333)/L = 0.546677 -> 8 + 132.159 = 140.159 -> px 140
|
||||
// hold .05s -> norm ln(17.6667)/L = 0.354007 -> 140.159 + 8 + 85.581 = 233.740 -> px 234
|
||||
// decay .5s -> norm ln(167.667)/L = 0.631385 -> 233.740 + 8 + 152.637 = 394.377 -> px 394
|
||||
// plateau 1s -> norm ln(334.333)/L = 0.716493 -> 999 - 8 - 173.211 = 817.789 -> px 818
|
||||
// release end pinned at the last column, 999.
|
||||
// A literal regression pin — no relational or bounds-only check catches a formula-shape change
|
||||
// the way an exact pixel count does.
|
||||
static void testAhdsrSchematicPlacement() {
|
||||
const Rect a = wideArea();
|
||||
const std::vector<EnvVertex> poly =
|
||||
buildEnvelopePolyline(ahdsr(0.2, 0.1, 0.3, 0.5, 0.4), overlayOf(a), 4.0);
|
||||
buildEnvelopePolyline(ahdsr(0.25, 0.05, 0.5, 0.5, 1.0), overlayOf(a), 4.0);
|
||||
EnvVertex v;
|
||||
CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 32);
|
||||
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 52);
|
||||
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 97);
|
||||
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 943);
|
||||
CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 140);
|
||||
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 234);
|
||||
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 394);
|
||||
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 818);
|
||||
CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 999);
|
||||
}
|
||||
|
||||
// The legibility the tapered axis exists for, at BOTH ends of the raised range. Linear-in-seconds
|
||||
// put the 3 ms default attack 0.07 px from the origin at a 10 s ceiling — indistinguishable from
|
||||
// zero and impossible to grab. Asserted at the editor's own floor width, not a comfortable one.
|
||||
static void testTaperedAxisKeepsBothEndsOfTheRangeLegible() {
|
||||
const Rect floorArea = Rect::ltrb(0, 0, kEditorMinWidth - 2 * kPad, 100);
|
||||
const std::vector<EnvVertex> poly =
|
||||
buildEnvelopePolyline(ahdsr(0.003, 0.0, 0.0, 1.0, 0.060), overlayOf(floorArea), 4.0);
|
||||
EnvVertex origin, attack;
|
||||
CHECK(findNode(poly, EnvNode::Origin, origin));
|
||||
CHECK(findNode(poly, EnvNode::AttackEnd, attack));
|
||||
// Well clear of the grab radius, so the default attack is a real handle rather than a node
|
||||
// sitting on the origin.
|
||||
CHECK(attack.x - origin.x >= 20);
|
||||
|
||||
// And a maxed stage still lands its end node at its slot's edge: the taper's norm-1 end and
|
||||
// the schematic's canvas edge are the same place, which is the anchor the policy rests on.
|
||||
const std::vector<EnvVertex> maxed = buildEnvelopePolyline(
|
||||
ahdsr(kGateStageMaxSeconds, 0.0, 0.0, 1.0, 0.0), overlayOf(floorArea), 4.0);
|
||||
EnvVertex maxAttack;
|
||||
CHECK(findNode(maxed, EnvNode::AttackEnd, maxAttack));
|
||||
const double slot = gateStageSlotPx(floorArea);
|
||||
CHECK(maxAttack.x == floorArea.x + static_cast<int>(kGateNodeSepPx + slot + 0.5));
|
||||
}
|
||||
|
||||
// The AHDSR schematic is scaled by the PARAM domain, NOT the capture length: the same params
|
||||
// produce the SAME polyline whether totalSeconds is 0.3 or 10 (gatePolyline doesn't even take
|
||||
// totalSeconds — only the sustain-less AHD's x-axis is wall-clock/PCM-aligned).
|
||||
@@ -392,6 +424,95 @@ static void testKnotHeightTracksTheExponent() {
|
||||
CHECK(steep.y >= a.y && steep.y <= a.bottom() - 1);
|
||||
}
|
||||
|
||||
// --- the knot sits ON its own curve (the reported defect, stated as the gate) -------------
|
||||
|
||||
// The general (non-truncated-phi) reading of a knot's level, computed from the vertices
|
||||
// `buildEnvelopePolyline` actually returned — x0/x1/knotX are all int pixels a caller can read
|
||||
// off the polyline, so this is a check ON the output, not a restatement of knotVtx's own
|
||||
// formula. x0 == x1 has no interior (no knot is ever built there).
|
||||
static double expectedKnotLevel(int x0, int x1, int knotX, double startLevel, double endLevel,
|
||||
double exponent) {
|
||||
const double phi = (x1 != x0)
|
||||
? static_cast<double>(knotX - x0) / static_cast<double>(x1 - x0)
|
||||
: 0.5;
|
||||
return startLevel + (endLevel - startLevel) * reasampler::util::curveMap(phi, exponent);
|
||||
}
|
||||
|
||||
// The reported defect, stated as the gate: at every exponent the knot's centre lies on the
|
||||
// trace, within 1 px. Swept over a range of canvas widths (down to a few pixels of stage span)
|
||||
// so the check actually exercises ODD pixel spans, where the segment's true midpoint falls
|
||||
// between two pixels — testKnotHeightTracksTheExponent above sits at a width whose span happens
|
||||
// to be even, which is exactly the kind of fixture that missed this defect.
|
||||
static void testKnotSitsOnItsOwnCurveAcrossOddAndEvenSpans() {
|
||||
bool sawOdd = false, sawEven = false;
|
||||
int worstAhdsr = 0, worstAhd = 0;
|
||||
for (int width = 24; width <= 260; width += 3) {
|
||||
const Rect a = Rect::ltrb(0, 0, width, 100);
|
||||
for (double exp : {util::kCurveMin, 0.3, 1.0, 3.0, util::kCurveMax}) {
|
||||
StageEnvelope e = ahdsr(0.4, 0.0, 0.0, 1.0, 0.0);
|
||||
e.attackCurve = exp;
|
||||
EnvVertex origin, attackEnd, knot;
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), 4.0);
|
||||
if (findNode(poly, EnvNode::Origin, origin) &&
|
||||
findNode(poly, EnvNode::AttackEnd, attackEnd) &&
|
||||
findNode(poly, EnvNode::AttackCurve, knot)) {
|
||||
const int span = attackEnd.x - origin.x;
|
||||
if (span > 0) {
|
||||
if (span % 2 == 0) sawEven = true; else sawOdd = true;
|
||||
const double expected =
|
||||
expectedKnotLevel(origin.x, attackEnd.x, knot.x, 0.0, 1.0, exp);
|
||||
const int expectedY = levelToY(a, expected);
|
||||
worstAhdsr = (std::max)(worstAhdsr, std::abs(knot.y - expectedY));
|
||||
CHECK(std::abs(knot.y - expectedY) <= 1);
|
||||
}
|
||||
}
|
||||
|
||||
StageEnvelope f = ahd(0.4, 0.6, 0.5, 0.0, 3.0);
|
||||
f.attackCurve = exp;
|
||||
EnvVertex originAhd, attackEndAhd, knotAhd;
|
||||
const std::vector<EnvVertex> polyAhd = buildEnvelopePolyline(f, overlayOf(a), 4.0);
|
||||
if (findNode(polyAhd, EnvNode::Origin, originAhd) &&
|
||||
findNode(polyAhd, EnvNode::AttackEnd, attackEndAhd) &&
|
||||
findNode(polyAhd, EnvNode::AttackCurve, knotAhd)) {
|
||||
const int span = attackEndAhd.x - originAhd.x;
|
||||
if (span > 0) {
|
||||
if (span % 2 == 0) sawEven = true; else sawOdd = true;
|
||||
const double expected = expectedKnotLevel(originAhd.x, attackEndAhd.x,
|
||||
knotAhd.x, 0.0, 1.0, exp);
|
||||
const int expectedY = levelToY(a, expected);
|
||||
worstAhd = (std::max)(worstAhd, std::abs(knotAhd.y - expectedY));
|
||||
CHECK(std::abs(knotAhd.y - expectedY) <= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CHECK(sawOdd); // the sweep actually exercised an odd-pixel span...
|
||||
CHECK(sawEven); // ...and an even one, so this isn't resting on one fixture's luck.
|
||||
std::printf(" worst knot/curve separation: AHDSR %d px, AHD %d px\n", worstAhdsr, worstAhd);
|
||||
}
|
||||
|
||||
// Exponent 1.0 is still a plain straight line even off the segment's exact midpoint — checked
|
||||
// at a deliberately ODD span so the linear case isn't only proven at the symmetric one.
|
||||
static void testNeutralExponentIsAStraightLineOffCentre() {
|
||||
bool found = false;
|
||||
for (int width = 24; width <= 200 && !found; ++width) {
|
||||
const Rect a = Rect::ltrb(0, 0, width, 100);
|
||||
StageEnvelope e = ahdsr(0.4, 0.0, 0.0, 1.0, 0.0);
|
||||
e.attackCurve = util::kCurveNeutral;
|
||||
EnvVertex origin, attackEnd, knot;
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), 4.0);
|
||||
if (!findNode(poly, EnvNode::Origin, origin)) continue;
|
||||
if (!findNode(poly, EnvNode::AttackEnd, attackEnd)) continue;
|
||||
if (!findNode(poly, EnvNode::AttackCurve, knot)) continue;
|
||||
const int span = attackEnd.x - origin.x;
|
||||
if (span <= 0 || span % 2 == 0) continue;
|
||||
found = true;
|
||||
const double phi = static_cast<double>(knot.x - origin.x) / static_cast<double>(span);
|
||||
CHECK(std::fabs(knot.level - phi) < 1e-12); // linear: level == phi, exactly
|
||||
}
|
||||
CHECK(found); // the sweep must actually land on an odd span
|
||||
}
|
||||
|
||||
// --- degenerate ---------------------------------------------------------------
|
||||
|
||||
static void testDegenerateSurfaceYieldsFlatBaseline() {
|
||||
@@ -409,10 +530,11 @@ int main() {
|
||||
testTimeToXClampsBothEnds();
|
||||
testLevelToY();
|
||||
testDegenerateAreaAndDuration();
|
||||
testGatePxPerSecond();
|
||||
testGateStageSlotPx();
|
||||
|
||||
testAhdsrNodeOrderAndLevels();
|
||||
testAhdsrSchematicPlacement();
|
||||
testTaperedAxisKeepsBothEndsOfTheRangeLegible();
|
||||
testGateLayoutIndependentOfSampleDuration();
|
||||
testZeroReleasePutsTheSustainPlateauAtTheRightEdge();
|
||||
testReleaseGrowsLeftwardFromTheAnchor();
|
||||
@@ -427,6 +549,8 @@ int main() {
|
||||
|
||||
testKnotsRideOnlySlopedNonZeroSegments();
|
||||
testKnotHeightTracksTheExponent();
|
||||
testKnotSitsOnItsOwnCurveAcrossOddAndEvenSpans();
|
||||
testNeutralExponentIsAStraightLineOffCentre();
|
||||
|
||||
testDegenerateSurfaceYieldsFlatBaseline();
|
||||
|
||||
|
||||
@@ -245,22 +245,35 @@ static void testKnobNeedlePointOnCircle() {
|
||||
|
||||
static void testKnobDragUpIncreases() {
|
||||
// Up (negative dy) increases, down decreases, scaled by the drag range.
|
||||
CHECK(approx(knobDragValue(0.5, -32, 128), 0.75));
|
||||
CHECK(approx(knobDragValue(0.5, +32, 128), 0.25));
|
||||
CHECK(approx(knobDragValue(0.5, -32, {}, 128), 0.75));
|
||||
CHECK(approx(knobDragValue(0.5, +32, {}, 128), 0.25));
|
||||
// A full-range upward drag from 0 lands exactly at 1.
|
||||
CHECK(approx(knobDragValue(0.0, -128, 128), 1.0));
|
||||
CHECK(approx(knobDragValue(0.0, -128, {}, 128), 1.0));
|
||||
// Default sensitivity applies when the range is omitted.
|
||||
CHECK(approx(knobDragValue(0.0, -kKnobDragRangePixels), 1.0));
|
||||
}
|
||||
|
||||
static void testKnobDragClamps() {
|
||||
CHECK(approx(knobDragValue(0.9, -64, 128), 1.0)); // over-drag up clamps at 1
|
||||
CHECK(approx(knobDragValue(0.1, +64, 128), 0.0)); // over-drag down clamps at 0
|
||||
CHECK(approx(knobDragValue(0.9, -64, {}, 128), 1.0)); // over-drag up clamps at 1
|
||||
CHECK(approx(knobDragValue(0.1, +64, {}, 128), 0.0)); // over-drag down clamps at 0
|
||||
// The start value itself is clamped before the delta applies.
|
||||
CHECK(approx(knobDragValue(1.5, 0, 128), 1.0));
|
||||
CHECK(approx(knobDragValue(-0.5, 0, 128), 0.0));
|
||||
CHECK(approx(knobDragValue(1.5, 0, {}, 128), 1.0));
|
||||
CHECK(approx(knobDragValue(-0.5, 0, {}, 128), 0.0));
|
||||
// A degenerate drag range yields the clamped start value.
|
||||
CHECK(approx(knobDragValue(0.7, -50, 0), 0.7));
|
||||
CHECK(approx(knobDragValue(0.7, -50, {}, 0), 0.7));
|
||||
}
|
||||
|
||||
// Ctrl scales the drag rate; Shift+Ctrl is Shift, so the rate goes back to coarse. The snap
|
||||
// itself is not this module's — only the rate is.
|
||||
static void testCtrlScalesTheDragRateAndShiftOverridesIt() {
|
||||
const DragModifiers fine{false, true};
|
||||
const DragModifiers both{true, true};
|
||||
CHECK(approx(knobDragValue(0.5, -32, fine, 128), 0.5 + 0.25 * kFineDragScale));
|
||||
CHECK(approx(knobDragValue(0.5, -32, both, 128), 0.75));
|
||||
CHECK(approx(knobDragValue(0.5, -32, DragModifiers{true, false}, 128), 0.75));
|
||||
// Continuity across a transition is the CALLER's re-anchor, not this function's: at a
|
||||
// zero delta both rates agree, which is exactly the state a re-anchor establishes.
|
||||
CHECK(knobDragValue(0.42, 0, fine, 128) == knobDragValue(0.42, 0, {}, 128));
|
||||
}
|
||||
|
||||
// --- controlAtPoint routing ---------------------------------------------------
|
||||
@@ -321,6 +334,7 @@ int main() {
|
||||
testKnobNeedlePointOnCircle();
|
||||
testKnobDragUpIncreases();
|
||||
testKnobDragClamps();
|
||||
testCtrlScalesTheDragRateAndShiftOverridesIt();
|
||||
testControlAtPointRoutes();
|
||||
testControlAtPointMisses();
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
// 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 "../src/core/instrument/ui/envelope_overlay.h" // gateStageSlotPx: finest drag surface
|
||||
#include "../src/core/instrument/ui/sample_bands.h" // kEditorMinWidth/kPad: the editor floor
|
||||
|
||||
#include <cfenv>
|
||||
#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 on ANY surface this taper serves — not the knob's own 128 px
|
||||
// travel, which is coarser than the AHDSR schematic's node drag at the editor floor. Derived from
|
||||
// the floor constant and the overlay's own slot-width formula, so a later floor change sharpens
|
||||
// (or coarsens) the step this test exercises automatically instead of leaving a copied number
|
||||
// silently stale.
|
||||
static void testEveryFinestDragStepMovesTheValue() {
|
||||
const Rect floorArea = Rect::ltrb(0, 0, kEditorMinWidth - 2 * kPad, 100);
|
||||
const double slot = gateStageSlotPx(floorArea);
|
||||
const int steps = static_cast<int>(slot / kFineDragScale);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// MODE-INDEPENDENCE, the whole point of resolveTo's std::round over std::nearbyint. First shows
|
||||
// the defect directly, generically: under round-toward-zero, the RETIRED std::nearbyint reads
|
||||
// that mode and truncates a value whose fraction is well past half, while std::round (specified
|
||||
// to round half-away-from-zero REGARDLESS of the current mode) does not. Then proves the
|
||||
// production round trip itself — not a stand-in — survives the same hostile mode across the grid.
|
||||
static void testRoundingSurvivesAHostileFpRoundingMode() {
|
||||
const int saved = std::fegetround();
|
||||
CHECK(std::fesetround(FE_TOWARDZERO) == 0);
|
||||
|
||||
CHECK(std::nearbyint(12.9) == 12.0); // the RETIRED behaviour: mode-dependent, wrong here
|
||||
CHECK(std::round(12.9) == 13.0); // the fix: mode-independent, rounds to nearest
|
||||
|
||||
for (int us = 0; us <= 200000; us += 7) {
|
||||
const double seconds = static_cast<double>(us) / 1e6;
|
||||
CHECK(timeSecondsFromNorm(timeNormFromSeconds(seconds)) == seconds);
|
||||
if (timeSecondsFromNorm(timeNormFromSeconds(seconds)) != seconds) break;
|
||||
}
|
||||
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) break;
|
||||
}
|
||||
|
||||
std::fesetround(saved); // restore — every other test in this binary assumes the default
|
||||
}
|
||||
|
||||
// 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();
|
||||
testRoundingSurvivesAHostileFpRoundingMode();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user