instrument: one staged-envelope system — per-segment curves, the sustain-less AHD, and a shared overlay for all three envelopes

Trigger's fade pair folds into the AHD (and goes live); the release anchors right;
Preserve rings its synthetic tail out instead of cutting it. Payload v10.
This commit is contained in:
2026-07-31 08:37:57 -04:00
parent 87d7ceb066
commit 13e8c5c4d9
51 changed files with 3406 additions and 1812 deletions
+107 -28
View File
@@ -8,6 +8,7 @@
#include "../src/core/instrument/map/component_state_io.h"
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
#include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral)
#include <cstdio>
#include <cstring>
@@ -285,12 +286,17 @@ static void testComponentStateRoundTrip() {
in.params.play.adsr.sustainLevel = 0.8;
in.params.play.adsr.releaseSeconds = 0.15;
in.params.play.trigger.lengthFraction = 0.75;
in.params.play.trigger.fadeInFrames = 441;
in.params.play.trigger.fadeOutFrames = 882;
in.params.play.trigAhd = AhdSeconds{0.011, 0.022, 0.65, 2.5, 0.4};
in.params.play.adsr.attackCurve = 3.0;
in.params.play.adsr.decayCurve = 0.3;
in.params.play.adsr.releaseCurve = 6.0;
in.params.play.filter.env.attackCurve = 1.25;
in.params.play.filter.env.decayCurve = 0.75;
in.params.play.filter.env.releaseCurve = 8.0;
in.params.play.filter.trigEnv = AhdSeconds{0.033, 0.044, 0.15, 0.2, 9.0};
in.params.play.pitchEngine = PitchEngine::Preserve;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.attackSeconds = 0.02;
in.params.play.pitchEnv.decaySeconds = 0.03;
in.params.play.pitchEnv.shape = AhdSeconds{0.02, 0.03, 0.45, 1.5, 0.6};
in.params.play.pitchEnv.peakSemitones = 5.0;
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
@@ -331,12 +337,31 @@ static void testComponentStateRoundTrip() {
CHECK(p.play.adsr.sustainLevel == 0.8);
CHECK(p.play.adsr.releaseSeconds == 0.15);
CHECK(p.play.trigger.lengthFraction == 0.75);
CHECK(p.play.trigger.fadeInFrames == 441);
CHECK(p.play.trigger.fadeOutFrames == 882);
// Every curve exponent, hold fraction and Trigger AHD field survives the round trip
// EXACTLY — the tail is doubles all the way down, so nothing quantizes.
CHECK(p.play.adsr.attackCurve == 3.0);
CHECK(p.play.adsr.decayCurve == 0.3);
CHECK(p.play.adsr.releaseCurve == 6.0);
CHECK(p.play.trigAhd.attackSeconds == 0.011);
CHECK(p.play.trigAhd.decaySeconds == 0.022);
CHECK(p.play.trigAhd.holdFraction == 0.65);
CHECK(p.play.trigAhd.attackCurve == 2.5);
CHECK(p.play.trigAhd.decayCurve == 0.4);
CHECK(p.play.filter.env.attackCurve == 1.25);
CHECK(p.play.filter.env.decayCurve == 0.75);
CHECK(p.play.filter.env.releaseCurve == 8.0);
CHECK(p.play.filter.trigEnv.attackSeconds == 0.033);
CHECK(p.play.filter.trigEnv.decaySeconds == 0.044);
CHECK(p.play.filter.trigEnv.holdFraction == 0.15);
CHECK(p.play.filter.trigEnv.attackCurve == 0.2);
CHECK(p.play.filter.trigEnv.decayCurve == 9.0);
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
CHECK(p.play.pitchEnv.enabled);
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.shape.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.shape.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.shape.holdFraction == 0.45);
CHECK(p.play.pitchEnv.shape.attackCurve == 1.5);
CHECK(p.play.pitchEnv.shape.decayCurve == 0.6);
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
}
@@ -401,12 +426,10 @@ static void testGoldenFullBlobFixture() {
in.params.play.adsr.sustainLevel = 0.8;
in.params.play.adsr.releaseSeconds = 0.15;
in.params.play.trigger.lengthFraction = 0.75;
in.params.play.trigger.fadeInFrames = 100;
in.params.play.trigger.fadeOutFrames = 200;
in.params.play.pitchEngine = PitchEngine::Preserve;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.attackSeconds = 0.02;
in.params.play.pitchEnv.decaySeconds = 0.03;
in.params.play.pitchEnv.shape.attackSeconds = 0.02;
in.params.play.pitchEnv.shape.decaySeconds = 0.03;
in.params.play.pitchEnv.peakSemitones = 5.0;
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
@@ -423,11 +446,11 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x09,0x00,0x00,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0a,0x00,0x00,
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00,0x00,0x00,0x00,0x00,
0xe8,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0xb8,0x1e,0x85,0xeb,
0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,0x7b,0x14,0xae,0x47,
0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0x9a,0x99,0x99,0x99,
@@ -457,6 +480,27 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // amp 0.0
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp 1.0
// --- payload v10 staged-curve tail, at its NEUTRAL default (this fixture sets no
// curve or AHD field), in the header's documented order ---
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp attack curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp decay curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp release curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // trig AHD attack 0.0 s
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // trig AHD decay 0.0 s
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD hold 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD att curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD dec curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // pitch hold 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // pitch attack curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // pitch decay curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt attack curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt decay curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt release curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // filt AHD attack 0.0 s
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // filt AHD decay 0.0 s
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD hold 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD att curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec curve 1.0
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
@@ -504,12 +548,13 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 9);
CHECK(kParamsPayloadVersion == 10);
CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// The filter tail rode a PAYLOAD bump, not an envelope one — the two axes stay
// independent, so a future envelope field cannot collide with it on one number.
// The filter and staged-curve tails rode PAYLOAD bumps, not envelope ones — the two axes
// stay independent, so a future envelope field cannot collide with either on one number.
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
CHECK(kParamsCurveVersion > kParamsFilterVersion);
}
// --- The filter tail (payload v9) --------------------------------------------
@@ -707,13 +752,46 @@ static void testSingleZoneMigrationIsLossless() {
CHECK(p.play.adsr.sustainLevel == 0.8);
CHECK(p.play.adsr.releaseSeconds == 0.15);
CHECK(p.play.trigger.lengthFraction == 0.75);
CHECK(p.play.trigger.fadeInFrames == 100);
CHECK(p.play.trigger.fadeOutFrames == 200);
// The retired fade pair lifts onto the AHD that replaced it: attack <- fade-in, decay <-
// fade-out (source frames over the project rate), hold <- the whole remainder.
CHECK(p.play.trigAhd.attackSeconds == 100.0 / 48000.0);
CHECK(p.play.trigAhd.decaySeconds == 200.0 / 48000.0);
CHECK(p.play.trigAhd.holdFraction == 1.0);
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
CHECK(p.play.pitchEnv.enabled);
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.shape.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.shape.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
// Everything the change added lifts to its own neutral, so the loaded instance plays as
// the saved one did: every exponent linear, and the pitch envelope with no hold stage.
CHECK(p.play.adsr.attackCurve == util::kCurveNeutral);
CHECK(p.play.adsr.decayCurve == util::kCurveNeutral);
CHECK(p.play.adsr.releaseCurve == util::kCurveNeutral);
CHECK(p.play.trigAhd.attackCurve == util::kCurveNeutral);
CHECK(p.play.trigAhd.decayCurve == util::kCurveNeutral);
CHECK(p.play.pitchEnv.shape.holdFraction == 0.0);
CHECK(p.play.filter.env.attackCurve == util::kCurveNeutral);
}
// A prior ZERO fade-out lands Decay = 0: the abrupt end an old Trigger instance could express
// stays representable under the AHD, which is what makes the consolidation lossless rather
// than merely close.
static void testZeroFadeOutMigratesToZeroDecay() {
legacy::Zone z;
z.sampleId = "kick";
z.lowNote = 0;
z.highNote = 127;
z.trigger = true;
z.lengthFraction = 1.0;
z.fadeIn = 441;
z.fadeOut = 0;
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 44100.0);
const PlaySeconds& play = out.params.play;
CHECK(play.playMode == PlayMode::Trigger);
CHECK(play.trigAhd.attackSeconds == 441.0 / 44100.0);
CHECK(play.trigAhd.decaySeconds == 0.0);
CHECK(play.trigAhd.holdFraction == 1.0);
}
// A legacy OVERRIDE THAT DISABLES THE LOOP migrates as a PRESENT loopOverride with hasLoop
@@ -872,8 +950,8 @@ static void testLegacyV3FramesConvertAtTheProjectRate() {
const ComponentState st = deserializeComponentState(out, 48000.0);
CHECK(st.selectionId == "kick");
CHECK(st.params.play.adsr.holdSeconds == 0.05);
CHECK(st.params.play.pitchEnv.attackSeconds == 0.02);
CHECK(st.params.play.pitchEnv.decaySeconds == 0.03);
CHECK(st.params.play.pitchEnv.shape.attackSeconds == 0.02);
CHECK(st.params.play.pitchEnv.shape.decaySeconds == 0.03);
CHECK(st.params.play.pitchEnv.peakSemitones == 5.0);
// A/D/S/R are absent in v3 -> the tier-0 seconds defaults hold.
CHECK(st.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
@@ -1001,11 +1079,11 @@ static void testSampleRefsTruncatedMidEntry() {
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
// v9 filter tail) = 292 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4, loop
// 1+8+8, channels4, name 4+0). Cutting 312 keeps the first 27 of entry two's 47 — mid
// loop.start (offset 23..31).
CHECK(bytes.size() > 312);
bytes.resize(bytes.size() - 312);
// v9 filter tail + the 160-byte v10 staged-curve tail) = 452 bytes; entry two is 47 bytes
// (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 472 keeps the first
// 27 of entry two's 47 — mid loop.start (offset 23..31).
CHECK(bytes.size() > 472);
bytes.resize(bytes.size() - 472);
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.sampleRefs.size() == 1);
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
@@ -1087,6 +1165,7 @@ int main() {
testEnvelopePrefixBytesFrozen();
testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless();
testZeroFadeOutMigratesToZeroDecay();
testSingleZoneMigrationLiftsLoopDisablingOverride();
testLiftedStateReSavesInCurrentFormat();
testMultiZoneMigrationAdoptsFirstZone();
+120
View File
@@ -0,0 +1,120 @@
// Standalone tests for reasampler::util::curve_law — no VST3, no REAPER, no framework. Same
// fast assert loop as the sibling pure tests. This is the ONE law behind the engine's segment
// evaluator, the overlay's knot geometry, and the deck's inner dial, so what it guarantees is
// what all three inherit.
//
// Covers: the LINEAR NEUTRAL (exponent 1.0 returns its input BIT-IDENTICALLY, which is what
// makes a pre-existing instance play unchanged); endpoint exactness at every exponent (no
// segment can overshoot its own endpoint levels); monotonicity and finiteness across the full
// 0.1..10 domain including both endpoints; the mid-level inverse the overlay knot drags
// through, and its round trip against the exponent.
#include "../src/core/util/curve_law.h"
#include <cmath>
#include <cstdio>
using namespace reasampler::util;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// The neutral is not merely "close to linear" — it must be the identity, bit for bit, or a
// blob that loaded at 1.0 would render differently from the engine that wrote it.
static void testNeutralExponentIsTheIdentity() {
for (int i = 0; i <= 1000; ++i) {
const double phi = static_cast<double>(i) / 1000.0;
CHECK(curveMap(phi, kCurveNeutral) == phi);
}
// Including the values a fractional stage position actually takes.
CHECK(curveMap(1.0 / 3.0, 1.0) == 1.0 / 3.0);
CHECK(curveMap(0.1234567890123, 1.0) == 0.1234567890123);
}
// Both endpoints are exact at every exponent, which is the whole overshoot guarantee: a curved
// stage starts where the previous one ended and ends where the next one starts.
static void testEndpointsAreExactAtEveryExponent() {
for (int i = 0; i <= 100; ++i) {
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 100.0);
CHECK(curveMap(0.0, e) == 0.0);
CHECK(curveMap(1.0, e) == 1.0);
}
}
// The full domain, both endpoints included: finite, in range, and strictly rising.
static void testSweepIsFiniteMonotoneAndInRange() {
const double exps[] = {kCurveMin, 0.25, 0.5, kCurveNeutral, 2.0, 4.0, kCurveMax};
for (double e : exps) {
double prev = -1.0;
for (int i = 0; i <= 500; ++i) {
const double phi = static_cast<double>(i) / 500.0;
const double v = curveMap(phi, e);
CHECK(std::isfinite(v));
CHECK(v >= 0.0 && v <= 1.0);
CHECK(v > prev - 1e-15); // non-decreasing
prev = v;
}
CHECK(std::fabs(prev - 1.0) < 1e-12);
}
}
// Which side of the neutral an exponent falls on is the SHAPE, and the two directions must not
// collapse into each other.
static void testExponentDirectionShapesTheSegment() {
CHECK(curveMap(0.5, 4.0) < curveMap(0.5, kCurveNeutral));
CHECK(curveMap(0.5, 0.25) > curveMap(0.5, kCurveNeutral));
CHECK(std::fabs(curveMap(0.5, kCurveNeutral) - 0.5) < 1e-15);
}
static void testClampCurveHoldsTheDomain() {
CHECK(clampCurve(-5.0) == kCurveMin);
CHECK(clampCurve(0.0) == kCurveMin);
CHECK(clampCurve(1e9) == kCurveMax);
CHECK(clampCurve(std::nan("")) == kCurveMin); // a corrupt blob degrades, never propagates
CHECK(clampCurve(2.5) == 2.5);
}
// The mid-level inverse is what a knot drag resolves through: it must be the exact inverse of
// the forward reading over the whole domain, or the knot and the dial could drift.
static void testMidLevelRoundTripsAgainstTheExponent() {
for (int i = 0; i <= 200; ++i) {
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 200.0);
const double mid = curveMidLevel(e);
CHECK(mid > 0.0 && mid < 1.0);
CHECK(std::fabs(curveFromMidLevel(mid) - e) < 1e-9);
}
// The mid-level is strictly DECREASING in the exponent, so a drag has one unambiguous
// direction at every point of the domain.
double prev = 1.0;
for (int i = 0; i <= 200; ++i) {
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 200.0);
const double mid = curveMidLevel(e);
CHECK(mid < prev);
prev = mid;
}
}
// A knot dragged past what the domain can express saturates rather than producing a
// non-finite exponent.
static void testMidLevelInverseSaturates() {
CHECK(curveFromMidLevel(0.0) == kCurveMax);
CHECK(curveFromMidLevel(-1.0) == kCurveMax);
CHECK(curveFromMidLevel(1.0) == kCurveMin);
CHECK(curveFromMidLevel(5.0) == kCurveMin);
CHECK(curveFromMidLevel(std::nan("")) == kCurveMax);
CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12);
}
int main() {
testNeutralExponentIsTheIdentity();
testEndpointsAreExactAtEveryExponent();
testSweepIsFiniteMonotoneAndInRange();
testExponentDirectionShapesTheSegment();
testClampCurveHoldsTheDomain();
testMidLevelRoundTripsAgainstTheExponent();
testMidLevelInverseSaturates();
if (g_fail == 0) std::printf("curve_law: all tests passed\n");
else std::printf("curve_law: %d FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}
+117 -25
View File
@@ -78,6 +78,89 @@ static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() {
CHECK(fe.rowToggle.id == -1);
}
// Exactly the three envelope decks carry an overlay-select radio, each its own, and no other
// group has one — the exclusivity the shell enforces is only meaningful if the id space is.
static void testOnlyTheThreeEnvelopeDecksCarryARadio() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
int radios = 0;
for (const DeckGroupDesc& d : g) {
if (d.captionRadio.id < 0) continue;
++radios;
const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect)
: d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect)
: d.id == kGroupFilterEnv ? cell(DeckParam::kFilterEnvSelect)
: -1;
CHECK(d.captionRadio.id == want);
}
CHECK(radios == 3);
}
}
// The mode-driven shape switch, on BOTH the amp and the filter envelope: Gate shows the
// AHDSR's five stages, Trigger the AHD's three (behind the play span on the amp deck), and
// neither mode leaks the other's controls onto the deck.
static void testGateAndTriggerFacesCarryTheirOwnShapes() {
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
const DeckGroupDesc& gAmp = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupAmpEnv))];
const DeckGroupDesc& tAmp = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
const std::vector<int> gateAmp = {cell(DeckParam::kAttack), cell(DeckParam::kHold),
cell(DeckParam::kDecay), cell(DeckParam::kSustain),
cell(DeckParam::kRelease)};
const std::vector<int> trigAmp = {cell(DeckParam::kTrigLength), cell(DeckParam::kTrigAttack),
cell(DeckParam::kTrigHold), cell(DeckParam::kTrigDecay),
-1};
CHECK(gAmp.cellIds == gateAmp);
CHECK(tAmp.cellIds == trigAmp);
const DeckGroupDesc& gFe = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupFilterEnv))];
const DeckGroupDesc& tFe = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupFilterEnv))];
const std::vector<int> trigFe = {cell(DeckParam::kFilterTrigAttack),
cell(DeckParam::kFilterTrigHold),
cell(DeckParam::kFilterTrigDecay), -1, -1};
CHECK(tFe.cellIds == trigFe);
CHECK(gFe.cellIds != tFe.cellIds);
// Same cell count either way, so the group's width — and its neighbours' placement —
// survives a mode flip.
CHECK(gFe.cellIds.size() == tFe.cellIds.size());
CHECK(deckGroupWidth(gFe) == deckGroupWidth(tFe));
}
// Every SLOPED stage knob carries an inner curve dial; Hold, Sustain, and everything that is
// not a stage carries none. This is the "which segments are sloped" rule, asserted rather than
// read.
static void testOnlySlopedStageKnobsCarryAnInnerCurveDial() {
const DeckParam sloped[] = {
DeckParam::kAttack, DeckParam::kDecay, DeckParam::kRelease,
DeckParam::kTrigAttack, DeckParam::kTrigDecay,
DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay,
DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvDecay, DeckParam::kFilterEnvRelease,
DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigDecay,
};
for (DeckParam p : sloped) {
const DeckParam c = curveParamFor(p);
CHECK(c != DeckParam::kCount);
// A curve control is itself flat — no inner dial on an inner dial.
CHECK(curveParamFor(c) == DeckParam::kCount);
}
const DeckParam flat[] = {
DeckParam::kHold, DeckParam::kSustain, DeckParam::kTrigHold,
DeckParam::kPitchEnvHold, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvSustain,
DeckParam::kFilterTrigHold, DeckParam::kTrigLength, DeckParam::kPitchEnvDepth,
DeckParam::kFilterCutoff, DeckParam::kMasterGain, DeckParam::kKeyTrack,
};
for (DeckParam p : flat) CHECK(curveParamFor(p) == DeckParam::kCount);
// Every sloped knob maps to a DISTINCT curve control — a copy-paste that pointed two
// stages at one exponent would tie two dials together silently.
for (std::size_t i = 0; i < sizeof(sloped) / sizeof(sloped[0]); ++i) {
for (std::size_t j = i + 1; j < sizeof(sloped) / sizeof(sloped[0]); ++j) {
CHECK(curveParamFor(sloped[i]) != curveParamFor(sloped[j]));
}
}
}
static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
// The reserved blanks are what stop a mode flip reflowing the groups beside AMP.
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
@@ -86,7 +169,7 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
const DeckGroupDesc& b = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
CHECK(deckGroupWidth(a) == deckGroupWidth(b));
CHECK(a.cellIds.size() == b.cellIds.size());
CHECK(b.cellIds[3] == -1 && b.cellIds[4] == -1);
CHECK(b.cellIds[4] == -1); // the Trigger face's one reserved blank
// Every other group is mode-independent, so the whole deck's height is too.
CHECK(deckHeight(gate, kAvailAtMinWidth) == deckHeight(trig, kAvailAtMinWidth));
}
@@ -188,16 +271,25 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
}
static void testEveryDeckControlIsClassifiedLiveOrReloading() {
// The live set: the six filter tone/modulation knobs, plus every stage time and stage
// level on all three envelopes.
// The live set: the six filter tone/modulation knobs, plus every stage time, stage level,
// hold fraction and curve exponent on all three envelopes — in BOTH mode shapes.
const DeckParam live[] = {
DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ,
DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterKeyTrack,
DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain,
DeckParam::kRelease,
DeckParam::kTrigAttack, DeckParam::kTrigHold, DeckParam::kTrigDecay,
DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvDecay,
DeckParam::kFilterEnvSustain, DeckParam::kFilterEnvRelease,
DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay, DeckParam::kPitchEnvDepth,
DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigHold, DeckParam::kFilterTrigDecay,
DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvHold, DeckParam::kPitchEnvDecay,
DeckParam::kPitchEnvDepth,
DeckParam::kAttackCurve, DeckParam::kDecayCurve, DeckParam::kReleaseCurve,
DeckParam::kTrigAttackCurve, DeckParam::kTrigDecayCurve,
DeckParam::kPitchEnvAttackCurve, DeckParam::kPitchEnvDecayCurve,
DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve,
DeckParam::kFilterEnvReleaseCurve,
DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve,
};
for (DeckParam p : live) CHECK(isLiveDeckParam(p));
@@ -206,8 +298,9 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
const DeckParam reloads[] = {
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel,
DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kTrigFadeIn,
DeckParam::kTrigFadeOut, DeckParam::kVoiceCount, DeckParam::kVoiceMode,
DeckParam::kKeyTrack, DeckParam::kTrigLength,
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
DeckParam::kMonoTrigger, DeckParam::kMasterGain,
};
for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p));
@@ -228,28 +321,24 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
static void testOnlyALiveControlsDragTakesTheLiveTier() {
// isLiveDeckParam alone is not what a user experiences — liveCommitFor is, at the editor's
// commit site. Inverting it has to FAIL a test rather than merely read wrong.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kFilterCutoff),
PlayMode::Gate));
// A knob's routing is the knob's, not the play mode's.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAttack),
PlayMode::Trigger));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigFadeIn),
PlayMode::Trigger));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kMasterGain),
PlayMode::Gate));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kFilterCutoff)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAttack)));
// The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural
// consequence of that consolidation.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigAttack)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigDecayCurve)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigLength)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kMasterGain)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAmpEnvSelect)));
// The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id
// are not parameter-set controls, so they must never reach the enum.
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -2, PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1, PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kCount),
PlayMode::Gate));
// An envelope-node drag edits the AHDSR in Gate; the same drag in Trigger rewrites the
// play span, which is not a live control.
CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1, PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kEnvNode, -1, PlayMode::Trigger));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -2));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kCount)));
// Every stage value an envelope node can reach is live, in either mode shape.
CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1));
// Every other drag (markers, scrollbar, curve nodes) commits through a reload.
CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff),
PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff)));
}
int main() {
@@ -257,6 +346,9 @@ int main() {
testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight();
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryARadio();
testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtTheEditorFloorWidth();
testDeckFitsInsideTheEnforcedMinimumWindow();
+262 -341
View File
@@ -1,24 +1,19 @@
// 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 S-VIEW-3 draggable-node INVERSE
// map: node hit-test + pixel-delta -> clamped/monotonic param set, HARD at the clamp + monotonic
// boundaries (the load-bearing "a drag can never produce a param a slider couldn't" invariant).
// 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 (grabs a drawn handle within the pick radius; misses off every node; skips
// the non-draggable Origin/ReleaseStart anchors AND other-mode nodes; NEAREST-node-wins with
// draw-order tie-break; EVERY Gate node individually grabbable at the tier-0 defaults — FA2);
// resolveNodeDrag Gate (each cumulative node edits its OWN segment at the PARAM-DOMAIN px scale;
// X->time, sustain node's Y->level; lower clamp at 0; upper clamp at the caller's max; only the
// dragged param changes; ReleaseEnd grabbable + draggable; per-node drag round-trip tracks the
// cursor ~1:1 — FA2); resolveNodeDrag Trigger (fades as fractions of the played span;
// fadeIn/fadeOut mutual clamp so they never cross; length clamp; FadeOutStart moves OPPOSITE the
// pixel delta; zero-fade-out node grabbable at the right edge and draggable inward — FA2);
// degenerate area/duration + non-draggable node + cross-mode node -> no motion.
// 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 <cstdlib>
#include <vector>
using namespace reasampler;
@@ -28,9 +23,41 @@ 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 bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }
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;
}
// Find the first vertex with a given node in a polyline; asserts presence via the returned bool.
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; }
@@ -38,348 +65,242 @@ static bool findNode(const std::vector<EnvVertex>& poly, EnvNode node, EnvVertex
return false;
}
// 1000px wide, 100px tall, offset origin. Trigger scale: 2.0s over 1000px => 0.002 s/px. Gate
// scale (FA2 param-domain schematic — sample-length-free): (850-1-32)px over the 8.0s schematic
// domain => 102.125 px/s, each segment prefixed by the 8px separation base; the gateEnv() nodes
// draw at A x@28, H x@47, D x@85, RS x@235, RE x@284.
static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); }
static constexpr double kTotal = 2.0;
static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
static const double kGateSecPerPx = 1.0 / gatePxPerSecond(wideArea());
static AmpEnvelope gateEnv() {
AmpEnvelope e;
e.mode = EnvMode::Gate;
e.attackSeconds = 0.2;
e.holdSeconds = 0.1;
e.decaySeconds = 0.3;
e.sustainLevel = 0.5;
e.releaseSeconds = 0.4;
return e;
}
static AmpEnvelope triggerEnv() {
AmpEnvelope e;
e.mode = EnvMode::Trigger;
e.lengthFraction = 0.5; // played span 1.0s -> 500px
e.fadeInFraction = 0.2;
e.fadeOutFraction = 0.2;
return e;
}
// --- nodeAtPoint --------------------------------------------------------------
static void testHitGrabsDrawnHandle() {
const AmpEnvelope e = gateEnv();
// Grab exactly where the forward map drew the node.
static NodeHit grabAt(const StageEnvelope& e, EnvNode node) {
const Rect a = wideArea();
// AttackEnd draws at x = left+28 (8px base + 0.2s * 102.125 px/s), y = top (level 1).
NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 28, a.y);
CHECK(h.hit && h.node == EnvNode::AttackEnd);
// The sustain node (DecayEnd) at left+85, level 0.5 -> ~top+50.
NodeHit s = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 85, a.y + 50);
CHECK(s.hit && s.node == EnvNode::DecayEnd);
EnvVertex v;
if (!findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), node, v)) return NodeHit{};
return nodeAtPoint(e, overlayOf(a), kTotal, v.x, v.y);
}
static void testHitMissesOffEveryNode() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
// A point far from any drawn handle (right of the release ramp, well away from a node).
NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 700, a.y + 5);
CHECK(!h.hit);
// --- 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 testHitSkipsNonDraggableAnchors() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
// Origin draws at (left, bottom-1). Even a pixel-perfect grab there is NOT a draggable node.
NodeHit o = nodeAtPoint(e, overlayOf(a), kTotal, a.x, a.bottom() - 1);
CHECK(!o.hit);
// ReleaseStart draws at (left+235, sustain level ~top+50) — the fixed plateau end. It is
// drawing-only -> not grabbable; no other node is within the radius, so this grab misses.
NodeHit rs = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 235, a.y + 50);
CHECK(!rs.hit);
}
static void testHitNearestNodeWinsOverDrawOrder() {
// FA2 nearest-wins: with a SHORT hold, AttackEnd (x@28) and HoldEnd (x@37 — the 8px base
// plus 0.01s ~= 1px) both fall within the grab radius of a point at x@33 — the NEAREST
// (HoldEnd, 4px) must win, not the earlier draw-order AttackEnd (5px), so tightly packed
// handles stay individually grabbable.
AmpEnvelope e = gateEnv();
e.holdSeconds = 0.01;
const Rect a = wideArea();
NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 33, a.y);
CHECK(h.hit && h.node == EnvNode::HoldEnd);
}
static void testGateDefaultsEveryNodeGrabbable() {
// THE FA2 headline regression: at the tier-0 Gate defaults (attack 3ms, hold 0, decay 0,
// sustain 1.0, release 60ms) the forward map's kGateNodeSepPx separation keeps every
// draggable node distinct, and a grab AT each drawn vertex resolves to THAT node — HoldEnd
// and DecayEnd are no longer shadowed by AttackEnd (pre-fix they were permanently
// ungrabbable in the default state).
const AmpEnvelope e; // struct defaults ARE the tier-0 Gate defaults
static void testAnchoredEndAndOriginAreNotGrabbable() {
const StageEnvelope e = ahdsrEnv();
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), kTotal);
CHECK(poly.size() == 6);
for (const EnvVertex& v : poly) {
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
const NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, v.x, v.y);
CHECK(h.hit && h.node == v.node);
}
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);
}
// --- resolveNodeDrag Gate -----------------------------------------------------
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 testGateAttackDragMovesOnlyAttack() {
const AmpEnvelope e = gateEnv();
static void testMissOutsideTheRadius() {
const StageEnvelope e = ahdsrEnv();
const Rect a = wideArea();
EnvClampBounds b; // default maxima 4.0s
// +50px at the GATE param-domain scale (~0.0098 s/px) on attack. Nothing else moves.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, b, 50, 0);
CHECK(near(out.attackSeconds, 0.2 + 50.0 * kGateSecPerPx));
CHECK(near(out.holdSeconds, e.holdSeconds));
CHECK(near(out.decaySeconds, e.decaySeconds));
CHECK(near(out.sustainLevel, e.sustainLevel));
CHECK(near(out.releaseSeconds, e.releaseSeconds));
}
static void testGateTimeLowerClampAtZero() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
// Drag attack far LEFT (-500px ~= -4.9s at the gate scale) from 0.2s: clamps to 0, never
// negative (monotonic: the segment cannot go below zero).
AmpEnvelope out = resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, b, -500, 0);
CHECK(near(out.attackSeconds, 0.0));
}
static void testGateTimeUpperClampAtSliderMax() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
b.maxDecaySeconds = 1.0; // the shell's decay slider tops out at 1.0s
// Drag decay far RIGHT (+2000px ~= +19.6s at the gate scale) from 0.3s: clamps to the slider
// max 1.0, NOT beyond (the drag can't produce a param the slider couldn't).
AmpEnvelope out = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, b, 2000, 0);
CHECK(near(out.decaySeconds, 1.0));
}
static void testGateSustainNodeBothAxes() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
// DecayEnd: +100px X at the gate timed scale on decay; +bottom-ward Y LOWERS the level. Level
// span is 99 px for [0,1]; drag DOWN by ~10px (positive dy) lowers sustain by ~10/99 ~= 0.101.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, b, 100, 10);
CHECK(near(out.decaySeconds, 0.3 + 100.0 * kGateSecPerPx));
CHECK(out.sustainLevel < e.sustainLevel); // dragged DOWN -> lower sustain
CHECK(near(out.sustainLevel, 0.5 - 10.0 / 99.0, 1e-6));
}
static void testGateSustainLevelClamps01() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
// Drag sustain UP hard (dy very negative): clamps to 1.0.
AmpEnvelope up = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, b, 0, -10000);
CHECK(near(up.sustainLevel, 1.0));
// Drag sustain DOWN hard (dy very positive): clamps to 0.0.
AmpEnvelope dn = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, b, 0, 10000);
CHECK(near(dn.sustainLevel, 0.0));
}
static void testGateTimeOnlyNodeIgnoresY() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
// HoldEnd is time-only: a big Y delta must NOT change any level (there is no level to change).
AmpEnvelope out = resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, b, 0, 500);
CHECK(near(out.holdSeconds, e.holdSeconds)); // dx 0 -> no time change either
CHECK(near(out.sustainLevel, e.sustainLevel)); // Y ignored for a time-only node
}
static void testGateReleaseEndGrabAndDrag() {
// The FA2 fix: ReleaseEnd is a drawn, IN-BOUNDS, grabbable handle (pre-FA2 it mapped past
// area.right() and could never be grabbed). gateEnv() draws it at x@284, level 0 (bottom row).
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 284, a.bottom() - 1);
CHECK(h.hit && h.node == EnvNode::ReleaseEnd);
// Dragging it RIGHT lengthens the release at the gate timed scale; only release changes.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::ReleaseEnd, overlayOf(a), kTotal, b, 85, 0);
CHECK(near(out.releaseSeconds, 0.4 + 85.0 * kGateSecPerPx));
CHECK(near(out.sustainLevel, e.sustainLevel));
CHECK(near(out.decaySeconds, e.decaySeconds));
// Far LEFT clamps to 0; far RIGHT clamps to the slider max.
AmpEnvelope lo = resolveNodeDrag(e, EnvNode::ReleaseEnd, overlayOf(a), kTotal, b, -2000, 0);
CHECK(near(lo.releaseSeconds, 0.0));
AmpEnvelope hi = resolveNodeDrag(e, EnvNode::ReleaseEnd, overlayOf(a), kTotal, b, 5000, 0);
CHECK(near(hi.releaseSeconds, b.maxReleaseSeconds));
}
static void testGateDragRoundTripTracksPixels() {
// 1:1 tracking (FA2): drag a Gate node by N px, rebuild the polyline from the edited params,
// and the node's drawn vertex has moved by ~N px (rounding may shift the landing by 1). The
// forward map is affine in each node's own segment duration with slope gatePxPerSecond and
// the inverse uses exactly the reciprocal, so the handle follows the cursor.
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
const int dx = 25;
for (EnvNode n : {EnvNode::AttackEnd, EnvNode::HoldEnd, EnvNode::DecayEnd,
EnvNode::ReleaseEnd}) {
EnvVertex before, after;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), n, before));
const AmpEnvelope edited = resolveNodeDrag(e, n, overlayOf(a), kTotal, b, dx, 0);
CHECK(findNode(buildEnvelopePolyline(edited, overlayOf(a), kTotal), n, after));
CHECK(std::abs((after.x - before.x) - dx) <= 1);
}
// The sustain node's Y axis tracks too: +10px down moves the drawn vertex ~10px down.
EnvVertex before, after;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), EnvNode::DecayEnd, before));
const AmpEnvelope edited = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, b, 0, 10);
CHECK(findNode(buildEnvelopePolyline(edited, overlayOf(a), kTotal), EnvNode::DecayEnd, after));
CHECK(std::abs((after.y - before.y) - 10) <= 1);
}
// --- resolveNodeDrag Trigger --------------------------------------------------
static void testTriggerFadeInIsFractionOfPlaySpan() {
const AmpEnvelope e = triggerEnv(); // played span 1.0s -> 500px
const Rect a = wideArea();
EnvClampBounds b;
// +50px = +0.1s on the play timeline = +0.1/1.0 = +0.1 fraction. fadeIn 0.2 -> 0.3.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeInEnd, overlayOf(a), kTotal, b, 50, 0);
CHECK(near(out.fadeInFraction, 0.3));
CHECK(near(out.fadeOutFraction, e.fadeOutFraction)); // unchanged
}
static void testTriggerFadesCannotCross() {
AmpEnvelope e = triggerEnv();
e.fadeInFraction = 0.5;
e.fadeOutFraction = 0.3; // sum 0.8, room 0.2 before they'd cross
const Rect a = wideArea();
EnvClampBounds b;
// Drag fade-in far RIGHT (+2000px): would push fadeIn well past 1-fadeOut=0.7, but the mutual
// clamp caps it at 0.7 so the fade nodes never cross (monotonic on the play timeline).
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeInEnd, overlayOf(a), kTotal, b, 2000, 0);
CHECK(near(out.fadeInFraction, 0.7));
CHECK(near(out.fadeOutFraction, 0.3));
}
static void testTriggerFadeOutMovesOppositePixelDelta() {
const AmpEnvelope e = triggerEnv(); // fadeOut 0.2, play span 1.0s -> 500px
const Rect a = wideArea();
EnvClampBounds b;
// FadeOutStart sits at (1-fadeOut) of the span; dragging it LEFT (-50px) LENGTHENS the fade-out.
// -50px = -0.1s = -0.1 fraction on the span, applied OPPOSITE -> fadeOut 0.2 -> 0.3.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeOutStart, overlayOf(a), kTotal, b, -50, 0);
CHECK(near(out.fadeOutFraction, 0.3));
CHECK(near(out.fadeInFraction, e.fadeInFraction));
}
static void testTriggerZeroFadeOutGrabbableAtRightEdge() {
// The FA2 fix: at fade-out == 0 and full length, FadeOutStart draws AT the right edge
// (right-1, level 1). It must be grabbable there and draggable INWARD to grow the fade from
// zero (drag LEFT -> longer fade-out, opposite the pixel delta).
AmpEnvelope e;
e.mode = EnvMode::Trigger;
e.lengthFraction = 1.0; // played span = full 2.0s -> 1000px
e.fadeInFraction = 0.1;
e.fadeOutFraction = 0.0;
const Rect a = wideArea();
EnvClampBounds b;
NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.right() - 1, a.y);
CHECK(h.hit && h.node == EnvNode::FadeOutStart);
// -100px = -0.2s on the 2.0s played span, applied OPPOSITE -> fadeOut 0.0 -> 0.1.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeOutStart, overlayOf(a), kTotal, b, -100, 0);
CHECK(near(out.fadeOutFraction, 0.1));
CHECK(near(out.lengthFraction, e.lengthFraction)); // length untouched
// LengthEnd sits at the same x but level 0 (bottom row) — grabbable at ITS drawn point.
NodeHit le = nodeAtPoint(e, overlayOf(a), kTotal, a.right() - 1, a.bottom() - 1);
CHECK(le.hit && le.node == EnvNode::LengthEnd);
}
static void testTriggerLengthClampsAtMax() {
const AmpEnvelope e = triggerEnv(); // length 0.5
const Rect a = wideArea();
EnvClampBounds b; // maxLengthFraction 1.0
// LengthEnd maps to a fraction of the WHOLE sample: +2000px = +4.0s = +2.0 fraction, clamps 1.0.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::LengthEnd, overlayOf(a), kTotal, b, 2000, 0);
CHECK(near(out.lengthFraction, 1.0));
// Drag far LEFT clamps to 0.
AmpEnvelope lo = resolveNodeDrag(e, EnvNode::LengthEnd, overlayOf(a), kTotal, b, -2000, 0);
CHECK(near(lo.lengthFraction, 0.0));
}
// --- No-motion guards ---------------------------------------------------------
static void testNonDraggableNodeNoMotion() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
AmpEnvelope o = resolveNodeDrag(e, EnvNode::Origin, overlayOf(a), kTotal, b, 500, 500);
CHECK(near(o.attackSeconds, e.attackSeconds) && near(o.sustainLevel, e.sustainLevel));
AmpEnvelope rs = resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, b, 500, 500);
CHECK(near(rs.releaseSeconds, e.releaseSeconds));
}
static void testDegenerateAreaNoMotion() {
const AmpEnvelope e = gateEnv();
EnvClampBounds b;
const Rect zeroW = Rect::ltrb(0, 0, 0, 100);
AmpEnvelope o1 = resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(zeroW), kTotal, b, 500, 0);
CHECK(near(o1.attackSeconds, e.attackSeconds));
AmpEnvelope o2 = resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(wideArea()), 0.0, b, 500, 0); // no time
CHECK(near(o2.attackSeconds, e.attackSeconds));
}
static void testCrossModeNodeNoMotion() {
// A node from the OTHER mode never writes (FA2 guard): the degenerate baseline polyline
// carries a ReleaseEnd vertex regardless of mode, so a Trigger-mode grab of it (e.g. over a
// zero-height canvas) must NOT write releaseSeconds — and symmetrically a Trigger node is
// inert on a Gate envelope.
EnvClampBounds b;
const AmpEnvelope t = triggerEnv();
AmpEnvelope out = resolveNodeDrag(t, EnvNode::ReleaseEnd, overlayOf(wideArea()), kTotal, b, 50, 0);
CHECK(near(out.releaseSeconds, t.releaseSeconds));
const AmpEnvelope g = gateEnv();
out = resolveNodeDrag(g, EnvNode::FadeInEnd, overlayOf(wideArea()), kTotal, b, 50, 0);
CHECK(near(out.fadeInFraction, g.fadeInFraction));
// And the zero-height baseline's ReleaseEnd is not even reported grabbable in Trigger mode.
const Rect flat = Rect::ltrb(0, 0, 100, 0);
const NodeHit h = nodeAtPoint(t, overlayOf(flat), kTotal, 99, 0);
// 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);
const StageEnvelope decay =
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 100, 0);
CHECK(std::fabs(decay.decaySeconds - (e.decaySeconds + 100 * secPerPx)) < 1e-9);
}
// 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);
}
// --- 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() {
testHitGrabsDrawnHandle();
testHitMissesOffEveryNode();
testHitSkipsNonDraggableAnchors();
testHitNearestNodeWinsOverDrawOrder();
testGateDefaultsEveryNodeGrabbable();
testEveryDrawnHandleIsGrabbable();
testAnchoredEndAndOriginAreNotGrabbable();
testAhdHasNoSustainNodes();
testMissOutsideTheRadius();
testGateAttackDragMovesOnlyAttack();
testGateTimeLowerClampAtZero();
testGateTimeUpperClampAtSliderMax();
testGateSustainNodeBothAxes();
testGateSustainLevelClamps01();
testGateTimeOnlyNodeIgnoresY();
testGateReleaseEndGrabAndDrag();
testGateDragRoundTripTracksPixels();
testAhdsrStageTimesTrackTheSchematicScale();
testReleaseDragsFromItsStartWithInvertedSign();
testSustainLevelOnTheDecayNodesYAxis();
testStageTimesClampToTheKnobDomain();
testTriggerFadeInIsFractionOfPlaySpan();
testTriggerFadesCannotCross();
testTriggerFadeOutMovesOppositePixelDelta();
testTriggerZeroFadeOutGrabbableAtRightEdge();
testTriggerLengthClampsAtMax();
testAhdStageTimesTrackTheWallClockScale();
testAhdHoldNodeEditsTheFraction();
testNonDraggableNodeNoMotion();
testDegenerateAreaNoMotion();
testCrossModeNodeNoMotion();
testKnotDragMovesTheExponentWithinItsDomain();
testKnotAndModelCannotDiverge();
testKnotOnALevelSegmentIsANoOp();
testDegenerateInputsAreNoOps();
if (g_fail == 0) std::printf("envelope_edit: all tests passed\n");
else std::printf("envelope_edit: %d FAILED\n", g_fail);
+253 -306
View File
@@ -1,21 +1,19 @@
// Standalone tests for reasampler::instrument::ui::envelope_overlay — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests. Assert the S-VIEW-3/FA2 amp-envelope ->
// polyline FORWARD map: the Gate BOUNDED-SCHEMATIC AHDSR shape (attack ramp / hold plateau /
// decay-to-sustain / fixed-width sustain plateau / in-bounds release) and the Trigger
// fade/%-length shape at the waveform time base.
// Standalone tests for reasampler::instrument::ui::envelope_overlay — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests. Assert the staged-envelope ->
// polyline FORWARD map for BOTH layout policies: the AHDSR bounded schematic with its
// 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 — the FA2
// 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.
// 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.
#include "../src/core/instrument/ui/envelope_overlay.h"
#include <cmath>
#include <cstdio>
#include <vector>
@@ -32,364 +30,313 @@ static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
// bugs). Under levelToY the level span is height-1 = 99 rows.
static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } // width 1000, height 100
// Find the first vertex with a given node in a polyline; asserts presence via the returned bool.
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;
}
static bool hasNode(const std::vector<EnvVertex>& poly, EnvNode node) {
EnvVertex v;
return findNode(poly, node, v);
}
static StageEnvelope ahdsr(double a, double h, double d, double sus, double r) {
StageEnvelope e;
e.kind = EnvKind::Ahdsr;
e.attackSeconds = a;
e.holdSeconds = h;
e.decaySeconds = d;
e.sustainLevel = sus;
e.releaseSeconds = r;
return e;
}
static StageEnvelope ahd(double a, double d, double frac, double origin, double span) {
StageEnvelope e;
e.kind = EnvKind::Ahd;
e.attackSeconds = a;
e.decaySeconds = d;
e.holdFraction = frac;
e.originSeconds = origin;
e.spanSeconds = span;
return e;
}
// --- timeToX / levelToY -------------------------------------------------------
static void testTimeToXEndpoints() {
const Rect a = wideArea();
CHECK(timeToX(a, 2.0, 0.0) == a.x); // t=0 -> left
CHECK(timeToX(a, 2.0, 2.0) == a.right() - 1); // t=total -> last in-bounds column
CHECK(timeToX(a, 2.0, 1.0) == a.x + 500); // midpoint
CHECK(timeToX(a, 2.0, 0.0) == a.x); // t=0 -> left
CHECK(timeToX(a, 2.0, 2.0) == a.right() - 1); // t=total -> last in-bounds column
CHECK(timeToX(a, 2.0, 1.0) == a.x + 500); // midpoint
}
static void testTimeToXNegativePinsLeft() {
const Rect a = wideArea();
CHECK(timeToX(a, 2.0, -0.5) == a.x); // t<0 pins left
}
static void testTimeToXPastEndClamps() {
// FA2 bounds invariant: t past total pins to the last in-bounds column, never past right.
static void testTimeToXClampsBothEnds() {
const Rect a = wideArea();
CHECK(timeToX(a, 2.0, -0.5) == a.x);
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() {
// 15% of the 1000px canvas is reserved for the sustain plateau -> 850px timed region.
CHECK(gateTimedWidth(wideArea()) == 850);
// Zero-width area -> 0; a tiny area still yields >= 1 so the px<->s scale never degenerates.
CHECK(gateTimedWidth(Rect::ltrb(5, 5, 5, 45)) == 0);
CHECK(gateTimedWidth(Rect::ltrb(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::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
}
static void testTimeToXDegenerate() {
static void testLevelToY() {
const Rect a = wideArea();
CHECK(timeToX(a, 0.0, 1.0) == a.x); // no duration -> left
const Rect z = Rect::ltrb(5, 5, 5, 45); // zero width
CHECK(timeToX(z, 2.0, 1.0) == z.x);
CHECK(levelToY(a, 1.0) == a.y); // level 1 -> top row
CHECK(levelToY(a, 0.0) == a.bottom() - 1); // level 0 -> bottom row
CHECK(levelToY(a, 0.5) == a.y + 50); // 99-row span, rounded
CHECK(levelToY(a, 5.0) == a.y); // clamps
CHECK(levelToY(a, -5.0) == a.bottom() - 1);
}
static void testLevelToYEndpoints() {
const Rect a = wideArea();
CHECK(levelToY(a, 1.0) == a.y); // level 1 -> top row
CHECK(levelToY(a, 0.0) == a.bottom() - 1); // level 0 -> bottom row
CHECK(levelToY(a, 0.5) == a.y + 50); // mid: round((1-0.5)*99)=round(49.5)=50
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);
}
static void testLevelToYClamps() {
// --- the AHDSR schematic ------------------------------------------------------
static void testAhdsrNodeOrderAndLevels() {
const Rect a = wideArea();
CHECK(levelToY(a, 2.0) == a.y); // >1 clamps to top
CHECK(levelToY(a, -1.0) == a.bottom() - 1); // <0 clamps to bottom
const Rect z = Rect::ltrb(5, 5, 45, 5); // zero height
CHECK(levelToY(z, 0.5) == z.y);
}
// --- Gate polyline ------------------------------------------------------------
static void testGateNodeOrderAndLevels() {
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.4;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
// Six vertices, in draw order.
CHECK(poly.size() == 6);
CHECK(poly[0].node == EnvNode::Origin);
CHECK(poly[1].node == EnvNode::AttackEnd);
CHECK(poly[2].node == EnvNode::HoldEnd);
CHECK(poly[3].node == EnvNode::DecayEnd);
CHECK(poly[4].node == EnvNode::ReleaseStart);
CHECK(poly[5].node == EnvNode::ReleaseEnd);
// Levels: origin 0, attack/hold peak 1, decay settles to sustain, plateau holds sustain,
// release ends at 0.
CHECK(poly[0].level == 0.0);
CHECK(poly[1].level == 1.0);
CHECK(poly[2].level == 1.0);
CHECK(poly[3].level == 0.5); // sustain
CHECK(poly[4].level == 0.5); // plateau end holds sustain
CHECK(poly[5].level == 0.0);
}
static void testGateSchematicPlacement() {
// 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;
env.decaySeconds = 0.3;
env.sustainLevel = 0.5;
env.releaseSeconds = 0.4;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
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 + 28);
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 47);
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 85);
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 235);
CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 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, overlayOf(a), 0.3) == buildEnvelopePolyline(env, overlayOf(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, overlayOf(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);
CHECK(poly.size() >= 6);
CHECK(poly[0].node == EnvNode::Origin && poly[0].level == 0.0);
CHECK(poly[1].node == EnvNode::AttackEnd && poly[1].level == 1.0);
CHECK(poly[2].node == EnvNode::HoldEnd && poly[2].level == 1.0);
CHECK(poly[3].node == EnvNode::DecayEnd && poly[3].level == 0.5);
CHECK(poly[4].node == EnvNode::ReleaseStart && poly[4].level == 0.5);
CHECK(poly[5].node == EnvNode::ReleaseEnd && poly[5].level == 0.0);
// Monotone in x across the traced line.
for (std::size_t i = 1; i < 6; ++i) CHECK(poly[i].x >= poly[i - 1].x);
// Every vertex in-bounds.
for (const EnvVertex& p : poly) {
CHECK(p.x >= a.x && p.x <= a.right() - 1);
CHECK(p.y >= a.y && p.y <= a.bottom() - 1);
}
CHECK(findNode(poly, EnvNode::ReleaseEnd, v));
CHECK(v.x == a.right() - 1); // ANCHORED, whatever the release is
}
static void testGateSustainPlateauFixedWidth() {
// The sustain plateau is ALWAYS the reserved width (canvas - timed region), independent of
// the AHDSR times — the bounded region that replaces the old plateau-to-sample-end.
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.1;
env.holdSeconds = 0.0;
env.decaySeconds = 0.2;
env.sustainLevel = 0.6;
env.releaseSeconds = 0.3;
// 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() {
const Rect a = wideArea();
const int plateauPx = a.width - gateTimedWidth(a); // 150
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
EnvVertex decay, plateauEnd;
const std::vector<EnvVertex> poly =
buildEnvelopePolyline(ahdsr(0.05, 0.0, 0.05, 0.7, 0.0), overlayOf(a), 4.0);
EnvVertex plateau, end;
CHECK(findNode(poly, EnvNode::ReleaseStart, plateau));
CHECK(findNode(poly, EnvNode::ReleaseEnd, end));
CHECK(end.x == a.right() - 1);
// One node separation short of the edge — the plateau spans essentially the whole canvas.
CHECK(plateau.x == a.right() - 1 - kGateNodeSepPx);
EnvVertex decay;
CHECK(findNode(poly, EnvNode::DecayEnd, decay));
CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd));
CHECK(plateauEnd.x - decay.x == plateauPx);
CHECK(plateauEnd.level == 0.6); // plateau holds the sustain level
CHECK(plateau.x - decay.x > a.width / 2);
}
static void testGateReleaseVisibleInBounds() {
// The FA2 fix: Release is a VISIBLE, in-bounds segment — ReleaseEnd sits strictly right of
// the plateau end and strictly inside the canvas (pre-FA2 it mapped past area.right() and the
// shell clipped its handle away).
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.4;
// The release END never moves; the release START is what a longer release pushes left.
static void testReleaseGrowsLeftwardFromTheAnchor() {
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
EnvVertex plateauEnd, rel;
CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd));
CHECK(findNode(poly, EnvNode::ReleaseEnd, rel));
CHECK(rel.x > plateauEnd.x); // a visible ramp, not a collapsed point
CHECK(rel.x < a.right()); // strictly in-bounds
CHECK(rel.level == 0.0);
EnvVertex shortStart, longStart, shortEnd, longEnd;
const std::vector<EnvVertex> shortR =
buildEnvelopePolyline(ahdsr(0.1, 0.0, 0.1, 0.5, 0.1), overlayOf(a), 4.0);
const std::vector<EnvVertex> longR =
buildEnvelopePolyline(ahdsr(0.1, 0.0, 0.1, 0.5, 1.5), overlayOf(a), 4.0);
CHECK(findNode(shortR, EnvNode::ReleaseStart, shortStart));
CHECK(findNode(longR, EnvNode::ReleaseStart, longStart));
CHECK(findNode(shortR, EnvNode::ReleaseEnd, shortEnd));
CHECK(findNode(longR, EnvNode::ReleaseEnd, longEnd));
CHECK(longStart.x < shortStart.x);
CHECK(shortEnd.x == longEnd.x);
}
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 = 4.0;
env.holdSeconds = 4.0;
env.decaySeconds = 4.0;
env.sustainLevel = 0.7;
env.releaseSeconds = 4.0;
// Tier-0 defaults are zero hold and zero decay; every node still has to be independently
// grabbable, which is what the per-segment separation base buys.
static void testTierZeroDefaultsKeepEveryNodeDistinct() {
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
CHECK(poly.size() == 6);
EnvVertex plateauEnd, rel;
CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd));
CHECK(findNode(poly, EnvNode::ReleaseEnd, rel));
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.x && poly[i].x < a.right()); // in-bounds
const std::vector<EnvVertex> poly =
buildEnvelopePolyline(ahdsr(0.003, 0.0, 0.0, 1.0, 0.060), overlayOf(a), 4.0);
for (std::size_t i = 1; i < 6; ++i) {
CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx - 1);
}
}
static void testGateAllVerticesInBounds() {
// The FA2 bounds invariant, swept over representative param sets (including extremes): every
// vertex of every polyline stays inside the canvas rect.
// Every stage maxed: the schematic exactly fills the canvas, the plateau collapses to its
// minimum gap, and nothing escapes the rect.
static void testMaxedStagesCompressWithoutOverrunning() {
const Rect a = wideArea();
const AmpEnvelope base; // defaults
AmpEnvelope big = base;
big.mode = EnvMode::Gate;
big.attackSeconds = 4.0; big.holdSeconds = 4.0; big.decaySeconds = 4.0;
big.sustainLevel = 1.0; big.releaseSeconds = 4.0;
AmpEnvelope zero = base;
zero.mode = EnvMode::Gate;
zero.attackSeconds = 0.0; zero.holdSeconds = 0.0; zero.decaySeconds = 0.0;
zero.sustainLevel = 0.0; zero.releaseSeconds = 0.0;
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;
const double m = kGateStageMaxSeconds;
const std::vector<EnvVertex> poly =
buildEnvelopePolyline(ahdsr(m, m, m, 0.5, m), overlayOf(a), 4.0);
for (std::size_t i = 1; i < 6; ++i) {
CHECK(poly[i].x >= poly[i - 1].x);
CHECK(poly[i].x <= a.right() - 1);
}
EnvVertex end;
CHECK(findNode(poly, EnvNode::ReleaseEnd, end));
CHECK(end.x == a.right() - 1);
}
for (const AmpEnvelope& env : {base, big, zero, trig, huge}) {
for (const EnvVertex& v : buildEnvelopePolyline(env, overlayOf(a), 2.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,
// fraction) triple can exceed the span, and no clamp on the SUM exists to be exercised.
static void testAhdSplitNeverExceedsTheSpan() {
const double span = 3.0;
for (int ai = 0; ai <= 20; ++ai) {
for (int di = 0; di <= 20; ++di) {
for (int fi = 0; fi <= 10; ++fi) {
const StageEnvelope e =
ahd(ai * 0.25, di * 0.25, fi * 0.1, 0.0, span);
const AhdSplit s = splitAhdSeconds(e);
CHECK(s.attack >= 0.0 && s.hold >= 0.0 && s.decay >= 0.0);
CHECK(s.total <= span + 1e-9);
CHECK(std::fabs(s.total - (s.attack + s.hold + s.decay)) < 1e-12);
}
}
}
}
// --- Trigger polyline ---------------------------------------------------------
static void testHoldFractionEndpoints() {
const StageEnvelope none = ahd(0.5, 0.5, 0.0, 0.0, 4.0);
const AhdSplit s0 = splitAhdSeconds(none);
CHECK(s0.hold == 0.0);
CHECK(std::fabs(s0.total - 1.0) < 1e-12);
static void testTriggerShape() {
// played span = length * total = 0.5 * 2.0 = 1.0s -> 500px wide. fadeIn .2 of play -> 0.2s
// (x@100), fade-out .3 of play -> begins at 0.7s (x@350), playEnd at 1.0s (x@500).
AmpEnvelope env;
env.mode = EnvMode::Trigger;
env.lengthFraction = 0.5;
env.fadeInFraction = 0.2;
env.fadeOutFraction = 0.3;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
const StageEnvelope full = ahd(0.5, 0.5, 1.0, 0.0, 4.0);
const AhdSplit s1 = splitAhdSeconds(full);
// 100% of what attack and decay left: 4 - 0.5 - 0.5 = 3.
CHECK(std::fabs(s1.hold - 3.0) < 1e-12);
CHECK(std::fabs(s1.total - 4.0) < 1e-12);
CHECK(poly.size() == 4);
CHECK(poly[0].node == EnvNode::Origin);
CHECK(poly[1].node == EnvNode::FadeInEnd);
CHECK(poly[2].node == EnvNode::FadeOutStart);
CHECK(poly[3].node == EnvNode::LengthEnd);
EnvVertex v;
CHECK(findNode(poly, EnvNode::FadeInEnd, v) && v.x == a.x + 100 && v.level == 1.0);
CHECK(findNode(poly, EnvNode::FadeOutStart, v) && v.x == a.x + 350 && v.level == 1.0);
CHECK(findNode(poly, EnvNode::LengthEnd, v) && v.x == a.x + 500 && v.level == 0.0);
// Attack + decay alone longer than the span: they fit by their own per-stage bounds and the
// remainder — and therefore hold — is zero. Still no clamp on the sum.
const AhdSplit s2 = splitAhdSeconds(ahd(3.0, 3.0, 1.0, 0.0, 4.0));
CHECK(std::fabs(s2.attack - 3.0) < 1e-12);
CHECK(std::fabs(s2.decay - 1.0) < 1e-12);
CHECK(s2.hold == 0.0);
CHECK(std::fabs(s2.total - 4.0) < 1e-12);
}
static void testTriggerFadeOverlapClamp() {
// fadeIn + fadeOut > 1: the fade-out is trimmed so they meet exactly (no crossed nodes).
AmpEnvelope env;
env.mode = EnvMode::Trigger;
env.lengthFraction = 1.0; // played span = full 2.0s -> 1000px
env.fadeInFraction = 0.8; // fade-in end at 0.8*2.0 = 1.6s -> x@800
env.fadeOutFraction = 0.6; // would be 1.4s -> clamped to 1-0.8=0.2 -> begins at 0.8*2.0 too
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
// --- the AHD polyline ---------------------------------------------------------
EnvVertex fin, fout;
CHECK(findNode(poly, EnvNode::FadeInEnd, fin));
CHECK(findNode(poly, EnvNode::FadeOutStart, fout));
CHECK(fin.x == fout.x); // fades meet exactly, never cross
CHECK(fin.x == a.x + 800);
// The 1:1 property: a stage boundary at N seconds sits over the waveform at N seconds.
static void testAhdIsOneToOneWithTheTimeAxis() {
const Rect a = wideArea();
const double total = 8.0;
const StageEnvelope e = ahd(1.0, 2.0, 0.5, 1.0, 6.0);
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), total);
const AhdSplit s = splitAhdSeconds(e);
EnvVertex origin, attack, hold, decay;
CHECK(findNode(poly, EnvNode::Origin, origin));
CHECK(findNode(poly, EnvNode::AttackEnd, attack));
CHECK(findNode(poly, EnvNode::HoldEnd, hold));
CHECK(findNode(poly, EnvNode::DecayEnd, decay));
CHECK(origin.x == timeToX(a, total, 1.0));
CHECK(attack.x == timeToX(a, total, 1.0 + s.attack));
CHECK(hold.x == timeToX(a, total, 1.0 + s.attack + s.hold));
CHECK(decay.x == timeToX(a, total, 1.0 + s.total));
// Levels: rises to unity, holds, falls to zero. No sustain-only nodes exist.
CHECK(origin.level == 0.0 && attack.level == 1.0 && hold.level == 1.0 && decay.level == 0.0);
CHECK(!hasNode(poly, EnvNode::ReleaseStart));
CHECK(!hasNode(poly, EnvNode::ReleaseEnd));
CHECK(!hasNode(poly, EnvNode::ReleaseCurve));
}
static void testTriggerFullLengthZeroFadeOutInBounds() {
// The FA2 fix: at full length + zero fade-out, FadeOutStart and LengthEnd land AT the last
// in-bounds column (right-1), NOT at the half-open right edge — so the shell draws their
// handles and the fade-out node is grabbable even when fade-out == 0.
AmpEnvelope env;
env.mode = EnvMode::Trigger;
env.lengthFraction = 1.0;
env.fadeInFraction = 0.1;
env.fadeOutFraction = 0.0;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
// --- curve knots --------------------------------------------------------------
EnvVertex fout, lend;
CHECK(findNode(poly, EnvNode::FadeOutStart, fout));
CHECK(findNode(poly, EnvNode::LengthEnd, lend));
CHECK(fout.x == a.right() - 1); // present + in-bounds at zero fade-out
CHECK(lend.x == a.right() - 1);
CHECK(fout.level == 1.0 && lend.level == 0.0);
// A knot rides every sloped stage that has a duration, and none that does not — a zero-length
// stage has no interior to put a handle in.
static void testKnotsRideOnlySlopedNonZeroSegments() {
const Rect a = wideArea();
const std::vector<EnvVertex> full =
buildEnvelopePolyline(ahdsr(0.2, 0.2, 0.2, 0.5, 0.2), overlayOf(a), 4.0);
CHECK(hasNode(full, EnvNode::AttackCurve));
CHECK(hasNode(full, EnvNode::DecayCurve));
CHECK(hasNode(full, EnvNode::ReleaseCurve));
const std::vector<EnvVertex> flat =
buildEnvelopePolyline(ahdsr(0.0, 0.2, 0.0, 0.5, 0.0), overlayOf(a), 4.0);
CHECK(!hasNode(flat, EnvNode::AttackCurve));
CHECK(!hasNode(flat, EnvNode::DecayCurve));
CHECK(!hasNode(flat, EnvNode::ReleaseCurve));
const std::vector<EnvVertex> ahdPoly =
buildEnvelopePolyline(ahd(0.5, 0.5, 0.5, 0.0, 4.0), overlayOf(a), 4.0);
CHECK(hasNode(ahdPoly, EnvNode::AttackCurve));
CHECK(hasNode(ahdPoly, EnvNode::DecayCurve));
// Every knot is flagged as one and every stage node is not.
for (const EnvVertex& v : ahdPoly) {
const bool isKnot = v.node == EnvNode::AttackCurve || v.node == EnvNode::DecayCurve;
CHECK(v.knot == isKnot);
}
}
// --- Degenerate ---------------------------------------------------------------
// The knot's HEIGHT is the exponent, read through the shared law: neutral sits at the segment
// midpoint level, a larger exponent pulls the attack knot toward the floor, a smaller one
// toward the ceiling. This is the visible half of the one-model rule.
static void testKnotHeightTracksTheExponent() {
const Rect a = wideArea();
StageEnvelope e = ahdsr(0.4, 0.0, 0.0, 1.0, 0.0);
EnvVertex neutral, steep, shallow;
static void testDegenerateFlatBaseline() {
AmpEnvelope env; // any params
const Rect zeroW = Rect::ltrb(0, 0, 0, 100);
const std::vector<EnvVertex> p1 = buildEnvelopePolyline(env, overlayOf(zeroW), 2.0);
CHECK(p1.size() == 2); // always a drawable line
CHECK(p1.front().level == 0.0 && p1.back().level == 0.0);
e.attackCurve = 1.0;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), 4.0), EnvNode::AttackCurve, neutral));
CHECK(std::fabs(neutral.level - 0.5) < 1e-12); // linear: half way up at half way across
CHECK(neutral.y == levelToY(a, 0.5));
const Rect ok = wideArea();
const std::vector<EnvVertex> p2 = buildEnvelopePolyline(env, overlayOf(ok), 0.0); // no duration
CHECK(p2.size() == 2);
CHECK(p2.front().level == 0.0 && p2.back().level == 0.0);
CHECK(p2.front().x == ok.x && p2.back().x == ok.right() - 1); // spans the area, in-bounds
e.attackCurve = 4.0;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), 4.0), EnvNode::AttackCurve, steep));
CHECK(steep.level < neutral.level);
CHECK(steep.y > neutral.y); // lower on screen
e.attackCurve = 0.25;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), 4.0), EnvNode::AttackCurve, shallow));
CHECK(shallow.level > neutral.level);
CHECK(shallow.y < neutral.y);
// The knot sits between its segment's endpoints in x, and inside the canvas in y.
CHECK(steep.x > a.x && steep.x < a.right() - 1);
CHECK(steep.y >= a.y && steep.y <= a.bottom() - 1);
}
// --- degenerate ---------------------------------------------------------------
static void testDegenerateSurfaceYieldsFlatBaseline() {
const std::vector<EnvVertex> zeroArea =
buildEnvelopePolyline(ahdsr(0.1, 0.1, 0.1, 0.5, 0.1), overlayOf(Rect{}), 4.0);
CHECK(zeroArea.size() == 2);
CHECK(zeroArea[0].level == 0.0 && zeroArea[1].level == 0.0);
const std::vector<EnvVertex> zeroDur =
buildEnvelopePolyline(ahd(0.1, 0.1, 0.5, 0.0, 1.0), overlayOf(wideArea()), 0.0);
CHECK(zeroDur.size() == 2);
}
int main() {
testTimeToXEndpoints();
testTimeToXNegativePinsLeft();
testTimeToXPastEndClamps();
testTimeToXDegenerate();
testGateTimedWidth();
testGatePxPerSecond();
testLevelToYEndpoints();
testLevelToYClamps();
testTimeToXClampsBothEnds();
testLevelToY();
testDegenerateAreaAndDuration();
testGateNodeOrderAndLevels();
testGateSchematicPlacement();
testGateLayoutIndependentOfSampleDuration();
testGateMinSeparationAtDefaults();
testGateSustainPlateauFixedWidth();
testGateReleaseVisibleInBounds();
testGateOverrunCompressesFromRight();
testGateAllVerticesInBounds();
testAhdsrNodeOrderAndLevels();
testZeroReleasePutsTheSustainPlateauAtTheRightEdge();
testReleaseGrowsLeftwardFromTheAnchor();
testTierZeroDefaultsKeepEveryNodeDistinct();
testMaxedStagesCompressWithoutOverrunning();
testTriggerShape();
testTriggerFadeOverlapClamp();
testTriggerFullLengthZeroFadeOutInBounds();
testAhdSplitNeverExceedsTheSpan();
testHoldFractionEndpoints();
testAhdIsOneToOneWithTheTimeAxis();
testDegenerateFlatBaseline();
testKnotsRideOnlySlopedNonZeroSegments();
testKnotHeightTracksTheExponent();
testDegenerateSurfaceYieldsFlatBaseline();
if (g_fail == 0) std::printf("envelope_overlay: all tests passed\n");
else std::printf("envelope_overlay: %d FAILED\n", g_fail);
+55 -10
View File
@@ -26,27 +26,27 @@ static int g_fail = 0;
// toggle + row toggle), MASTER (1 cell, no toggle).
static std::vector<DeckGroupDesc> shellLikeDeck() {
std::vector<DeckGroupDesc> g;
g.push_back({0, 78, {100, 44}, {1, 2, 3, 4, 5}, {}});
g.push_back({1, 38, {101, 48}, {6}, {}});
g.push_back({2, 58, {102, 32}, {7, 8, 9}, {}});
g.push_back({3, 38, {103, 40}, {10}, {104, 44}});
g.push_back({4, 46, {}, {11}, {}});
g.push_back({0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}});
g.push_back({1, 38, {}, {101, 48}, {6}, {}});
g.push_back({2, 58, {}, {102, 32}, {7, 8, 9}, {}});
g.push_back({3, 38, {}, {103, 40}, {10}, {104, 44}});
g.push_back({4, 46, {}, {}, {11}, {}});
return g;
}
static void testGroupWidth() {
// Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6.
DeckGroupDesc amp{0, 78, {100, 44}, {1, 2, 3, 4, 5}, {}};
DeckGroupDesc amp{0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}};
CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX);
// Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12.
DeckGroupDesc pitch{1, 38, {101, 48}, {6}, {}};
DeckGroupDesc pitch{1, 38, {}, {101, 48}, {6}, {}};
CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX);
// Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122.
DeckGroupDesc voice{3, 38, {103, 40}, {10}, {104, 44}};
DeckGroupDesc voice{3, 38, {}, {103, 40}, {10}, {104, 44}};
CHECK(deckGroupWidth(voice) ==
kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
// No toggles: max(caption, cells) + padding.
DeckGroupDesc master{4, 46, {}, {11}, {}};
DeckGroupDesc master{4, 46, {}, {}, {11}, {}};
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
}
@@ -148,7 +148,7 @@ static void testHitTest() {
// A blank cell (id -1) misses even though its rect exists.
std::vector<DeckGroupDesc> trig;
trig.push_back({0, 78, {100, 44}, {20, 21, 22, -1, -1}, {}});
trig.push_back({0, 78, {}, {100, 44}, {20, 21, 22, -1, -1}, {}});
const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
const DeckCellLayout& blank = tl.groups[0].cells[4];
CHECK(blank.id == -1);
@@ -162,6 +162,49 @@ static void testHitTest() {
CHECK(h.kind == DeckHitKind::None);
}
// The corner radio widens the caption row, takes the far corner, and pushes the caption
// toggle left of itself — the three properties the overlay-select switch relies on.
static void testCaptionRadioGeometryAndHit() {
const DeckGroupDesc bare{7, 78, {}, {200, 44}, {1, 2}, {}};
const DeckGroupDesc withRadio{7, 78, {201}, {200, 44}, {1, 2}, {}};
// Caption row grows by exactly gap + radio; the knob row is unchanged, so a group whose
// caption row already dominated grows by that much.
CHECK(deckGroupWidth(withRadio) - deckGroupWidth(bare) ==
kDeckToggleGap + kDeckRadioSize);
std::vector<DeckGroupDesc> g{withRadio};
const DeckLayout dl = layoutDeck(g, 0, 0, 800);
const DeckGroupLayout& lay = dl.groups[0];
CHECK(lay.captionRadio.id == 201);
CHECK(lay.captionRadio.box.width == kDeckRadioSize);
// Far corner: flush with the group's inner right edge.
CHECK(lay.captionRadio.box.right() == lay.box.right() - kDeckGroupPadX);
// The toggle sits entirely left of the radio, and the caption text left of the toggle.
CHECK(lay.captionToggle.seg1.right() <= lay.captionRadio.box.x);
CHECK(lay.caption.right() <= lay.captionToggle.seg0.x);
const DeckHit h = hitTestDeck(dl, lay.captionRadio.box.x + 2, lay.captionRadio.box.y + 2);
CHECK(h.kind == DeckHitKind::CaptionRadio && h.id == 201);
}
// The inner dial is a concentric sub-region of the knob: a grab there still names the cell,
// with `inner` set, so a cell with no inner value simply ignores the flag.
static void testInnerDialHit() {
const std::vector<DeckGroupDesc> g = shellLikeDeck();
const DeckLayout dl = layoutDeck(g, 0, 0, 900);
const DeckCellLayout& c = dl.groups[0].cells[0];
CHECK(c.inner.width == kDeckInnerDialSize && c.inner.height == kDeckInnerDialSize);
// Concentric with the knob square.
CHECK(c.inner.x + c.inner.width / 2 == c.knob.x + c.knob.width / 2);
CHECK(c.inner.y + c.inner.height / 2 == c.knob.y + c.knob.height / 2);
DeckHit h = hitTestDeck(dl, c.inner.x + c.inner.width / 2, c.inner.y + c.inner.height / 2);
CHECK(h.kind == DeckHitKind::Knob && h.id == c.id && h.inner);
// A grab on the outer ring is the same cell WITHOUT the inner flag.
h = hitTestDeck(dl, c.knob.x + 1, c.knob.y + 1);
CHECK(h.kind == DeckHitKind::Knob && h.id == c.id && !h.inner);
}
static void testEmptyDeck() {
const std::vector<DeckGroupDesc> none;
CHECK(deckRowCount(none, 800) == 0);
@@ -176,6 +219,8 @@ int main() {
testFirstGroupAlwaysPlaces();
testGroupInnerGeometry();
testHitTest();
testCaptionRadioGeometryAndHit();
testInnerDialHit();
testEmptyDeck();
if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail);
+68 -24
View File
@@ -191,36 +191,76 @@ static void testSustainLevelChangeGlides() {
}
static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
// No hold stage, so the shape is the attack-decay one the pre-AHD envelope had.
PitchEnvParams p;
p.enabled = true;
p.attackFrames = 0;
p.decayFrames = 1000;
p.peakSemitones = 12.0;
p.shape.attackFrames = 0;
p.shape.decayFrames = 1000;
p.shape.holdFraction = 0.0;
PitchEnvelope a, b;
a.configure(p);
b.configure(p);
a.configure(100000, p);
b.configure(100000, p);
a.noteOn();
b.noteOn();
for (int i = 0; i < 400; ++i) { a.tick(); b.tick(); }
b.applyLive(0, 2000, 12.0); // decay doubled mid-decay
PitchEnvParams longer = p;
longer.shape.decayFrames = 2000;
b.applyLive(longer); // decay doubled mid-decay
CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame
// A depth move is a level step, so it glides rather than jumping: the first frame after
// the edit is exactly what the unedited peer emits.
PitchEnvelope c, d;
c.configure(p);
d.configure(p);
c.configure(100000, p);
d.configure(100000, p);
c.noteOn();
d.noteOn();
for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); }
c.applyLive(0, 1000, 0.0); // depth to zero mid-decay
PitchEnvParams noDepth = p;
noDepth.peakSemitones = 0.0;
c.applyLive(noDepth); // depth to zero mid-decay
CHECK(c.tick() == d.tick());
// ...and it does eventually reach the new depth rather than staying put.
for (int i = 0; i < 400; ++i) c.tick();
CHECK(c.tick() == 0.0);
}
// The pitch envelope's new middle stage, on the same phi rule: a hold dialled mid-hold keeps
// the level (flat by definition) and moves the boundary, and the fraction is taken against
// what attack and decay left rather than against the whole span.
static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() {
PitchEnvParams p;
p.enabled = true;
p.peakSemitones = 12.0;
p.shape.attackFrames = 100;
p.shape.decayFrames = 100;
p.shape.holdFraction = 0.5; // half of (1000 - 200) = 400 frames of hold
PitchEnvelope e;
e.configure(1000, p);
e.noteOn();
for (int i = 0; i < 100; ++i) e.tick(); // through the attack
CHECK(e.tick() == 12.0); // frame 100: at the peak, holding
for (int i = 0; i < 398; ++i) e.tick(); // to the last frame of the hold
CHECK(e.tick() == 12.0); // frame 499: still holding
CHECK(e.tick() == 12.0); // frame 500: decay's own first frame
CHECK(std::fabs(e.tick() - 12.0 * (1.0 - 1.0 / 100.0)) < 1e-12); // frame 501: descending
// A live hold change mid-hold is continuous (the stage is flat) and the envelope still
// finishes inside the span.
PitchEnvelope f;
f.configure(1000, p);
f.noteOn();
for (int i = 0; i < 300; ++i) f.tick();
PitchEnvParams wider = p;
wider.shape.holdFraction = 1.0;
f.applyLive(wider);
CHECK(f.tick() == 12.0);
for (int i = 0; i < 1200; ++i) f.tick();
CHECK(f.tick() == 0.0);
}
// --- The fresh-note path: snap, never the phi rule ---------------------------------------
static void testAFreshEnvelopeTakesANewlyDialledStageTimeOutright() {
@@ -258,9 +298,12 @@ static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() {
PitchEnvParams stale; // enabled, but every leg zero
stale.enabled = true;
PitchEnvelope env;
env.configure(stale);
env.configure(100000, stale);
env.noteOn();
env.snapLive(0, 1000, 12.0);
PitchEnvParams dialled = stale;
dialled.peakSemitones = 12.0;
dialled.shape.decayFrames = 1000;
env.snapLive(dialled);
CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope
for (int i = 0; i < 499; ++i) env.tick();
CHECK(std::fabs(env.tick() - 6.0) < 1e-12);
@@ -412,25 +455,25 @@ static void testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote() {
{"pitch env attack",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.attackFrames = 48000;
s.play.pitchEnv.decayFrames = 48000;
s.play.pitchEnv.shape.attackFrames = 48000;
s.play.pitchEnv.shape.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvAttackFrames = 4000; }, -1},
[](LiveValues& v) { v.pitchEnv.shape.attackFrames = 4000; }, -1},
{"pitch env decay",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.decayFrames = 48000;
s.play.pitchEnv.shape.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvDecayFrames = 8000; }, -1},
[](LiveValues& v) { v.pitchEnv.shape.decayFrames = 8000; }, -1},
{"pitch env depth",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.decayFrames = 480000;
s.play.pitchEnv.shape.decayFrames = 480000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvPeakSemitones = 0.0; }, -1},
[](LiveValues& v) { v.pitchEnv.peakSemitones = 0.0; }, -1},
};
for (const Case& c : cases) {
assertLiveFieldMovesTheSoundingNote(c.name, c.rig, c.mutate, c.noteOffBlock);
@@ -646,9 +689,9 @@ static void testPitchRatioAndVelocityGainStayLatched() {
hostile.filterKeyTrack = 2.0;
hostile.filterSettings.cutoffNorm = 0.0f;
hostile.filterModAmount = 1.0;
hostile.pitchEnvAttackFrames = 4800;
hostile.pitchEnvDecayFrames = 4800;
hostile.pitchEnvPeakSemitones = 24.0;
hostile.pitchEnv.shape.attackFrames = 4800;
hostile.pitchEnv.shape.decayFrames = 4800;
hostile.pitchEnv.peakSemitones = 24.0;
hostile.adsr.attackFrames = 96000; // a timed stage the voice is already past
for (int blk = 0; blk < 8; ++blk) {
if (blk == 2) block.publish(hostile);
@@ -696,7 +739,7 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
filterSweep(rig);
rig.play.filter.velAmount = 0.0;
rig.play.pitchEnv.enabled = true;
rig.play.pitchEnv.decayFrames = 24000;
rig.play.pitchEnv.shape.decayFrames = 24000;
rig.play.pitchEnv.peakSemitones = 3.0;
LiveValues hostile = foldLive(rig.play);
@@ -705,9 +748,9 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
hostile.filterModAmount = -1.0;
hostile.filterEnv.decayFrames = 4800;
hostile.filterEnv.sustainLevel = 0.0;
hostile.pitchEnvAttackFrames = 4800;
hostile.pitchEnvDecayFrames = 4800;
hostile.pitchEnvPeakSemitones = 24.0;
hostile.pitchEnv.shape.attackFrames = 4800;
hostile.pitchEnv.shape.decayFrames = 4800;
hostile.pitchEnv.peakSemitones = 24.0;
hostile.adsr.sustainLevel = 0.4;
SampleData quiet = rig, loud = rig, untouched = rig;
@@ -743,6 +786,7 @@ int main() {
testShortenedStageStillLandsContinuously();
testSustainLevelChangeGlides();
testPitchEnvelopeHoldsPhaseAndGlidesDepth();
testPitchEnvelopeHoldStagePlaysAndHoldsPhase();
testAFreshEnvelopeTakesANewlyDialledStageTimeOutright();
testAFreshPitchEnvelopeTakesTheNewTimesOutright();
testCutoffMoveAcrossPrepareDoesNotStep();
+21 -6
View File
@@ -27,7 +27,9 @@ static_assert(std::is_trivially_copyable_v<LiveValues>, "the live block must sta
static void testFoldCarriesEveryContinuousControl() {
PlayParams p;
p.adsr = AdsrParams{11, 22, 33, 0.44, 55};
p.adsr = AdsrParams{11, 22, 33, 0.44, 55, 2.0, 0.5, 3.0};
p.trigAhd = AhdParams{61, 62, 0.63, 4.0, 0.25};
p.filter.trigEnv = AhdParams{71, 72, 0.73, 5.0, 0.2};
p.filter.enabled = true;
p.filter.settings.cutoffNorm = 0.25f;
p.filter.settings.resonanceNorm = 0.5f;
@@ -36,8 +38,7 @@ static void testFoldCarriesEveryContinuousControl() {
p.filter.modAmount = -0.6;
p.filter.keyTrack = 1.5;
p.filter.env = AdsrParams{1, 2, 3, 0.4, 5};
p.pitchEnv.attackFrames = 7;
p.pitchEnv.decayFrames = 9;
p.pitchEnv.shape = AhdParams{7, 9, 0.4, 1.5, 0.75};
p.pitchEnv.peakSemitones = -3.5;
const LiveValues v = foldLive(p);
@@ -46,6 +47,17 @@ static void testFoldCarriesEveryContinuousControl() {
CHECK(v.adsr.decayFrames == 33);
CHECK(v.adsr.sustainLevel == 0.44);
CHECK(v.adsr.releaseFrames == 55);
CHECK(v.adsr.attackCurve == 2.0);
CHECK(v.adsr.decayCurve == 0.5);
CHECK(v.adsr.releaseCurve == 3.0);
CHECK(v.ampAhd.attackFrames == 61);
CHECK(v.ampAhd.decayFrames == 62);
CHECK(v.ampAhd.holdFraction == 0.63);
CHECK(v.ampAhd.attackCurve == 4.0);
CHECK(v.ampAhd.decayCurve == 0.25);
CHECK(v.filterAhd.attackFrames == 71);
CHECK(v.filterAhd.holdFraction == 0.73);
CHECK(v.filterAhd.decayCurve == 0.2);
CHECK(v.filterSettings.cutoffNorm == 0.25f);
CHECK(v.filterSettings.resonanceNorm == 0.5f);
CHECK(v.filterSettings.morphNorm == 0.75f);
@@ -54,9 +66,12 @@ static void testFoldCarriesEveryContinuousControl() {
CHECK(v.filterKeyTrack == 1.5);
CHECK(v.filterEnv.decayFrames == 3);
CHECK(v.filterEnv.sustainLevel == 0.4);
CHECK(v.pitchEnvAttackFrames == 7);
CHECK(v.pitchEnvDecayFrames == 9);
CHECK(v.pitchEnvPeakSemitones == -3.5);
CHECK(v.pitchEnv.shape.attackFrames == 7);
CHECK(v.pitchEnv.shape.decayFrames == 9);
CHECK(v.pitchEnv.shape.holdFraction == 0.4);
CHECK(v.pitchEnv.shape.attackCurve == 1.5);
CHECK(v.pitchEnv.shape.decayCurve == 0.75);
CHECK(v.pitchEnv.peakSemitones == -3.5);
}
static void testUnpublishedBlockReadsAsNothing() {
+28 -12
View File
@@ -566,8 +566,8 @@ static void testLegacyLiftDecision() {
// --- resolvePlay: stored SECONDS -> engine FRAMES at the live rate --------------
static void testResolvePlayConvertsWallClockAtTheRate() {
// Wall-clock times convert at the LIVE rate; source-timeline quantities (the Trigger
// %-length and its fades) carry through untouched, and levels/depths are not times.
// Wall-clock times convert at the LIVE rate; the Trigger %-length, every hold FRACTION and
// every curve exponent are rate-free and carry through untouched, as do levels and depths.
PlaySeconds st;
st.playMode = PlayMode::Trigger;
st.adsr.attackSeconds = 0.01;
@@ -575,13 +575,20 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
st.adsr.decaySeconds = 0.02;
st.adsr.sustainLevel = 0.8;
st.adsr.releaseSeconds = 0.15;
st.adsr.attackCurve = 2.5;
st.adsr.decayCurve = 0.4;
st.adsr.releaseCurve = 3.5;
st.trigger.lengthFraction = 0.75;
st.trigger.fadeInFrames = 441;
st.trigger.fadeOutFrames = 882;
st.trigAhd.attackSeconds = 0.01;
st.trigAhd.decaySeconds = 0.02;
st.trigAhd.holdFraction = 0.6;
st.trigAhd.attackCurve = 1.75;
st.trigAhd.decayCurve = 0.8;
st.pitchEngine = PitchEngine::Preserve;
st.pitchEnv.enabled = true;
st.pitchEnv.attackSeconds = 0.02;
st.pitchEnv.decaySeconds = 0.03;
st.pitchEnv.shape.attackSeconds = 0.02;
st.pitchEnv.shape.decaySeconds = 0.03;
st.pitchEnv.shape.holdFraction = 0.25;
st.pitchEnv.peakSemitones = 5.0;
const PlayParams at48 = resolvePlay(st, 48000);
@@ -591,13 +598,20 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
CHECK(at48.adsr.decayFrames == 960);
CHECK(at48.adsr.sustainLevel == 0.8); // a level, not a time
CHECK(at48.adsr.releaseFrames == 7200);
CHECK(at48.adsr.attackCurve == 2.5); // dimensionless
CHECK(at48.adsr.decayCurve == 0.4);
CHECK(at48.adsr.releaseCurve == 3.5);
CHECK(at48.trigger.lengthFraction == 0.75); // source-timeline, unconverted
CHECK(at48.trigger.fadeInFrames == 441);
CHECK(at48.trigger.fadeOutFrames == 882);
CHECK(at48.trigAhd.attackFrames == 480);
CHECK(at48.trigAhd.decayFrames == 960);
CHECK(at48.trigAhd.holdFraction == 0.6); // a fraction, not a time
CHECK(at48.trigAhd.attackCurve == 1.75);
CHECK(at48.trigAhd.decayCurve == 0.8);
CHECK(at48.pitchEngine == PitchEngine::Preserve);
CHECK(at48.pitchEnv.enabled);
CHECK(at48.pitchEnv.attackFrames == 960);
CHECK(at48.pitchEnv.decayFrames == 1440);
CHECK(at48.pitchEnv.shape.attackFrames == 960);
CHECK(at48.pitchEnv.shape.decayFrames == 1440);
CHECK(at48.pitchEnv.shape.holdFraction == 0.25);
CHECK(at48.pitchEnv.peakSemitones == 5.0); // a depth, not a time
// THE no-hardcoded-rate contract: the SAME stored seconds yield different frame counts
@@ -606,8 +620,10 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
CHECK(at96.adsr.attackFrames == 960);
CHECK(at96.adsr.holdFrames == 4800);
CHECK(at96.adsr.releaseFrames == 14400);
CHECK(at96.pitchEnv.attackFrames == 1920);
CHECK(at96.trigger.fadeInFrames == 441); // still unconverted
CHECK(at96.pitchEnv.shape.attackFrames == 1920);
CHECK(at96.trigAhd.attackFrames == 960);
CHECK(at96.trigAhd.holdFraction == 0.6); // still unconverted
CHECK(at96.adsr.attackCurve == 2.5);
}
static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
+18 -12
View File
@@ -1023,17 +1023,20 @@ static void testAhdsrHoldZeroEqualsAdsr() {
}
// A trigger-mode DC sample (all 1.0) so a rendered voice's output tracks the trigger envelope
// * velocity directly. `play` sets Trigger mode + params; Varispeed so no shift colours the amp.
// * velocity directly. `attack`/`decay` are the AHD's ramp lengths in frames, with Hold taking
// the whole remainder — the shape that replaced the retired fade pair. Varispeed so no shift
// colours the amp.
static SampleData triggerSample(std::size_t frames, double lengthFraction,
std::int64_t fadeIn, std::int64_t fadeOut,
std::int64_t attack, std::int64_t decay,
std::int64_t startFrame = 0) {
SampleData s = dcSample(frames, 60);
s.startFrame = startFrame;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate amp shape from pitch
s.play.trigger.lengthFraction = lengthFraction;
s.play.trigger.fadeInFrames = fadeIn;
s.play.trigger.fadeOutFrames = fadeOut;
s.play.trigAhd.attackFrames = attack;
s.play.trigAhd.decayFrames = decay;
s.play.trigAhd.holdFraction = 1.0;
return s;
}
@@ -1181,10 +1184,13 @@ static void testPreserveDurationInvariance() {
const std::size_t atUp = lengthAt(72); // +12
const std::size_t atDown = lengthAt(48); // -12
// All three within a small tolerance of the source length (Preserve holds duration). The
// tolerance covers the shifter's fill/latency edge, not a duration scaling (which would be 2x).
CHECK(atRoot >= frames - 20 && atRoot <= frames + 20);
CHECK(atUp >= frames - 20 && atUp <= frames + 20);
CHECK(atDown >= frames - 20 && atDown <= frames + 20);
// tolerance covers the shifter's fill/latency edge and the terminal ring-out Preserve ends
// on (voice.h's seedTerminalDeclick — bounded by the declick floor at ~185 frames), not a
// duration scaling, which would be 2x.
const std::size_t kTail = 200;
CHECK(atRoot >= frames - 20 && atRoot <= frames + kTail);
CHECK(atUp >= frames - 20 && atUp <= frames + kTail);
CHECK(atDown >= frames - 20 && atDown <= frames + kTail);
// The decisive assertion: the up/down lengths track the root length (NOT halved/doubled).
CHECK(atUp > frames / 2 + 200); // an octave up did NOT halve the duration (Varispeed would)
CHECK(atDown < frames * 2 - 200); // an octave down did NOT double it
@@ -1220,8 +1226,8 @@ static void testPitchEnvOffBitIdentical() {
if (withDisabledEnv) {
s.play.pitchEnv.enabled = false; // explicitly disabled (offset always 0)
s.play.pitchEnv.peakSemitones = 12.0; // a depth that WOULD matter if enabled
s.play.pitchEnv.attackFrames = 0;
s.play.pitchEnv.decayFrames = 500;
s.play.pitchEnv.shape.attackFrames = 0;
s.play.pitchEnv.shape.decayFrames = 500;
}
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
@@ -1248,8 +1254,8 @@ static void testPitchEnvOnBendsVarispeed() {
SampleData s = sineSample(n, 40.0, 60);
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.attackFrames = 0; // start at the peak
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.shape.attackFrames = 0; // start at the peak
s.play.pitchEnv.shape.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
+529
View File
@@ -0,0 +1,529 @@
// Standalone tests for the STAGED ENVELOPE system in the pure engine — no VST3, no REAPER, no
// framework. The engine's other seams are covered by sampler_core_tests (allocation, repitch,
// loops), sampler_filter_tests (the filter in the voice path) and live_delivery_tests (what a
// published block does to a sounding voice); this file covers what shape the envelopes have.
//
// Covers: the LINEAR NEUTRAL (exponent 1.0 reproduces the pre-curve evaluation bit for bit on
// every sloped stage of all three envelopes); the exponent sweep across the full domain
// (finite, monotone within a stage, never past the stage's endpoint levels); the AHD span split
// (A+H+D can never exceed the span, for any triple, with no clamp on the sum; hold at 0% and
// 100%); the Gate/Trigger shape switch on both the amp and the filter envelope, with each
// mode's stage values surviving the other; and the Trigger tail's terminal behaviour under
// Preserve in both voice modes, against a Varispeed render that must not change.
#include "../src/core/instrument/engine/voice_engine.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <vector>
using namespace reasampler;
using namespace reasampler::instrument::engine;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
constexpr double kPi = 3.14159265358979323846;
static SampleData dcSample(std::size_t frames, int rootNote = 60) {
SampleData s;
s.frames.assign(frames, 1.0f);
s.rootNote = rootNote;
s.sampleRate = 48000;
return s;
}
// --- The linear neutral -------------------------------------------------------
// The migration bar, at the evaluator: with every exponent at 1.0 each sloped stage emits
// EXACTLY the closed-form linear value the pre-curve engine emitted. Bit-identical, not close:
// a hair of drift here is a project reopening with a different sound.
static void testNeutralExponentReproducesTheLinearEvaluationExactly() {
// AHDSR attack, decay and release, each measured over its whole span.
AdsrParams p;
p.attackFrames = 400;
p.holdFrames = 0;
p.decayFrames = 500;
p.sustainLevel = 0.25;
p.releaseFrames = 300;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
for (int i = 0; i < 400; ++i) {
CHECK(env.tick() == static_cast<double>(i) / 400.0);
}
for (int i = 0; i < 500; ++i) {
CHECK(env.tick() == 1.0 + (0.25 - 1.0) * (static_cast<double>(i) / 500.0));
}
CHECK(env.tick() == 0.25); // sustain
env.noteOff();
for (int i = 0; i < 300; ++i) {
CHECK(env.tick() == 0.25 * (1.0 - static_cast<double>(i) / 300.0));
}
// The AHD's two sloped stages, against the same closed forms.
AhdParams a;
a.attackFrames = 200;
a.decayFrames = 300;
a.holdFraction = 0.0;
AhdEnvelope ahd;
ahd.configure(1000, a);
for (int i = 0; i < 200; ++i) {
CHECK(ahd.amplitudeAt(i) == static_cast<double>(i) / 200.0);
}
for (int i = 0; i < 300; ++i) {
CHECK(ahd.amplitudeAt(200 + i) == 1.0 - static_cast<double>(i) / 300.0);
}
// The pitch envelope's two, scaled by the depth.
PitchEnvParams pe;
pe.enabled = true;
pe.peakSemitones = 12.0;
pe.shape.attackFrames = 200;
pe.shape.decayFrames = 300;
pe.shape.holdFraction = 0.0;
PitchEnvelope pitch;
pitch.configure(1000, pe);
pitch.noteOn();
for (int i = 0; i < 200; ++i) {
CHECK(pitch.tick() == 12.0 * (static_cast<double>(i) / 200.0));
}
for (int i = 0; i < 300; ++i) {
CHECK(pitch.tick() == 12.0 * (1.0 - static_cast<double>(i) / 300.0));
}
}
// --- The exponent sweep -------------------------------------------------------
// The whole domain including both endpoints: every emitted value finite, inside the stage's
// own endpoint levels, and monotone in the stage's direction. An exponent can reshape a stage
// but never make it overshoot or wander.
static void testExponentSweepStaysFiniteMonotoneAndInRange() {
const double exps[] = {util::kCurveMin, 0.3, 0.7, 1.0, 2.0, 5.0, util::kCurveMax};
for (double e : exps) {
AdsrParams p;
p.attackFrames = 256;
p.decayFrames = 256;
p.sustainLevel = 0.3;
p.releaseFrames = 256;
p.attackCurve = e;
p.decayCurve = e;
p.releaseCurve = e;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
double prev = -1.0;
for (int i = 0; i < 256; ++i) { // attack: rises 0 -> 1
const double v = env.tick();
CHECK(std::isfinite(v));
CHECK(v >= 0.0 && v <= 1.0);
CHECK(v >= prev);
prev = v;
}
prev = 2.0;
for (int i = 0; i < 256; ++i) { // decay: falls 1 -> sustain, never below it
const double v = env.tick();
CHECK(std::isfinite(v));
CHECK(v >= 0.3 - 1e-12 && v <= 1.0 + 1e-12);
CHECK(v <= prev);
prev = v;
}
env.tick(); // sustain
env.noteOff();
prev = 2.0;
for (int i = 0; i < 256; ++i) { // release: falls to 0, never below
const double v = env.tick();
CHECK(std::isfinite(v));
CHECK(v >= -1e-12 && v <= 0.3 + 1e-12);
CHECK(v <= prev);
prev = v;
}
// The AHD's own two stages under the same exponent.
AhdParams a;
a.attackFrames = 256;
a.decayFrames = 256;
a.holdFraction = 0.0;
a.attackCurve = e;
a.decayCurve = e;
AhdEnvelope ahd;
ahd.configure(1000, a);
prev = -1.0;
for (int i = 0; i < 256; ++i) {
const double v = ahd.amplitudeAt(i);
CHECK(std::isfinite(v) && v >= 0.0 && v <= 1.0 && v >= prev);
prev = v;
}
prev = 2.0;
for (int i = 0; i < 256; ++i) {
const double v = ahd.amplitudeAt(256 + i);
CHECK(std::isfinite(v) && v >= 0.0 && v <= 1.0 && v <= prev);
prev = v;
}
}
}
// The exponent has to be AUDIBLE, not merely stored: the same stage at two exponents renders
// measurably different levels at the same position.
static void testExponentActuallyReshapesTheStage() {
AhdParams a;
a.attackFrames = 1000;
a.holdFraction = 0.0;
AhdEnvelope steep, shallow;
a.attackCurve = 8.0;
steep.configure(2000, a);
a.attackCurve = 0.15;
shallow.configure(2000, a);
CHECK(steep.amplitudeAt(500) < 0.05);
CHECK(shallow.amplitudeAt(500) > 0.85);
CHECK(shallow.amplitudeAt(500) - steep.amplitudeAt(500) > 0.5);
}
// --- The AHD span split -------------------------------------------------------
// The combined-time bound, swept across the full domains: no (attack, decay, hold-fraction)
// triple can push the sum past the span. The property is structural — Hold is a fraction of
// what is LEFT — so there is no clamp on the sum for a case to slip past.
static void testAhdSumNeverExceedsTheSpanForAnyTriple() {
const std::int64_t span = 1000;
for (std::int64_t a = 0; a <= 2000; a += 125) {
for (std::int64_t d = 0; d <= 2000; d += 125) {
for (int f = 0; f <= 10; ++f) {
AhdParams p;
p.attackFrames = a;
p.decayFrames = d;
p.holdFraction = f * 0.1;
const AhdSpan s = fitAhd(span, p);
CHECK(s.attack >= 0 && s.hold >= 0 && s.decay >= 0);
CHECK(s.total == s.attack + s.hold + s.decay);
CHECK(s.total <= span);
// And the envelope itself is silent at and past the fitted total.
AhdEnvelope e;
e.configure(span, p);
CHECK(e.amplitudeAt(static_cast<double>(s.total)) == 0.0);
}
}
}
}
static void testHoldFractionEndpoints() {
AhdParams p;
p.attackFrames = 100;
p.decayFrames = 200;
p.holdFraction = 0.0;
const AhdSpan none = fitAhd(1000, p);
CHECK(none.hold == 0); // 0% takes no time at all
CHECK(none.total == 300);
p.holdFraction = 1.0;
const AhdSpan full = fitAhd(1000, p);
CHECK(full.hold == 700); // exactly the remainder after attack and decay
CHECK(full.total == 1000);
// A negative/NaN fraction degrades to none rather than to a negative stage.
p.holdFraction = -1.0;
CHECK(fitAhd(1000, p).hold == 0);
p.holdFraction = std::nan("");
CHECK(fitAhd(1000, p).hold == 0);
}
// --- The Gate/Trigger shape switch --------------------------------------------
// Each mode plays its OWN stage values: the parameter set carries both, so flipping to Trigger
// and back cannot lose either mode's dialled envelope. Asserted on rendered output, not on the
// struct — a voice reading the wrong field would still store the right one.
static void testEachModePlaysItsOwnStageValuesAndTheOtherSurvives() {
SampleData s = dcSample(4000);
// Gate: a slow attack. Trigger: an instant onset and a long decay. Deliberately opposite,
// so a voice reading the wrong shape is unmistakable.
s.play.adsr.attackFrames = 2000;
s.play.adsr.sustainLevel = 1.0;
s.play.trigAhd.attackFrames = 0;
s.play.trigAhd.decayFrames = 2000;
s.play.trigAhd.holdFraction = 0.0;
const auto renderFirst = [&](PlayMode mode) {
SampleData copy = s;
copy.play.playMode = mode;
VoiceEngine eng(1, copy);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 1000);
return out;
};
const std::vector<AudioSample> gate = renderFirst(PlayMode::Gate);
CHECK(gate[0] < 0.01f); // halfway up a 2000-frame attack
CHECK(std::fabs(gate[999] - 999.0f / 2000.0f) < 1e-3f);
const std::vector<AudioSample> trig = renderFirst(PlayMode::Trigger);
CHECK(trig[0] > 0.99f); // instant onset
CHECK(std::fabs(trig[999] - (1.0f - 999.0f / 2000.0f)) < 1e-3f);
// Back to Gate: the AHDSR values were never touched by the excursion.
const std::vector<AudioSample> again = renderFirst(PlayMode::Gate);
for (std::size_t i = 0; i < gate.size(); ++i) CHECK(again[i] == gate[i]);
}
// The same switch on the FILTER envelope, which follows the amp's rule rather than its own:
// the two shapes are stored side by side and each mode reads only its own.
static void testFilterEnvelopeFollowsTheModeShape() {
SampleData s = dcSample(4000);
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 0.1f;
s.play.filter.modAmount = 0.9;
// Gate: the filter envelope opens slowly. Trigger: it opens instantly and closes.
s.play.filter.env.attackFrames = 2000;
s.play.filter.env.sustainLevel = 1.0;
s.play.filter.trigEnv.attackFrames = 0;
s.play.filter.trigEnv.decayFrames = 2000;
s.play.filter.trigEnv.holdFraction = 0.0;
const auto brightnessAt = [&](PlayMode mode, std::size_t frame) {
SampleData copy = s;
copy.play.playMode = mode;
// A DC source through a swept low-pass: the settled level tracks the corner, so the
// rendered value at a frame is a proxy for how far the envelope has opened it.
VoiceEngine eng(1, copy);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, frame + 1);
return static_cast<double>(out[frame]);
};
// Gate opens over time; Trigger starts open and closes. The orderings invert, which cannot
// happen if both modes read one envelope.
CHECK(brightnessAt(PlayMode::Gate, 20) < brightnessAt(PlayMode::Gate, 1500));
CHECK(brightnessAt(PlayMode::Trigger, 20) > brightnessAt(PlayMode::Trigger, 1500));
}
// --- The Trigger tail (item 4) ------------------------------------------------
// The largest sample-to-sample step in the last `window` frames a voice actually produced,
// plus where the voice stopped. A hard cut at a non-zero level shows up here as a step the
// size of that level.
struct TailMeasure {
double worstStep = 0.0;
double lastLevel = 0.0;
std::size_t soundingFrames = 0;
};
static TailMeasure renderTail(VoiceMode voiceMode, PitchEngine engine, std::size_t maxFrames) {
// A sine, not DC: the shifter's splice machinery needs real waveform to recycle, and a DC
// source would hide exactly the discontinuity under test.
SampleData s;
s.frames.resize(4000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
}
s.sampleRate = 48000;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = engine;
s.play.trigger.lengthFraction = 1.0;
// The abrupt-end case the spec keeps representable: zero decay, so nothing in the ENVELOPE
// hides a discontinuity at the sample end.
s.play.trigAhd.attackFrames = 0;
s.play.trigAhd.decayFrames = 0;
s.play.trigAhd.holdFraction = 1.0;
VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512, voiceMode);
eng.noteOn(67, 127); // transposed, so Preserve genuinely runs its shifter
TailMeasure m;
std::vector<AudioSample> out;
for (std::size_t f = 0; f < maxFrames; ++f) {
eng.render(out, 1);
if (eng.activeVoiceCount() == 0) break;
m.soundingFrames = f + 1;
}
// Include the frame after the voice freed: the cut itself is the step from the last
// sounding sample to the silence that follows it.
const std::size_t end = std::min(m.soundingFrames + 1, out.size());
for (std::size_t i = 1; i < end; ++i) {
m.worstStep = std::max(m.worstStep,
std::fabs(static_cast<double>(out[i]) -
static_cast<double>(out[i - 1])));
}
if (m.soundingFrames > 0) m.lastLevel = std::fabs(static_cast<double>(out[end - 1]));
return m;
}
// A Trigger one-shot in Preserve must end without a terminal discontinuity, in both voice
// modes. The threshold is stated rather than eyeballed: the source's own steepest
// sample-to-sample slope is 0.8*2*pi/40 ~= 0.126, so the cut must not exceed what the waveform
// itself already does. (Neither of us can judge this by ear — this is the measurable proxy;
// the audible check is Daniel's.)
static void testTriggerPreserveEndsWithoutATerminalDiscontinuity() {
const double kSourceSlope = 0.8 * 2.0 * kPi / 40.0;
for (VoiceMode vm : {VoiceMode::Poly, VoiceMode::Mono}) {
const TailMeasure m = renderTail(vm, PitchEngine::Preserve, 8000);
CHECK(m.soundingFrames > 0);
CHECK(m.worstStep <= kSourceSlope * 1.5);
// And the voice genuinely reaches silence rather than being left ringing.
CHECK(m.lastLevel < 1e-3);
}
}
// The same cut on the peer path: an AHD whose stages end BEFORE the play span (hold under
// 100% with a zero decay) stops the voice mid-tail, and under Preserve that tail is just as
// synthetic as the one at the sample end.
static void testTriggerPreserveAhdEndingEarlyAlsoRingsOut() {
SampleData s;
s.frames.resize(4000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
}
s.sampleRate = 48000;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Preserve;
s.play.trigger.lengthFraction = 1.0;
s.play.trigAhd.attackFrames = 0;
s.play.trigAhd.decayFrames = 0;
s.play.trigAhd.holdFraction = 0.25; // ends at ~1000 frames, far short of the 4000-frame span
VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(67, 127);
std::vector<AudioSample> out;
std::size_t sounding = 0;
for (std::size_t f = 0; f < 4000; ++f) {
eng.render(out, 1);
if (eng.activeVoiceCount() == 0) break;
sounding = f + 1;
}
CHECK(sounding > 900 && sounding < 1400); // the AHD ended, not the span
double worst = 0.0;
const std::size_t end = std::min(sounding + 1, out.size());
for (std::size_t i = 1; i < end; ++i) {
worst = std::max(worst, std::fabs(static_cast<double>(out[i]) -
static_cast<double>(out[i - 1])));
}
CHECK(worst <= (0.8 * 2.0 * kPi / 40.0) * 1.5);
}
// Varispeed is not implicated and must be left exactly as it was: its terminal sample is real
// source content at its natural end, so no ring-out is armed there. Asserted as byte-identity
// between two renders of the same rig, one of which would differ if the Preserve-only guard
// were ever widened.
static void testVarispeedTailIsUntouched() {
const auto render = [](std::size_t frames) {
SampleData s;
s.frames.resize(2000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
}
s.sampleRate = 48000;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigger.lengthFraction = 1.0;
s.play.trigAhd.holdFraction = 1.0;
VoiceEngine eng(1, s);
eng.noteOn(60, 127); // unity ratio: the read head walks the source frame for frame
std::vector<AudioSample> out;
eng.render(out, frames);
return out;
};
const std::vector<AudioSample> out = render(2400);
// Unity Varispeed reproduces the source exactly through its span, then stops dead — the
// pre-change behaviour, with no ring-out appended.
for (std::size_t i = 0; i < 2000; ++i) {
CHECK(out[i] == static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0)));
}
for (std::size_t i = 2000; i < out.size(); ++i) CHECK(out[i] == 0.0f);
}
// --- Migration contour --------------------------------------------------------
// The retired fade pair was an EQUAL-POWER ramp (sin/cos); the AHD that replaced it is the
// curve law's neutral, which is LINEAR. Migration preserves the stage LENGTHS exactly, so the
// contour tracks the old one to within the fixed sin(x)-vs-x gap — max |sin(t*pi/2) - t| over
// [0,1], which is ~0.2105 at t ~= 0.4. Stated as the measured bound rather than judged: whether
// that difference matters is Daniel's call, not this test's.
static void testMigratedFadeContourMatchesTheRetiredShapeWithinTheStatedBound() {
const std::int64_t span = 1000;
const std::int64_t fadeIn = 200;
const std::int64_t fadeOut = 300;
AhdParams migrated;
migrated.attackFrames = fadeIn; // Attack <- fade-in
migrated.decayFrames = fadeOut; // Decay <- fade-out
migrated.holdFraction = 1.0; // Hold <- the whole remainder
AhdEnvelope ahd;
ahd.configure(span, migrated);
// Stage LENGTHS are exact: the fades land on the same frames they always did.
CHECK(ahd.stages().attack == fadeIn);
CHECK(ahd.stages().decay == fadeOut);
CHECK(ahd.stages().total == span);
// The pre-change evaluator, written out so the comparison is against a stated reference
// rather than against whatever the code now does.
const auto retired = [&](double off) {
if (off < 0.0 || off >= static_cast<double>(span)) return 0.0;
if (off < static_cast<double>(fadeIn)) {
return std::sin(off / static_cast<double>(fadeIn) * (kPi / 2.0));
}
const double foStart = static_cast<double>(span - fadeOut);
if (off >= foStart) {
return std::cos((off - foStart) / static_cast<double>(fadeOut) * (kPi / 2.0));
}
return 1.0;
};
double worst = 0.0;
for (std::int64_t i = 0; i < span; ++i) {
worst = std::max(worst, std::fabs(ahd.amplitudeAt(static_cast<double>(i)) -
retired(static_cast<double>(i))));
}
CHECK(worst <= 0.2106); // the sin-vs-linear bound, and nothing beyond it
// Both agree exactly where it matters structurally: the onset, the plateau, and the end.
CHECK(ahd.amplitudeAt(0.0) == retired(0.0));
CHECK(ahd.amplitudeAt(600.0) == retired(600.0));
CHECK(ahd.amplitudeAt(static_cast<double>(span)) == retired(static_cast<double>(span)));
}
// A prior ZERO fade-out migrates to Decay = 0 and keeps the abrupt end the old controls could
// express — nothing the retired mechanism could say is lost.
static void testZeroFadeOutMigratesToAnAbruptEnd() {
AhdParams migrated;
migrated.attackFrames = 0;
migrated.decayFrames = 0;
migrated.holdFraction = 1.0;
AhdEnvelope ahd;
ahd.configure(500, migrated);
CHECK(ahd.amplitudeAt(0.0) == 1.0);
CHECK(ahd.amplitudeAt(499.0) == 1.0); // still at unity on the last frame
CHECK(ahd.amplitudeAt(500.0) == 0.0); // and off on the next
CHECK(ahd.finished());
}
int main() {
testNeutralExponentReproducesTheLinearEvaluationExactly();
testExponentSweepStaysFiniteMonotoneAndInRange();
testExponentActuallyReshapesTheStage();
testAhdSumNeverExceedsTheSpanForAnyTriple();
testHoldFractionEndpoints();
testEachModePlaysItsOwnStageValuesAndTheOtherSurvives();
testFilterEnvelopeFollowsTheModeShape();
testTriggerPreserveEndsWithoutATerminalDiscontinuity();
testTriggerPreserveAhdEndingEarlyAlsoRingsOut();
testVarispeedTailIsUntouched();
testMigratedFadeContourMatchesTheRetiredShapeWithinTheStatedBound();
testZeroFadeOutMigratesToAnAbruptEnd();
if (g_fail == 0) {
std::printf("all staged_envelopes tests passed\n");
return 0;
}
std::printf("%d staged_envelopes check(s) failed\n", g_fail);
return 1;
}
+22
View File
@@ -121,6 +121,27 @@ static void testSecondaryTertiaryAreDistinguishable() {
CHECK(delta >= 60);
}
// The instrument's envelope overlay is traced OVER the waveform, which draws in the primary
// accent — an accent-on-accent pair no floor covers, since neither is a surface. It moved from
// the secondary to the tertiary for exactly this reason, so the pair is pinned two ways: the
// tertiary must separate from the primary MORE than the secondary did (the measurable half of
// the move), and the separation is a hue one, since two pastels sit close in luminance by
// construction. Whether the result reads clearly is a perceptual call, not this test's.
static void testOverlayAccentSeparatesFromTheWaveformAccent() {
const KitColor wave = roleColor(Role::AccentPrimary);
const KitColor overlay = roleColor(Role::AccentTertiary);
const KitColor prior = roleColor(Role::AccentSecondary);
CHECK(contrastRatio(overlay, wave) > contrastRatio(prior, wave));
// Hue divergence against the waveform: the waveform's green dominates its red, the
// overlay's red dominates its green — opposite balances, not two shades of one.
CHECK(wave.g > wave.r);
CHECK(overlay.r > overlay.g);
const int delta = std::abs(int(wave.r) - int(overlay.r)) +
std::abs(int(wave.g) - int(overlay.g)) +
std::abs(int(wave.b) - int(overlay.b)) ;
CHECK(delta >= 60);
}
static void testWarnClearsStateFloorOnBackground() {
// warn (destructive) must be unmistakable -> clears the state floor on the base.
CHECK(contrastRatio(roleColor(Role::Warn), roleColor(Role::BgBase))
@@ -237,6 +258,7 @@ int main() {
testTextOnPastelFillClearsBodyFloor();
testTextOnHoverSurfaceClearsFloor();
testSecondaryTertiaryAreDistinguishable();
testOverlayAccentSeparatesFromTheWaveformAccent();
testWarnClearsStateFloorOnBackground();
testLabelOnActiveSurfaceClearsFloor();
testRolesAreDistinctAndElevationMonotonic();
+3 -95
View File
@@ -1,10 +1,9 @@
// Standalone tests for reasampler::instrument::map::trigger_seam — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests.
//
// Covers: triggerPlayLength (zero play length, startFrame set, startFrame past frameCount,
// rounding); framesToFadeFraction (zero play length, basic ratio); fadeFractionToFrames
// (zero play length, rounding); round-trip fidelity; the Finding 1 regression (start-point
// set — the case that was broken before this module existed).
// Covers triggerPlayLength: zero play length, startFrame set, startFrame past frameCount,
// rounding, and the Finding 1 regression (start-point set — the case that was broken before
// this module existed).
#include "../src/core/instrument/map/trigger_seam.h"
@@ -58,85 +57,6 @@ static void testPlayLengthRounding() {
CHECK(triggerPlayLength(0.6, 3, 0) == 2);
}
// --- framesToFadeFraction -----------------------------------------------------
static void testFramesToFadeFractionBasic() {
// 100 frames fade over 1000 play length -> 0.1.
const double frac = framesToFadeFraction(100, 1000);
CHECK(frac > 0.0999 && frac < 0.1001);
}
static void testFramesToFadeFractionZeroPlayLength() {
// Degenerate: zero play length -> 0.0 (no division by zero).
CHECK(framesToFadeFraction(100, 0) == 0.0);
CHECK(framesToFadeFraction(0, 0) == 0.0);
}
static void testFramesToFadeFractionFullSpan() {
// fadeFrames == playLength -> fraction 1.0.
const double frac = framesToFadeFraction(500, 500);
CHECK(frac > 0.9999 && frac < 1.0001);
}
// --- fadeFractionToFrames -----------------------------------------------------
static void testFadeFractionToFramesBasic() {
// 0.1 of 1000 play length -> round(100.0) = 100.
CHECK(fadeFractionToFrames(0.1, 1000) == 100);
}
static void testFadeFractionToFramesZeroPlayLength() {
// Degenerate: play length 0 -> 0 frames.
CHECK(fadeFractionToFrames(0.5, 0) == 0);
}
static void testFadeFractionToFramesRounding() {
// 0.333... of 3 -> round(1.0) = 1.
CHECK(fadeFractionToFrames(1.0 / 3.0, 3) == 1);
// 0.5 of 3 -> round(1.5) = 2.
CHECK(fadeFractionToFrames(0.5, 3) == 2);
}
// --- Round-trip ---------------------------------------------------------------
static void testRoundTripNoStartPoint() {
// Pack then unpack: fadeInFrames should survive (within 1 frame of rounding).
// frameCount=44100, startFrame=0, lengthFraction=1.0 -> playLength=44100.
// fadeInFrames = 2205 (5% of 44100).
const std::int64_t fadeIn = 2205;
const std::int64_t playLen = triggerPlayLength(1.0, 44100, 0);
const double frac = framesToFadeFraction(fadeIn, playLen);
const std::int64_t recovered = fadeFractionToFrames(frac, playLen);
// Should be exact (2205 / 44100 * 44100 = 2205.0).
CHECK(recovered == fadeIn);
}
static void testRoundTripWithStartPoint() {
// The Finding 1 case: startFrame set. frameCount=44100, startFrame=8820 (20%).
// postStart=35280, lengthFraction=1.0 -> playLength=35280.
// fadeInFrames = 1764 (5% of 35280).
const std::int64_t frameCount = 44100;
const std::int64_t startFrame = 8820;
const std::int64_t fadeIn = 1764;
const std::int64_t playLen = triggerPlayLength(1.0, frameCount, startFrame);
CHECK(playLen == 35280);
const double frac = framesToFadeFraction(fadeIn, playLen);
const std::int64_t recovered = fadeFractionToFrames(frac, playLen);
CHECK(recovered == fadeIn);
}
static void testRoundTripFadeGreaterThanSpan() {
// fadeFrames > playLength -> fraction > 1 (returned unclamped; the overlay clamps at draw).
// The shell is responsible for clamping before writing AmpEnvelope.
const std::int64_t playLen = 100;
const std::int64_t fadeIn = 150;
const double frac = framesToFadeFraction(fadeIn, playLen);
CHECK(frac > 1.0); // intentionally unclamped from this module's perspective
// The round-trip still recovers the original fade, so the shell can clamp after.
const std::int64_t recovered = fadeFractionToFrames(frac, playLen);
CHECK(recovered == fadeIn);
}
int main() {
testPlayLengthNoStartPoint();
testPlayLengthWithStartPoint();
@@ -144,18 +64,6 @@ int main() {
testPlayLengthStartFramePastEnd();
testPlayLengthRounding();
testFramesToFadeFractionBasic();
testFramesToFadeFractionZeroPlayLength();
testFramesToFadeFractionFullSpan();
testFadeFractionToFramesBasic();
testFadeFractionToFramesZeroPlayLength();
testFadeFractionToFramesRounding();
testRoundTripNoStartPoint();
testRoundTripWithStartPoint();
testRoundTripFadeGreaterThanSpan();
if (g_fail == 0) std::printf("trigger_seam: all tests passed\n");
else std::printf("trigger_seam: %d FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;