instrument: fix AHD DecayEnd overlay/grab defect, pin flaky curve test, close comment/doc findings
This commit is contained in:
@@ -156,8 +156,11 @@ static void testKnobLawRoundTripsOutsideTheDetent() {
|
||||
const double back = curveFromKnobNorm(knobNormFromCurve(e));
|
||||
CHECK(std::fabs(back - e) < 1e-9);
|
||||
}
|
||||
CHECK(curveFromKnobNorm(0.0) == kCurveMin);
|
||||
CHECK(curveFromKnobNorm(-3.0) == kCurveMin); // out-of-range norm saturates
|
||||
// exp(-log(10)) is not guaranteed bit-exact to kCurveMin's literal 0.1 (1-2 ulp either way);
|
||||
// both norms below collapse to the same t == 0.0 computation, so both get the same
|
||||
// tolerance rather than leaning on clampCurve's floor to land on it by luck.
|
||||
CHECK(std::fabs(curveFromKnobNorm(0.0) - kCurveMin) < 1e-12);
|
||||
CHECK(std::fabs(curveFromKnobNorm(-3.0) - kCurveMin) < 1e-12); // out-of-range norm saturates
|
||||
CHECK(std::fabs(curveFromKnobNorm(1.0) - kCurveMax) < 1e-12);
|
||||
// The ENDS need only land on the domain, not on an exact norm — 0.1 is not exactly 1/10 in
|
||||
// binary, so log(kCurveMin) is a hair off -log(kCurveMax). Only the centre carries an
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// pixel delta produces exactly the param a knob would have.
|
||||
//
|
||||
// Covers: nodeAtPoint (every drawn handle grabbable, the anchored ReleaseEnd and the Origin
|
||||
// never grabbed, other-kind nodes rejected, misses outside the radius); resolveNodeDrag
|
||||
// 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
|
||||
@@ -47,6 +48,18 @@ static StageEnvelope ahdsrEnv() {
|
||||
return e;
|
||||
}
|
||||
|
||||
// F1's coincidence cases need attack/decay/fraction/span combinations ahdEnv() doesn't cover.
|
||||
static StageEnvelope ahd(double a, double d, double frac, double origin, double span) {
|
||||
StageEnvelope e;
|
||||
e.kind = EnvKind::Ahd;
|
||||
e.attackSeconds = a;
|
||||
e.decaySeconds = d;
|
||||
e.holdFraction = frac;
|
||||
e.originSeconds = origin;
|
||||
e.spanSeconds = span;
|
||||
return e;
|
||||
}
|
||||
|
||||
static StageEnvelope ahdEnv() {
|
||||
StageEnvelope e;
|
||||
e.kind = EnvKind::Ahd;
|
||||
@@ -117,6 +130,26 @@ static void testAhdHasNoSustainNodes() {
|
||||
CHECK(out.attackSeconds == e.attackSeconds);
|
||||
}
|
||||
|
||||
// F1: at the Trigger default (decay 0, holdFraction 1.0) DecayEnd sits on HoldEnd's own instant
|
||||
// AND cannot move there (resolveNodeDrag's decay branch has derivative 0 — see the denom guard).
|
||||
// A grab at its true (now un-nudged) position must miss rather than resolve to a dead handle;
|
||||
// HoldEnd, the live node underneath, stays fully grabbable.
|
||||
static void testDeadCoincidentDecayEndIsNotGrabbable() {
|
||||
const StageEnvelope e = ahd(0.5, 0.0, 1.0, 0.0, 3.0);
|
||||
CHECK(!grabAt(e, EnvNode::DecayEnd).hit);
|
||||
CHECK(grabAt(e, EnvNode::HoldEnd).hit);
|
||||
CHECK(grabAt(e, EnvNode::HoldEnd).node == EnvNode::HoldEnd);
|
||||
}
|
||||
|
||||
// Coincidence alone does not drop DecayEnd — only holdFraction == 1.0 makes it truly dead. With
|
||||
// holdFraction < 1 the X-drag still moves decaySeconds (denom > 0), so it stays grabbable even
|
||||
// when decay is 0 and it starts out coincident with HoldEnd.
|
||||
static void testFunctionalCoincidentDecayEndStaysGrabbable() {
|
||||
const StageEnvelope e = ahd(0.5, 0.0, 0.5, 0.0, 3.0);
|
||||
CHECK(grabAt(e, EnvNode::DecayEnd).hit);
|
||||
CHECK(grabAt(e, EnvNode::DecayEnd).node == EnvNode::DecayEnd);
|
||||
}
|
||||
|
||||
static void testMissOutsideTheRadius() {
|
||||
const StageEnvelope e = ahdsrEnv();
|
||||
const Rect a = wideArea();
|
||||
@@ -310,6 +343,8 @@ int main() {
|
||||
testEveryDrawnHandleIsGrabbable();
|
||||
testAnchoredEndAndOriginAreNotGrabbable();
|
||||
testAhdHasNoSustainNodes();
|
||||
testDeadCoincidentDecayEndIsNotGrabbable();
|
||||
testFunctionalCoincidentDecayEndStaysGrabbable();
|
||||
testMissOutsideTheRadius();
|
||||
|
||||
testAhdsrStageTimesTrackTheSchematicScale();
|
||||
|
||||
@@ -102,9 +102,10 @@ static void testDegenerateAreaAndDuration() {
|
||||
// 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() {
|
||||
const double expected =
|
||||
(1000.0 - 1.0 - 4.0 * kGateNodeSepPx) / (4.0 * kGateStageMaxSeconds); // 967/8 px/s
|
||||
CHECK(gatePxPerSecond(wideArea()) == expected);
|
||||
// 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
|
||||
}
|
||||
@@ -304,6 +305,36 @@ static void testAhdIsOneToOneWithTheTimeAxis() {
|
||||
CHECK(!hasNode(poly, EnvNode::ReleaseCurve));
|
||||
}
|
||||
|
||||
// F1 regression: DecayEnd stays at its true wall-clock instant even when that instant coincides
|
||||
// with HoldEnd's (decay ~ 0) — the 1:1 AHD axis promises N seconds -> N seconds, and a nudge
|
||||
// away from that instant lies about the shape, including the Trigger default's abrupt cutoff.
|
||||
// Fails against the prior nudge, which moved DecayEnd right whenever the gap was under
|
||||
// kGateNodeSepPx.
|
||||
static void testDecayEndStaysAtItsTrueInstantEvenWhenCoincidentWithHoldEnd() {
|
||||
const Rect a = wideArea();
|
||||
const double total = 4.0;
|
||||
|
||||
// Short but nonzero decay: the true gap to HoldEnd is a few px, under kGateNodeSepPx, so
|
||||
// the retired nudge would have fired here too.
|
||||
const StageEnvelope shortDecay = ahd(0.5, 0.02, 1.0, 0.0, 3.0);
|
||||
const AhdSplit sShort = splitAhdSeconds(shortDecay);
|
||||
EnvVertex decayShort;
|
||||
CHECK(findNode(buildEnvelopePolyline(shortDecay, overlayOf(a), total), EnvNode::DecayEnd,
|
||||
decayShort));
|
||||
CHECK(decayShort.x == timeToX(a, total, sShort.total));
|
||||
|
||||
// Zero decay (the Trigger AHD default's shape): DecayEnd and HoldEnd share the exact same
|
||||
// instant — the abrupt cutoff — and DecayEnd must not be nudged off it.
|
||||
const StageEnvelope zeroDecay = ahd(0.5, 0.0, 1.0, 0.0, 3.0);
|
||||
const AhdSplit sZero = splitAhdSeconds(zeroDecay);
|
||||
const std::vector<EnvVertex> polyZero = buildEnvelopePolyline(zeroDecay, overlayOf(a), total);
|
||||
EnvVertex decayZero, holdZero;
|
||||
CHECK(findNode(polyZero, EnvNode::DecayEnd, decayZero));
|
||||
CHECK(findNode(polyZero, EnvNode::HoldEnd, holdZero));
|
||||
CHECK(decayZero.x == timeToX(a, total, sZero.total));
|
||||
CHECK(decayZero.x == holdZero.x); // truly coincident, not nudged apart
|
||||
}
|
||||
|
||||
// --- curve knots --------------------------------------------------------------
|
||||
|
||||
// A knot rides every sloped stage that has a duration, and none that does not — a zero-length
|
||||
@@ -392,6 +423,7 @@ int main() {
|
||||
testAhdSplitNeverExceedsTheSpan();
|
||||
testHoldFractionEndpoints();
|
||||
testAhdIsOneToOneWithTheTimeAxis();
|
||||
testDecayEndStaysAtItsTrueInstantEvenWhenCoincidentWithHoldEnd();
|
||||
|
||||
testKnotsRideOnlySlopedNonZeroSegments();
|
||||
testKnotHeightTracksTheExponent();
|
||||
|
||||
@@ -703,8 +703,10 @@ static void testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate(
|
||||
CHECK(matched.trigAhd.attackFrames == 441);
|
||||
CHECK(matched.trigAhd.decayFrames == 882);
|
||||
|
||||
// A 44.1 kHz file opened in a 48 kHz project: 441 * 48000/44100 = 480 source frames, ~8.8%
|
||||
// longer than the fade the saved instance actually had.
|
||||
// resolvePlay's second argument is the DECODE rate; the seconds above were lifted (divided)
|
||||
// at the PROJECT rate 44100 — so this is a 48 kHz file opened in a 44.1 kHz project (the
|
||||
// mirror of component_state_io.h's worked example): 441 * 48000/44100 = 480 source frames,
|
||||
// ~8.8% longer than the fade the saved instance actually had.
|
||||
const PlayParams stretched = resolvePlay(st, 48000);
|
||||
CHECK(stretched.trigAhd.attackFrames == 480);
|
||||
CHECK(stretched.trigAhd.decayFrames == 960);
|
||||
|
||||
Reference in New Issue
Block a user