FA2: param-domain Gate schematic + 8px node min-sep (all nodes grabbable at defaults); cross-mode drag guard; double-clamp px overflow fix

This commit is contained in:
2026-07-27 19:09:47 -04:00
parent d5d1902ea4
commit 99f377db28
6 changed files with 360 additions and 142 deletions
+87 -31
View File
@@ -5,11 +5,14 @@
// fade/%-length shape at the waveform time base.
//
// Covers: timeToX / levelToY (linear maps, edge clamps, past-end CLAMPED to right-1 — the FA2
// bounds invariant, degenerate area/duration); gateTimedWidth; buildEnvelopePolyline Gate (node
// order, levels, timed-region placement, fixed sustain-plateau reserve, release visible
// in-bounds, overrun pile-up clamped at the right edge, every vertex in-bounds);
// buildEnvelopePolyline Trigger (fade-in/unity/fade-out at fractions of the played span, overlap
// clamp, full-length/zero-fade-out nodes in-bounds at right-1); degenerate flat baseline.
// bounds invariant, no 32-bit overflow on huge times, degenerate area/duration); gateTimedWidth
// + gatePxPerSecond; buildEnvelopePolyline Gate (node order, levels, PARAM-DOMAIN timed-region
// placement independent of sample duration, per-segment kGateNodeSepPx separation — every node
// distinct even at the tier-0 zero-hold/zero-decay defaults, fixed sustain-plateau reserve,
// release visible in-bounds, overrun compressed from the right preserving the minimum gaps,
// every vertex in-bounds); buildEnvelopePolyline Trigger (fade-in/unity/fade-out at fractions of
// the played span, overlap clamp, full-length/zero-fade-out nodes in-bounds at right-1);
// degenerate flat baseline.
#include "../src/vst/envelope_overlay.h"
@@ -53,6 +56,9 @@ static void testTimeToXPastEndClamps() {
const Rect a = wideArea();
CHECK(timeToX(a, 2.0, 3.0) == a.right - 1);
CHECK(timeToX(a, 2.0, 1000.0) == a.right - 1);
// A HUGE t must clamp in double space, not overflow the integer cast (32-bit long on
// Windows would wrap to LONG_MIN and pin to the WRONG edge).
CHECK(timeToX(a, 2.0, 1e15) == a.right - 1);
}
static void testGateTimedWidth() {
@@ -63,6 +69,15 @@ static void testGateTimedWidth() {
CHECK(gateTimedWidth(Rect{0, 0, 1, 10}) == 1);
}
static void testGatePxPerSecond() {
// PARAM-DOMAIN scale: (timedW - 1 - 4*sep) px spread over 4 x kGateStageMaxSeconds. For the
// 1000px canvas: (850 - 1 - 32) / 8.0s = 817/8 px/s. Independent of any sample duration.
const double expected = 817.0 / (4.0 * kGateStageMaxSeconds);
CHECK(gatePxPerSecond(wideArea()) == expected);
CHECK(gatePxPerSecond(Rect{5, 5, 5, 45}) == 0.0); // zero-width area -> 0
CHECK(gatePxPerSecond(Rect{0, 0, 10, 10}) > 0.0); // tiny area: usable floors at 1px, > 0
}
static void testTimeToXDegenerate() {
const Rect a = wideArea();
CHECK(timeToX(a, 0.0, 1.0) == a.left); // no duration -> left
@@ -118,26 +133,55 @@ static void testGateNodeOrderAndLevels() {
}
static void testGateSchematicPlacement() {
// FA2 bounded schematic: timed region = 850px (150px reserved plateau), total 2.0s =>
// 425 px/s in the timed region. attack .2 -> x@85, hold end .3 -> x@round(127.5)=128, decay
// end .6 -> x@255. Plateau is the FIXED 150px reserve -> ReleaseStart x@405. Release .4 ->
// 170px ramp -> ReleaseEnd x@575, well inside the canvas.
// FA2 bounded schematic at the PARAM-DOMAIN scale: timed region = 850px (150px reserved
// plateau), pps = (850-1-32)/8s = 102.125 px/s, each segment prefixed by the 8px separation
// base. attack .2 -> x@round(8+20.425)=28; hold .1 -> x@round(28.425+8+10.2125)=47; decay
// .3 -> x@round(46.6375+8+30.6375)=85; plateau is the FIXED 150px reserve -> ReleaseStart
// x@235; release .4 -> x@round(235.275+8+40.85)=284, well inside the canvas.
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.2;
env.holdSeconds = 0.1; // hold end at 0.3s
env.decaySeconds = 0.3; // decay end at 0.6s
env.holdSeconds = 0.1;
env.decaySeconds = 0.3;
env.sustainLevel = 0.5;
env.releaseSeconds = 0.4;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, a, 2.0);
EnvVertex v;
CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.left + 85);
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.left + 128);
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.left + 255);
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.left + 405);
CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.left + 575);
CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.left + 28);
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.left + 47);
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.left + 85);
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.left + 235);
CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.left + 284);
}
static void testGateLayoutIndependentOfSampleDuration() {
// The Gate schematic is scaled by the PARAM domain, NOT the capture length: the same params
// produce the SAME polyline over a 0.3s and a 10s sample (pre-fix, a 60ms release on a 10s
// capture collapsed to ~5px while 2s stages on a 0.3s capture pinned to the right edge).
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.2;
env.holdSeconds = 0.1;
env.decaySeconds = 0.3;
env.sustainLevel = 0.5;
env.releaseSeconds = 0.06;
const Rect a = wideArea();
CHECK(buildEnvelopePolyline(env, a, 0.3) == buildEnvelopePolyline(env, a, 10.0));
}
static void testGateMinSeparationAtDefaults() {
// THE FA2 headline: at the tier-0 Gate defaults (attack 3ms, hold 0, decay 0, sustain 1.0,
// release 60ms) every consecutive node pair is at least kGateNodeSepPx apart — no node ever
// renders on top of its neighbour, so each is individually grabbable.
const AmpEnvelope env; // struct defaults ARE the tier-0 Gate defaults
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, a, 2.0);
CHECK(poly.size() == 6);
for (size_t i = 1; i < poly.size(); ++i) {
CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx);
}
}
static void testGateSustainPlateauFixedWidth() {
@@ -183,28 +227,32 @@ static void testGateReleaseVisibleInBounds() {
CHECK(rel.level == 0.0);
}
static void testGateOverrunClampsToCanvas() {
// A/H/D/R overrun the visible span (sum 3.5s > 2.0s total): the trailing nodes pile up at
// the last in-bounds column — monotonic, in-bounds, still individually draggable back left.
// NOTHING maps past area.right (the pre-FA2 release tail is gone).
static void testGateOverrunCompressesFromRight() {
// Stages BEYOND the schematic domain (4.0s each > kGateStageMaxSeconds): the layout
// compresses from the right preserving the minimum gaps — ReleaseEnd pins to the last
// in-bounds column, but the trailing nodes stay strictly increasing and individually
// separated (>= kGateNodeSepPx), NOT piled on one pixel. NOTHING maps past area.right.
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 1.0;
env.holdSeconds = 1.0;
env.decaySeconds = 1.0; // decay end at 3.0s -> already past the timed span
env.attackSeconds = 4.0;
env.holdSeconds = 4.0;
env.decaySeconds = 4.0;
env.sustainLevel = 0.7;
env.releaseSeconds = 0.5;
env.releaseSeconds = 4.0;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, a, 2.0);
CHECK(poly.size() == 6);
EnvVertex decay, plateauEnd, rel;
CHECK(findNode(poly, EnvNode::DecayEnd, decay));
EnvVertex plateauEnd, rel;
CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd));
CHECK(findNode(poly, EnvNode::ReleaseEnd, rel));
CHECK(decay.x == a.right - 1); // pinned to the last in-bounds column
CHECK(plateauEnd.x == a.right - 1);
CHECK(rel.x == a.right - 1);
CHECK(rel.x == a.right - 1); // pinned to the last in-bounds column
CHECK(plateauEnd.level == 0.7); // still at sustain
for (size_t i = 1; i < poly.size(); ++i) {
CHECK(poly[i].x > poly[i - 1].x); // strictly monotonic
CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx - 1); // min gaps survive compression
CHECK(poly[i].x >= a.left && poly[i].x < a.right); // in-bounds
}
}
static void testGateAllVerticesInBounds() {
@@ -223,8 +271,13 @@ static void testGateAllVerticesInBounds() {
AmpEnvelope trig = base;
trig.mode = EnvMode::Trigger;
trig.lengthFraction = 1.0; trig.fadeInFraction = 0.0; trig.fadeOutFraction = 0.0;
// 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).
AmpEnvelope huge = base;
huge.mode = EnvMode::Gate;
huge.releaseSeconds = 1e12;
for (const AmpEnvelope& env : {base, big, zero, trig}) {
for (const AmpEnvelope& env : {base, big, zero, trig, huge}) {
for (const EnvVertex& v : buildEnvelopePolyline(env, a, 2.0)) {
CHECK(v.x >= a.left && v.x < a.right);
CHECK(v.y >= a.top && v.y < a.bottom);
@@ -316,14 +369,17 @@ int main() {
testTimeToXPastEndClamps();
testTimeToXDegenerate();
testGateTimedWidth();
testGatePxPerSecond();
testLevelToYEndpoints();
testLevelToYClamps();
testGateNodeOrderAndLevels();
testGateSchematicPlacement();
testGateLayoutIndependentOfSampleDuration();
testGateMinSeparationAtDefaults();
testGateSustainPlateauFixedWidth();
testGateReleaseVisibleInBounds();
testGateOverrunClampsToCanvas();
testGateOverrunCompressesFromRight();
testGateAllVerticesInBounds();
testTriggerShape();