334 lines
14 KiB
C++
334 lines
14 KiB
C++
// Standalone tests for reasampler::instrument::ui::envelope_edit — no VST3, no REAPER, no
|
|
// framework. Same fast assert loop as the sibling pure tests. Assert the INVERSE (edit) map
|
|
// against envelope_overlay's forward map: a grab lands on the node that was drawn there, and a
|
|
// 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
|
|
// (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.
|
|
|
|
#include "../src/core/instrument/ui/envelope_edit.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <vector>
|
|
|
|
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 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;
|
|
|
|
static EnvClampBounds bounds() {
|
|
EnvClampBounds b;
|
|
b.maxAttackSeconds = 2.0;
|
|
b.maxHoldSeconds = 2.0;
|
|
b.maxDecaySeconds = 2.0;
|
|
b.maxReleaseSeconds = 2.0;
|
|
return b;
|
|
}
|
|
|
|
static StageEnvelope ahdsrEnv() {
|
|
StageEnvelope e;
|
|
e.kind = EnvKind::Ahdsr;
|
|
e.attackSeconds = 0.3;
|
|
e.holdSeconds = 0.2;
|
|
e.decaySeconds = 0.4;
|
|
e.sustainLevel = 0.6;
|
|
e.releaseSeconds = 0.5;
|
|
return e;
|
|
}
|
|
|
|
static StageEnvelope ahdEnv() {
|
|
StageEnvelope e;
|
|
e.kind = EnvKind::Ahd;
|
|
e.attackSeconds = 0.4;
|
|
e.decaySeconds = 0.6;
|
|
e.holdFraction = 0.5;
|
|
e.originSeconds = 0.0;
|
|
e.spanSeconds = 3.0;
|
|
return e;
|
|
}
|
|
|
|
static bool findNode(const std::vector<EnvVertex>& poly, EnvNode node, EnvVertex& out) {
|
|
for (const EnvVertex& v : poly) {
|
|
if (v.node == node) { out = v; return true; }
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Grab exactly where the forward map drew the node.
|
|
static NodeHit grabAt(const StageEnvelope& e, EnvNode node) {
|
|
const Rect a = wideArea();
|
|
EnvVertex v;
|
|
if (!findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), node, v)) return NodeHit{};
|
|
return nodeAtPoint(e, overlayOf(a), kTotal, v.x, v.y);
|
|
}
|
|
|
|
// --- hit-test ------------------------------------------------------------------
|
|
|
|
static void testEveryDrawnHandleIsGrabbable() {
|
|
const StageEnvelope e = ahdsrEnv();
|
|
const EnvNode want[] = {EnvNode::AttackEnd, EnvNode::HoldEnd, EnvNode::DecayEnd,
|
|
EnvNode::ReleaseStart, EnvNode::AttackCurve, EnvNode::DecayCurve,
|
|
EnvNode::ReleaseCurve};
|
|
for (EnvNode n : want) {
|
|
const NodeHit h = grabAt(e, n);
|
|
CHECK(h.hit);
|
|
CHECK(h.node == n);
|
|
}
|
|
}
|
|
|
|
static void testAnchoredEndAndOriginAreNotGrabbable() {
|
|
const StageEnvelope e = ahdsrEnv();
|
|
const Rect a = wideArea();
|
|
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), kTotal);
|
|
EnvVertex end;
|
|
CHECK(findNode(poly, EnvNode::ReleaseEnd, end));
|
|
// The bottom-right corner is fixed: a grab there either misses or resolves to a NEIGHBOUR,
|
|
// never to ReleaseEnd itself.
|
|
const NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, end.x, end.y);
|
|
CHECK(!h.hit || h.node != EnvNode::ReleaseEnd);
|
|
EnvVertex origin;
|
|
CHECK(findNode(poly, EnvNode::Origin, origin));
|
|
const NodeHit o = nodeAtPoint(e, overlayOf(a), kTotal, origin.x, origin.y);
|
|
CHECK(!o.hit || o.node != EnvNode::Origin);
|
|
}
|
|
|
|
static void testAhdHasNoSustainNodes() {
|
|
const StageEnvelope e = ahdEnv();
|
|
CHECK(grabAt(e, EnvNode::AttackEnd).hit);
|
|
CHECK(grabAt(e, EnvNode::HoldEnd).hit);
|
|
CHECK(grabAt(e, EnvNode::DecayEnd).hit);
|
|
// ReleaseStart is not drawn on an AHD at all, so there is nothing to grab.
|
|
CHECK(!grabAt(e, EnvNode::ReleaseStart).hit);
|
|
// And an explicit resolve of an other-kind node is a no-op rather than a stray write.
|
|
const StageEnvelope out = resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(wideArea()),
|
|
kTotal, bounds(), 40, 0);
|
|
CHECK(out.releaseSeconds == e.releaseSeconds);
|
|
CHECK(out.attackSeconds == e.attackSeconds);
|
|
}
|
|
|
|
static void testMissOutsideTheRadius() {
|
|
const StageEnvelope e = ahdsrEnv();
|
|
const Rect a = wideArea();
|
|
// Far from every handle in both axes.
|
|
const NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 3, a.bottom() - 40);
|
|
CHECK(!h.hit);
|
|
}
|
|
|
|
// --- AHDSR drags ---------------------------------------------------------------
|
|
|
|
static void testAhdsrStageTimesTrackTheSchematicScale() {
|
|
const Rect a = wideArea();
|
|
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);
|
|
|
|
const StageEnvelope decay =
|
|
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 30, 0);
|
|
CHECK(std::fabs(decay.decaySeconds - (e.decaySeconds + 30 * secPerPx)) < 1e-9);
|
|
}
|
|
|
|
// The release is dragged from its TOP node and its end is anchored to the canvas edge, so
|
|
// pulling that node LEFT lengthens the release — the sign is inverted relative to every other
|
|
// stage.
|
|
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);
|
|
const StageEnvelope shorter =
|
|
resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), 40, 0);
|
|
CHECK(shorter.releaseSeconds < e.releaseSeconds);
|
|
}
|
|
|
|
static void testSustainLevelOnTheDecayNodesYAxis() {
|
|
const Rect a = wideArea();
|
|
const StageEnvelope e = ahdsrEnv();
|
|
const double lvlPerPx = 1.0 / (a.height - 1);
|
|
const StageEnvelope up =
|
|
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -10);
|
|
CHECK(std::fabs(up.sustainLevel - (e.sustainLevel + 10 * lvlPerPx)) < 1e-9);
|
|
// Clamped to [0,1] at both ends.
|
|
CHECK(resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -10000)
|
|
.sustainLevel == 1.0);
|
|
CHECK(resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, 10000)
|
|
.sustainLevel == 0.0);
|
|
}
|
|
|
|
static void testStageTimesClampToTheKnobDomain() {
|
|
const Rect a = wideArea();
|
|
const StageEnvelope e = ahdsrEnv();
|
|
CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 100000, 0)
|
|
.attackSeconds == bounds().maxAttackSeconds);
|
|
CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), -100000, 0)
|
|
.attackSeconds == 0.0);
|
|
}
|
|
|
|
// --- AHD drags -----------------------------------------------------------------
|
|
|
|
static void testAhdStageTimesTrackTheWallClockScale() {
|
|
const Rect a = wideArea();
|
|
const StageEnvelope e = ahdEnv();
|
|
const double secPerPx = kTotal / a.width;
|
|
const StageEnvelope attack =
|
|
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 100, 0);
|
|
CHECK(std::fabs(attack.attackSeconds - (e.attackSeconds + 100 * secPerPx)) < 1e-9);
|
|
|
|
// DecayEnd's underlying param (decaySeconds) does NOT move 1:1 with the cursor: the drawn
|
|
// endpoint is t0 + total, and Hold eats a holdFraction share of whatever decay gives up
|
|
// (d(total)/d(decay) = 1 - holdFraction), so decaySeconds itself has to move faster than
|
|
// the cursor to make the DRAWN node track it. Assert on the RENDERED position, not the
|
|
// raw param — that is the property a drag actually has to deliver, and asserting the old
|
|
// 1:1 param delta here is exactly what let the node-tracking defect through undetected.
|
|
EnvVertex before;
|
|
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), EnvNode::DecayEnd, before));
|
|
const StageEnvelope decay =
|
|
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 100, 0);
|
|
EnvVertex after;
|
|
CHECK(findNode(buildEnvelopePolyline(decay, overlayOf(a), kTotal), EnvNode::DecayEnd, after));
|
|
CHECK(std::abs((after.x - before.x) - 100) <= 1); // 1:1 with the cursor, to rounding
|
|
}
|
|
|
|
// Hold is a fraction of what attack and decay left, so the node's pixel motion converts through
|
|
// that remainder — and the fraction can never leave [0,1], which is what keeps the sum bounded.
|
|
static void testAhdHoldNodeEditsTheFraction() {
|
|
const Rect a = wideArea();
|
|
const StageEnvelope e = ahdEnv();
|
|
const double secPerPx = kTotal / a.width;
|
|
const AhdSplit s = splitAhdSeconds(e);
|
|
const double rem = e.spanSeconds - s.attack - s.decay;
|
|
const StageEnvelope moved =
|
|
resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), 100, 0);
|
|
CHECK(std::fabs(moved.holdFraction - ((s.hold + 100 * secPerPx) / rem)) < 1e-9);
|
|
CHECK(resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), 100000, 0)
|
|
.holdFraction == 1.0);
|
|
CHECK(resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), -100000, 0)
|
|
.holdFraction == 0.0);
|
|
}
|
|
|
|
// --- curve knots ---------------------------------------------------------------
|
|
|
|
static void testKnotDragMovesTheExponentWithinItsDomain() {
|
|
const Rect a = wideArea();
|
|
StageEnvelope e = ahdsrEnv();
|
|
e.attackCurve = util::kCurveNeutral;
|
|
const StageEnvelope up =
|
|
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, -12);
|
|
const StageEnvelope down =
|
|
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 12);
|
|
// Dragging the attack knot UP (toward the ceiling) is a faster-rising, SMALLER exponent.
|
|
CHECK(up.attackCurve < util::kCurveNeutral);
|
|
CHECK(down.attackCurve > util::kCurveNeutral);
|
|
CHECK(up.attackCurve >= util::kCurveMin && up.attackCurve <= util::kCurveMax);
|
|
CHECK(down.attackCurve >= util::kCurveMin && down.attackCurve <= util::kCurveMax);
|
|
// Extreme drags saturate at the domain endpoints rather than escaping them.
|
|
CHECK(resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, -100000)
|
|
.attackCurve == util::kCurveMin);
|
|
CHECK(resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 100000)
|
|
.attackCurve == util::kCurveMax);
|
|
// Only the dragged segment's exponent moves.
|
|
CHECK(up.decayCurve == e.decayCurve && up.releaseCurve == e.releaseCurve);
|
|
CHECK(up.attackSeconds == e.attackSeconds);
|
|
}
|
|
|
|
// The one-model rule, asserted structurally: the drawn knot's height IS the shared law's
|
|
// reading of the stored exponent, and a zero-delta drag from that grab reproduces the exponent
|
|
// exactly — so the overlay and the inner dial cannot express different values for one field.
|
|
static void testKnotAndModelCannotDiverge() {
|
|
const Rect a = wideArea();
|
|
for (double exp : {0.2, 0.5, 1.0, 2.0, 7.0}) {
|
|
StageEnvelope e = ahdsrEnv();
|
|
e.attackCurve = exp;
|
|
EnvVertex knot;
|
|
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), EnvNode::AttackCurve,
|
|
knot));
|
|
CHECK(std::fabs(knot.level - util::curveMidLevel(exp)) < 1e-12);
|
|
const StageEnvelope same =
|
|
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 0);
|
|
CHECK(std::fabs(same.attackCurve - exp) < 1e-9);
|
|
}
|
|
}
|
|
|
|
// A decay into a sustain of exactly 1.0 is a LEVEL segment: there is no curve to express, so
|
|
// the drag must leave the exponent alone rather than divide by a zero level span.
|
|
static void testKnotOnALevelSegmentIsANoOp() {
|
|
const Rect a = wideArea();
|
|
StageEnvelope e = ahdsrEnv();
|
|
e.sustainLevel = 1.0;
|
|
e.decayCurve = 2.5;
|
|
const StageEnvelope out =
|
|
resolveNodeDrag(e, EnvNode::DecayCurve, overlayOf(a), kTotal, bounds(), 0, -30);
|
|
CHECK(out.decayCurve == 2.5);
|
|
}
|
|
|
|
// A NEAR-level segment (sustain 0.99) is not caught by the exact-equality guard above, but its
|
|
// tiny divisor turns a one-pixel drag into a saturating swing of the exponent — the drag must
|
|
// still be a no-op rather than slam to a domain endpoint.
|
|
static void testKnotOnANearLevelSegmentIsANoOp() {
|
|
const Rect a = wideArea();
|
|
StageEnvelope e = ahdsrEnv();
|
|
e.sustainLevel = 0.99;
|
|
e.decayCurve = 2.5;
|
|
const StageEnvelope out =
|
|
resolveNodeDrag(e, EnvNode::DecayCurve, overlayOf(a), kTotal, bounds(), 0, -1);
|
|
CHECK(out.decayCurve == 2.5);
|
|
}
|
|
|
|
// --- degenerate ----------------------------------------------------------------
|
|
|
|
static void testDegenerateInputsAreNoOps() {
|
|
const StageEnvelope e = ahdsrEnv();
|
|
const StageEnvelope zeroArea =
|
|
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(Rect{}), kTotal, bounds(), 50, 0);
|
|
CHECK(zeroArea.attackSeconds == e.attackSeconds);
|
|
const StageEnvelope zeroDur =
|
|
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(wideArea()), 0.0, bounds(), 50, 0);
|
|
CHECK(zeroDur.attackSeconds == e.attackSeconds);
|
|
}
|
|
|
|
int main() {
|
|
testEveryDrawnHandleIsGrabbable();
|
|
testAnchoredEndAndOriginAreNotGrabbable();
|
|
testAhdHasNoSustainNodes();
|
|
testMissOutsideTheRadius();
|
|
|
|
testAhdsrStageTimesTrackTheSchematicScale();
|
|
testReleaseDragsFromItsStartWithInvertedSign();
|
|
testSustainLevelOnTheDecayNodesYAxis();
|
|
testStageTimesClampToTheKnobDomain();
|
|
|
|
testAhdStageTimesTrackTheWallClockScale();
|
|
testAhdHoldNodeEditsTheFraction();
|
|
|
|
testKnotDragMovesTheExponentWithinItsDomain();
|
|
testKnotAndModelCannotDiverge();
|
|
testKnotOnALevelSegmentIsANoOp();
|
|
testKnotOnANearLevelSegmentIsANoOp();
|
|
|
|
testDegenerateInputsAreNoOps();
|
|
|
|
if (g_fail == 0) std::printf("envelope_edit: all tests passed\n");
|
|
else std::printf("envelope_edit: %d FAILED\n", g_fail);
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|