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:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user