One taper, one modifier law: extract param_taper, raise the stage ceiling to 10 s, and make the AHDSR schematic axis the taper itself
This commit is contained in:
+140
-23
@@ -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,73 @@ static void testKnotOnANearLevelSegmentIsANoOp() {
|
||||
CHECK(out.decayCurve == 2.5);
|
||||
}
|
||||
|
||||
// --- 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 +460,8 @@ int main() {
|
||||
testFunctionalCoincidentDecayEndStaysGrabbable();
|
||||
testMissOutsideTheRadius();
|
||||
|
||||
testAhdsrStageTimesTrackTheSchematicScale();
|
||||
testAhdsrStageNodesTrackTheCursorAcrossTheWholeRange();
|
||||
testDrawAndDragAreExactInverses();
|
||||
testReleaseDragsFromItsStartWithInvertedSign();
|
||||
testSustainLevelOnTheDecayNodesYAxis();
|
||||
testStageTimesClampToTheKnobDomain();
|
||||
@@ -355,6 +469,9 @@ int main() {
|
||||
testAhdStageTimesTrackTheWallClockScale();
|
||||
testAhdHoldNodeEditsTheFraction();
|
||||
|
||||
testCtrlScalesEveryAxisOfANodeDrag();
|
||||
testShiftSnapsEachAxisToItsOwnWholeUnit();
|
||||
|
||||
testKnotDragMovesTheExponentWithinItsDomain();
|
||||
testKnotAndModelCannotDiverge();
|
||||
testKnotOnALevelSegmentIsANoOp();
|
||||
|
||||
Reference in New Issue
Block a user