instrument: fix AHD node-tracking/tie-break/live-latch defects and close staged-envelope-curve test gaps

This commit is contained in:
2026-07-31 09:52:17 -04:00
parent d60ab1524a
commit 2fa1405b06
27 changed files with 360 additions and 86 deletions
+60
View File
@@ -97,6 +97,18 @@ static void testDegenerateAreaAndDuration() {
CHECK(gatePxPerSecond(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() {
const double expected =
(1000.0 - 1.0 - 4.0 * kGateNodeSepPx) / (4.0 * kGateStageMaxSeconds); // 967/8 px/s
CHECK(gatePxPerSecond(wideArea()) == expected);
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 AHDSR schematic ------------------------------------------------------
static void testAhdsrNodeOrderAndLevels() {
@@ -122,6 +134,33 @@ 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.
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);
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::ReleaseEnd, v) && v.x == a.x + 999);
}
// 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).
static void testGateLayoutIndependentOfSampleDuration() {
const Rect a = wideArea();
const StageEnvelope e = ahdsr(0.2, 0.1, 0.3, 0.5, 0.06);
CHECK(buildEnvelopePolyline(e, overlayOf(a), 0.3) == buildEnvelopePolyline(e, overlayOf(a), 10.0));
}
// The layout failure this policy exists to fix: at zero release the sustain plateau must run to
// (near) the right edge instead of the figure bunching left.
static void testZeroReleasePutsTheSustainPlateauAtTheRightEdge() {
@@ -176,12 +215,29 @@ static void testMaxedStagesCompressWithoutOverrunning() {
for (std::size_t i = 1; i < 6; ++i) {
CHECK(poly[i].x >= poly[i - 1].x);
CHECK(poly[i].x <= a.right() - 1);
// The compression exists to preserve MINIMUM gaps under overrun, not merely
// non-strict monotonicity — a compression that let two nodes collapse onto one
// pixel would still pass a `>=` check but defeat the whole point of kGateNodeSepPx.
CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx - 1);
}
EnvVertex end;
CHECK(findNode(poly, EnvNode::ReleaseEnd, end));
CHECK(end.x == a.right() - 1);
}
// Absurd stage values must clamp in double space, not overflow the integer cast (32-bit long
// on Windows would wrap negative and land on the WRONG edge) — a regression named for the bug
// it once was. gateVtx's own double-space clamp is what this exercises.
static void testAbsurdReleaseValueStaysInBounds() {
const Rect a = wideArea();
StageEnvelope huge = ahdsr(0.1, 0.1, 0.1, 0.5, 0.1);
huge.releaseSeconds = 1e12;
for (const EnvVertex& v : buildEnvelopePolyline(huge, overlayOf(a), 4.0)) {
CHECK(v.x >= a.x && v.x < a.right());
CHECK(v.y >= a.y && v.y < a.bottom());
}
}
// --- the AHD split ------------------------------------------------------------
// The combined-time bound, asserted structurally across the full domains: no (attack, decay,
@@ -322,12 +378,16 @@ int main() {
testTimeToXClampsBothEnds();
testLevelToY();
testDegenerateAreaAndDuration();
testGatePxPerSecond();
testAhdsrNodeOrderAndLevels();
testAhdsrSchematicPlacement();
testGateLayoutIndependentOfSampleDuration();
testZeroReleasePutsTheSustainPlateauAtTheRightEdge();
testReleaseGrowsLeftwardFromTheAnchor();
testTierZeroDefaultsKeepEveryNodeDistinct();
testMaxedStagesCompressWithoutOverrunning();
testAbsurdReleaseValueStaysInBounds();
testAhdSplitNeverExceedsTheSpan();
testHoldFractionEndpoints();