Merge Γ-W1-T1: one taper, one modifier law, the 10 s stage ceiling

This commit is contained in:
2026-08-01 21:33:01 -04:00
25 changed files with 1519 additions and 220 deletions
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+22 -8
View File
@@ -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();
+296
View File
@@ -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;
}