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:
2026-08-01 19:09:19 -04:00
parent e589addc54
commit 3eb72d01c4
23 changed files with 1208 additions and 193 deletions
+61 -30
View File
@@ -4,15 +4,18 @@
// RIGHT-ANCHORED release, and the sustain-less AHD laid 1:1 over the waveform's time axis.
//
// Covers: timeToX / levelToY (linear maps, edge clamps, past-end clamped to right-1, no 32-bit
// overflow on huge times, degenerate area/duration); gatePxPerSecond; the AHDSR polyline (node
// order, levels, release anchored at the right edge, the sustain plateau reaching the edge at
// zero release, per-segment separation at the tier-0 defaults, overrun compression, every
// vertex in-bounds); splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the
// AHD polyline (1:1 with the time axis, origin offset); curve knots (present only on sloped
// non-zero segments, height following the exponent); the degenerate flat baseline.
// overflow on huge times, degenerate area/duration); gateStageSlotPx; the AHDSR polyline (node
// order, levels, the TAPERED stage placement and its legibility at both ends of the range,
// release anchored at the right edge, the sustain plateau reaching the edge at zero release,
// per-segment separation at the tier-0 defaults, overrun compression, every vertex in-bounds);
// splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the AHD polyline (1:1 with
// the time axis, origin offset); curve knots (present only on sloped non-zero segments, height
// following the exponent); the degenerate flat baseline.
#include "../src/core/instrument/ui/envelope_overlay.h"
#include "../src/core/instrument/ui/sample_bands.h" // the editor floor the legibility test uses
#include <cmath>
#include <cstdio>
#include <vector>
@@ -94,20 +97,19 @@ static void testDegenerateAreaAndDuration() {
CHECK(timeToX(Rect{}, 2.0, 1.0) == 0);
CHECK(timeToX(wideArea(), 0.0, 1.0) == wideArea().x);
CHECK(levelToY(Rect{}, 0.5) == 0);
CHECK(gatePxPerSecond(Rect{}) == 0.0);
CHECK(gateStageSlotPx(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() {
// 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
// The literal slot width, independent of any sample duration: usable px = canvas width minus the
// last column minus 4 node-separation bases, split four ways. A stage then occupies its own
// TAPERED fraction of that slot, which is what makes a dragged handle track the cursor at both
// ends of the range — a scale regression here is exactly what a relational-only check misses.
static void testGateStageSlotPx() {
// 967 / 4 px, pinned as a literal — restating the formula with the same named constants
// would let a change to kGateNodeSepPx move both sides and pass silently.
CHECK(gateStageSlotPx(wideArea()) == 241.75);
CHECK(gateStageSlotPx(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0
CHECK(gateStageSlotPx(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0
}
// --- the AHDSR schematic ------------------------------------------------------
@@ -135,24 +137,52 @@ 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.
// The literal per-node x placement, hand-derived from the documented formula (slot = 241.75 px
// per testGateStageSlotPx; each timed stage is prefixed by the kGateNodeSepPx=8 base and occupies
// slot x timeNormFromSeconds(t) of its own slot; L = ln(1 + 10/0.003) = 8.112028):
// attack .25s -> norm ln(84.3333)/L = 0.546677 -> 8 + 132.159 = 140.159 -> px 140
// hold .05s -> norm ln(17.6667)/L = 0.354007 -> 140.159 + 8 + 85.581 = 233.740 -> px 234
// decay .5s -> norm ln(167.667)/L = 0.631385 -> 233.740 + 8 + 152.637 = 394.377 -> px 394
// plateau 1s -> norm ln(334.333)/L = 0.716493 -> 999 - 8 - 173.211 = 817.789 -> px 818
// 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);
buildEnvelopePolyline(ahdsr(0.25, 0.05, 0.5, 0.5, 1.0), 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::AttackEnd, v) && v.x == a.x + 140);
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 234);
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 394);
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 818);
CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 999);
}
// The legibility the tapered axis exists for, at BOTH ends of the raised range. Linear-in-seconds
// put the 3 ms default attack 0.07 px from the origin at a 10 s ceiling — indistinguishable from
// zero and impossible to grab. Asserted at the editor's own floor width, not a comfortable one.
static void testTaperedAxisKeepsBothEndsOfTheRangeLegible() {
const Rect floorArea = Rect::ltrb(0, 0, kEditorMinWidth - 2 * kPad, 100);
const std::vector<EnvVertex> poly =
buildEnvelopePolyline(ahdsr(0.003, 0.0, 0.0, 1.0, 0.060), overlayOf(floorArea), 4.0);
EnvVertex origin, attack;
CHECK(findNode(poly, EnvNode::Origin, origin));
CHECK(findNode(poly, EnvNode::AttackEnd, attack));
// Well clear of the grab radius, so the default attack is a real handle rather than a node
// sitting on the origin.
CHECK(attack.x - origin.x >= 20);
// And a maxed stage still lands its end node at its slot's edge: the taper's norm-1 end and
// the schematic's canvas edge are the same place, which is the anchor the policy rests on.
const std::vector<EnvVertex> maxed = buildEnvelopePolyline(
ahdsr(kGateStageMaxSeconds, 0.0, 0.0, 1.0, 0.0), overlayOf(floorArea), 4.0);
EnvVertex maxAttack;
CHECK(findNode(maxed, EnvNode::AttackEnd, maxAttack));
const double slot = gateStageSlotPx(floorArea);
CHECK(maxAttack.x == floorArea.x + static_cast<int>(kGateNodeSepPx + slot + 0.5));
}
// 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).
@@ -409,10 +439,11 @@ int main() {
testTimeToXClampsBothEnds();
testLevelToY();
testDegenerateAreaAndDuration();
testGatePxPerSecond();
testGateStageSlotPx();
testAhdsrNodeOrderAndLevels();
testAhdsrSchematicPlacement();
testTaperedAxisKeepsBothEndsOfTheRangeLegible();
testGateLayoutIndependentOfSampleDuration();
testZeroReleasePutsTheSustainPlateauAtTheRightEdge();
testReleaseGrowsLeftwardFromTheAnchor();