instrument: spline EGs — hard points on the one shared spline, a drawn contour per envelope beside its staged state, payload v13
This commit is contained in:
@@ -452,7 +452,7 @@ 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,0x0c,0x00,0x00,
|
||||
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0d,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,
|
||||
@@ -515,6 +515,36 @@ static void testGoldenFullBlobFixture() {
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0
|
||||
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0
|
||||
// --- payload v13 dual Staged/Spline state. Three spline EGs (amp, pitch, filter),
|
||||
// each Staged with the y = 1 - x default contour, then the three velocity curves'
|
||||
// hard-flag tails, all flags clear ---
|
||||
0x00, // amp: Staged
|
||||
0x02,0x00,0x00,0x00, // 2 points
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // x 0.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // y 1.0
|
||||
0x00, // smooth
|
||||
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // x 127.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // y 0.0
|
||||
0x00, // smooth
|
||||
0x00, // pitch: Staged
|
||||
0x02,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
|
||||
0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,
|
||||
0x00, // filter: Staged
|
||||
0x02,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
|
||||
0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,
|
||||
0x03,0x00,0x00,0x00, 0x00,0x00,0x00, // amp curve hard flags (3 points)
|
||||
0x02,0x00,0x00,0x00, 0x00,0x00, // filter curve hard flags
|
||||
0x02,0x00,0x00,0x00, 0x00,0x00, // pitch curve hard flags
|
||||
};
|
||||
// clang-format on
|
||||
CHECK(bytes.size() == sizeof(kGolden));
|
||||
@@ -562,17 +592,18 @@ static void testEnvelopePrefixBytesFrozen() {
|
||||
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
||||
}
|
||||
CHECK(kComponentStateVersion == 11);
|
||||
CHECK(kParamsPayloadVersion == 12);
|
||||
CHECK(kParamsPayloadVersion == 13);
|
||||
CHECK(kParamsSingleRecordVersion == 8);
|
||||
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
||||
// The filter, staged-curve, loop and velocity tails rode PAYLOAD bumps, not envelope ones
|
||||
// — the two axes stay independent, so a future envelope field cannot collide with any of
|
||||
// them on one number.
|
||||
// The filter, staged-curve, loop, velocity and spline tails rode PAYLOAD bumps, not
|
||||
// envelope ones — the two axes stay independent, so a future envelope field cannot collide
|
||||
// with any of them on one number.
|
||||
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
||||
CHECK(kParamsCurveVersion > kParamsFilterVersion);
|
||||
CHECK(kParamsLoopVersion > kParamsCurveVersion);
|
||||
CHECK(kParamsVelocityVersion > kParamsLoopVersion);
|
||||
CHECK(kParamsPayloadVersion == kParamsVelocityVersion);
|
||||
CHECK(kParamsSplineVersion > kParamsVelocityVersion);
|
||||
CHECK(kParamsPayloadVersion == kParamsSplineVersion);
|
||||
}
|
||||
|
||||
// --- The filter tail (payload v9) --------------------------------------------
|
||||
@@ -1460,11 +1491,12 @@ static void testSampleRefsTruncatedMidEntry() {
|
||||
// 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 + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the
|
||||
// 36-byte v12 pitch curve) = 488 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4,
|
||||
// loop 1+8+8, channels4, name 4+0). Cutting 508 keeps the first 27 of entry two's 47 —
|
||||
// mid loop.start (offset 23..31).
|
||||
CHECK(bytes.size() > 508);
|
||||
bytes.resize(bytes.size() - 508);
|
||||
// 36-byte v12 pitch curve + the 135-byte v13 dual-state tail, three 39-byte spline EGs and
|
||||
// three 6-byte hard-flag tails) = 623 bytes; entry two is 47 bytes (id 4+3, path 4+7,
|
||||
// root4, loop 1+8+8, channels4, name 4+0). Cutting 643 keeps the first 27 of entry two's
|
||||
// 47 — mid loop.start (offset 23..31).
|
||||
CHECK(bytes.size() > 643);
|
||||
bytes.resize(bytes.size() - 643);
|
||||
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||
CHECK(back.sampleRefs.size() == 1);
|
||||
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
||||
|
||||
+67
-15
@@ -366,6 +366,7 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
|
||||
DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
|
||||
DeckParam::kKeyTrack, DeckParam::kTrigLength,
|
||||
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
||||
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
|
||||
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
||||
DeckParam::kMonoTrigger, DeckParam::kMasterGain,
|
||||
};
|
||||
@@ -450,16 +451,36 @@ static void testANonRadioIdLeavesTheOverlaySelectionAlone() {
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp);
|
||||
}
|
||||
|
||||
// The two group gates, spelled the way the predicates read them. Spline flags default off, so
|
||||
// a case that says nothing about them is asserting the staged behaviour.
|
||||
static DeckEnableState gates(bool pitchEnv, bool filter) {
|
||||
DeckEnableState s;
|
||||
s.pitchEnvEnabled = pitchEnv;
|
||||
s.filterEnabled = filter;
|
||||
return s;
|
||||
}
|
||||
|
||||
// An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on
|
||||
// the same params: a node drag must not reach a value the knob refuses.
|
||||
static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
|
||||
CHECK(overlayEnvInert(OverlayEnv::kPitch, /*pitchEnv=*/false, /*filter=*/true));
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kPitch, true, true));
|
||||
CHECK(overlayEnvInert(OverlayEnv::kFilter, true, /*filter=*/false));
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kFilter, true, true));
|
||||
CHECK(overlayEnvInert(OverlayEnv::kPitch, gates(/*pitchEnv=*/false, /*filter=*/true)));
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kPitch, gates(true, true)));
|
||||
CHECK(overlayEnvInert(OverlayEnv::kFilter, gates(true, /*filter=*/false)));
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kFilter, gates(true, true)));
|
||||
// Amp has no enable toggle, so it is never inert; kNone draws nothing to grab.
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kAmp, false, false));
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kNone, false, false));
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kAmp, gates(false, false)));
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kNone, gates(false, false)));
|
||||
|
||||
// The enable gate alone, which the SPLINE overlay reads: it survives a mode switch, so a
|
||||
// disabled group's contour is as dead as its knobs.
|
||||
CHECK(!overlayEnvEnabled(OverlayEnv::kPitch, gates(false, true)));
|
||||
CHECK(overlayEnvEnabled(OverlayEnv::kAmp, gates(false, false)));
|
||||
// ...while the staged overlay additionally goes inert once the envelope is drawn: its
|
||||
// nodes are no longer what the overlay is editing.
|
||||
DeckEnableState drawn = gates(true, true);
|
||||
drawn.ampSpline = true;
|
||||
CHECK(overlayEnvInert(OverlayEnv::kAmp, drawn));
|
||||
CHECK(overlayEnvEnabled(OverlayEnv::kAmp, drawn));
|
||||
}
|
||||
|
||||
// A deck knob goes inert exactly with its group's own enable toggle — including the filter's
|
||||
@@ -467,16 +488,45 @@ static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
|
||||
// go inert with the rest of the filter (the reachable-through-the-deck route mouseDownDeck
|
||||
// checks before ever routing a curve-cell click to the popup).
|
||||
static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() {
|
||||
CHECK(deckKnobInert(DeckParam::kFilterVelCurve, /*pitchEnv=*/true, /*filter=*/false));
|
||||
CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, true, true));
|
||||
CHECK(deckKnobInert(DeckParam::kFilterCutoff, true, false));
|
||||
CHECK(!deckKnobInert(DeckParam::kFilterCutoff, true, true));
|
||||
CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, /*pitchEnv=*/false, true));
|
||||
CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, true, true));
|
||||
CHECK(deckKnobInert(DeckParam::kFilterVelCurve, gates(/*pitchEnv=*/true, /*filter=*/false)));
|
||||
CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, gates(true, true)));
|
||||
CHECK(deckKnobInert(DeckParam::kFilterCutoff, gates(true, false)));
|
||||
CHECK(!deckKnobInert(DeckParam::kFilterCutoff, gates(true, true)));
|
||||
CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, gates(/*pitchEnv=*/false, true)));
|
||||
CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates(true, true)));
|
||||
// The amp's own velocity cell and every ordinary control are never inert here — inertness
|
||||
// is a filter/pitch-env-group-only concept.
|
||||
CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, false, false));
|
||||
CHECK(!deckKnobInert(DeckParam::kAttack, false, false));
|
||||
// is a filter/pitch-env-group-only concept until an envelope is drawn.
|
||||
CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, gates(false, false)));
|
||||
CHECK(!deckKnobInert(DeckParam::kAttack, gates(false, false)));
|
||||
}
|
||||
|
||||
// A drawn envelope's STAGED segment knobs go inert; the mode toggle itself and the depth knobs
|
||||
// that scale either shape stay live. (Which segment knobs, per envelope, is pinned in
|
||||
// spline_egs_tests alongside the rest of the spline rules.)
|
||||
static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() {
|
||||
CHECK(!isLiveDeckParam(DeckParam::kAmpEnvMode));
|
||||
CHECK(!isLiveDeckParam(DeckParam::kPitchEnvMode));
|
||||
CHECK(!isLiveDeckParam(DeckParam::kFilterEnvMode));
|
||||
CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp);
|
||||
CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch);
|
||||
CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter);
|
||||
// A mode toggle must not be mistaken for the overlay-select radio beside it.
|
||||
CHECK(overlayEnvForRadio(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kNone);
|
||||
CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone);
|
||||
}
|
||||
|
||||
// The three mode toggles ride each env group's caption slack, so the deck's wrapped geometry
|
||||
// is unchanged by them: raising their segment width past the caption headroom would reflow the
|
||||
// first row and push the deck to a fourth one (see testDeckFitsInsideTheEnforcedMinimumWindow).
|
||||
static void testTheModeTogglesCostNoGroupWidth() {
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
for (const DeckGroupDesc& g : sampleDeckGroups(mode)) {
|
||||
if (g.captionToggle2.id < 0) continue;
|
||||
DeckGroupDesc without = g;
|
||||
without.captionToggle2 = DeckToggleDesc{};
|
||||
CHECK(deckGroupWidth(g) == deckGroupWidth(without));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
@@ -485,6 +535,8 @@ int main() {
|
||||
testANonRadioIdLeavesTheOverlaySelectionAlone();
|
||||
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
|
||||
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
|
||||
testAModeToggleIsNeitherLiveNorAnOverlayRadio();
|
||||
testTheModeTogglesCostNoGroupWidth();
|
||||
testEveryDeckControlIsClassifiedLiveOrReloading();
|
||||
testOnlyALiveControlsDragTakesTheLiveTier();
|
||||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||
|
||||
+12
-12
@@ -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);
|
||||
@@ -165,8 +165,8 @@ static void testHitTest() {
|
||||
// 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}, {}};
|
||||
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) ==
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Standalone tests for reasampler::instrument::ui::spline_edit — no VST3, no REAPER, no
|
||||
// framework. Asserts the ONE point-editing grammar both spline consumers route through:
|
||||
// left-click grabs a node and adds in empty space, right-click deletes, control-click toggles
|
||||
// hard/smooth, and a click outside the mapping box resolves to nothing unless it lands on a
|
||||
// node's pick radius (so the popup's inset ring can grab but never add).
|
||||
|
||||
#include "../src/core/instrument/ui/spline_edit.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::instrument::ui;
|
||||
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)
|
||||
|
||||
static const VelocityCurve::Box kBox{100, 50, 127, 101};
|
||||
|
||||
// A three-point curve whose interior node sits well away from both endpoints.
|
||||
static VelocityCurve threePoint() {
|
||||
VelocityCurve c = VelocityCurve::rampDown();
|
||||
c.addPoint(64.0, 0.5);
|
||||
return c;
|
||||
}
|
||||
|
||||
static void testLeftClickOnANodeGrabsIt() {
|
||||
const VelocityCurve c = threePoint();
|
||||
const auto px = c.pixelFromPoint(kBox, c.points()[1]);
|
||||
const SplineEdit e = resolveSplineEdit(c, kBox, SplineGesture::kLeft, px.x, px.y);
|
||||
CHECK(e.kind == SplineEditKind::kGrab);
|
||||
CHECK(e.index == 1);
|
||||
}
|
||||
|
||||
static void testLeftClickInEmptySpaceAdds() {
|
||||
const VelocityCurve c = VelocityCurve::rampDown();
|
||||
// Well away from either endpoint's drawn position and from the traced line's nodes.
|
||||
const SplineEdit e = resolveSplineEdit(c, kBox, SplineGesture::kLeft, 160, 60);
|
||||
CHECK(e.kind == SplineEditKind::kAdd);
|
||||
CHECK(e.index == -1); // no point yet — the caller creates it
|
||||
}
|
||||
|
||||
static void testRightClickOnANodeDeletesAndElsewhereDoesNothing() {
|
||||
const VelocityCurve c = threePoint();
|
||||
const auto px = c.pixelFromPoint(kBox, c.points()[1]);
|
||||
const SplineEdit hit = resolveSplineEdit(c, kBox, SplineGesture::kRight, px.x, px.y);
|
||||
CHECK(hit.kind == SplineEditKind::kDelete);
|
||||
CHECK(hit.index == 1);
|
||||
// Right-click on empty canvas must NOT add — delete is the only thing the gesture means.
|
||||
const SplineEdit miss = resolveSplineEdit(c, kBox, SplineGesture::kRight, 160, 60);
|
||||
CHECK(miss.kind == SplineEditKind::kNone);
|
||||
}
|
||||
|
||||
static void testControlClickOnANodeTogglesHardAndElsewhereDoesNothing() {
|
||||
const VelocityCurve c = threePoint();
|
||||
const auto px = c.pixelFromPoint(kBox, c.points()[1]);
|
||||
const SplineEdit hit = resolveSplineEdit(c, kBox, SplineGesture::kControlLeft, px.x, px.y);
|
||||
CHECK(hit.kind == SplineEditKind::kToggleHard);
|
||||
CHECK(hit.index == 1);
|
||||
const SplineEdit miss = resolveSplineEdit(c, kBox, SplineGesture::kControlLeft, 160, 60);
|
||||
CHECK(miss.kind == SplineEditKind::kNone);
|
||||
}
|
||||
|
||||
// The popup draws its box inset inside a border; a click in that ring must be able to GRAB an
|
||||
// endpoint handle (which is drawn on the box edge, within the pick radius of the ring) but must
|
||||
// never ADD — an added point there clamps onto an endpoint's x and stacks an undeletable
|
||||
// duplicate.
|
||||
static void testOutsideTheBoxGrabsButNeverAdds() {
|
||||
const VelocityCurve c = VelocityCurve::rampDown();
|
||||
const auto first = c.pixelFromPoint(kBox, c.points()[0]);
|
||||
const SplineEdit ring =
|
||||
resolveSplineEdit(c, kBox, SplineGesture::kLeft, first.x - 3, first.y - 3);
|
||||
CHECK(ring.kind == SplineEditKind::kGrab);
|
||||
CHECK(ring.index == 0);
|
||||
// Far outside, on no node at all.
|
||||
const SplineEdit away = resolveSplineEdit(c, kBox, SplineGesture::kLeft, kBox.left - 60,
|
||||
kBox.top - 40);
|
||||
CHECK(away.kind == SplineEditKind::kNone);
|
||||
}
|
||||
|
||||
static void testDegenerateBoxResolvesToNothing() {
|
||||
const VelocityCurve c = threePoint();
|
||||
CHECK(resolveSplineEdit(c, VelocityCurve::Box{0, 0, 0, 40}, SplineGesture::kLeft, 0, 0)
|
||||
.kind == SplineEditKind::kNone);
|
||||
CHECK(resolveSplineEdit(c, VelocityCurve::Box{0, 0, 40, 1}, SplineGesture::kLeft, 0, 0)
|
||||
.kind == SplineEditKind::kNone);
|
||||
}
|
||||
|
||||
// The overlay's box is the FULL area — no inset — so the contour spans the sample's whole
|
||||
// drawn width 1:1 with its time axis.
|
||||
static void testOverlayBoxIsTheWholeArea() {
|
||||
const OverlayArea area{Rect::ltrb(12, 40, 812, 240)};
|
||||
const VelocityCurve::Box box = splineOverlayBox(area);
|
||||
CHECK(box.left == 12);
|
||||
CHECK(box.top == 40);
|
||||
CHECK(box.width == 800);
|
||||
CHECK(box.height == 200);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLeftClickOnANodeGrabsIt();
|
||||
testLeftClickInEmptySpaceAdds();
|
||||
testRightClickOnANodeDeletesAndElsewhereDoesNothing();
|
||||
testControlClickOnANodeTogglesHardAndElsewhereDoesNothing();
|
||||
testOutsideTheBoxGrabsButNeverAdds();
|
||||
testDegenerateBoxResolvesToNothing();
|
||||
testOverlayBoxIsTheWholeArea();
|
||||
if (g_fail == 0) std::printf("spline_edit: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Standalone tests for the SPLINE EG system — no VST3, no REAPER, no framework. One file for
|
||||
// the whole feature because its seams span three modules that only mean something together: the
|
||||
// shared spline (engine), the dual Staged/Spline state and its wire format (map), and the
|
||||
// point-editing grammar (ui).
|
||||
//
|
||||
// The eleven cases below are the spec's own test list, in its order. Each is named for the rule
|
||||
// it pins, so a failure names the behaviour rather than the module.
|
||||
|
||||
#include "../src/core/instrument/engine/voice_engine.h"
|
||||
#include "../src/core/instrument/map/component_state_io.h"
|
||||
#include "../src/core/instrument/ui/deck_groups.h"
|
||||
#include "../src/core/instrument/ui/spline_edit.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::engine;
|
||||
using reasampler::instrument::map::ComponentState;
|
||||
using reasampler::instrument::map::InstrumentParams;
|
||||
using reasampler::instrument::map::PlaySeconds;
|
||||
using reasampler::instrument::map::deserializeComponentState;
|
||||
using reasampler::instrument::map::kParamsFormatMarker;
|
||||
using reasampler::instrument::map::kParamsPayloadVersion;
|
||||
using reasampler::instrument::map::resolvePlay;
|
||||
using reasampler::instrument::map::serializeComponentState;
|
||||
|
||||
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; }
|
||||
|
||||
// x runs over the curve's canonical span; a spline EG's phase maps onto it linearly.
|
||||
static double xAt(double phase) { return kCurveXMin + phase * (kCurveXMax - kCurveXMin); }
|
||||
|
||||
// A contour that rises then falls, with a peak at x=64 the caller can make hard or smooth.
|
||||
static VelocityCurve peakContour(bool hardPeak) {
|
||||
VelocityCurve c = VelocityCurve::fromPoints(
|
||||
{{0.0, 0.0}, {32.0, 0.2}, {64.0, 1.0}, {96.0, 0.3}, {127.0, 0.0}},
|
||||
CurveDomain::Unipolar);
|
||||
if (hardPeak) c.setHard(2, true);
|
||||
return c;
|
||||
}
|
||||
|
||||
// --- 1. A hard point is a genuine slope discontinuity -------------------------
|
||||
|
||||
// The whole hard-point enhancement in one assertion: at a hard knot each side's one-sided slope
|
||||
// is that SEGMENT'S OWN secant — no smoothing was applied on either side — so the two disagree
|
||||
// and the contour has a real corner. The same knot left smooth pins to a shared tangent.
|
||||
static void testHardPointGivesDifferingOneSidedSlopes() {
|
||||
const VelocityCurve hard = peakContour(/*hardPeak=*/true);
|
||||
const double h = 1e-4;
|
||||
const double left = (hard.eval(64.0) - hard.eval(64.0 - h)) / h;
|
||||
const double right = (hard.eval(64.0 + h) - hard.eval(64.0)) / h;
|
||||
// The two adjacent secants: (1.0-0.2)/32 rising, (0.3-1.0)/32 falling.
|
||||
const double secantIn = (1.0 - 0.2) / 32.0;
|
||||
const double secantOut = (0.3 - 1.0) / 32.0;
|
||||
CHECK(near(left, secantIn, 1e-4));
|
||||
CHECK(near(right, secantOut, 1e-4));
|
||||
CHECK(left > 0.0 && right < 0.0); // a genuine corner, not a slope that merely changes rate
|
||||
|
||||
// Neither adjacent segment is straightened by the hard knot — each is still a curve, which
|
||||
// is what "one or more monotone splines joined at their angles" means. A straight segment
|
||||
// would put its midpoint exactly on the chord.
|
||||
CHECK(!near(hard.eval(48.0), (0.2 + 1.0) / 2.0, 1e-6));
|
||||
CHECK(!near(hard.eval(80.0), (1.0 + 0.3) / 2.0, 1e-6));
|
||||
|
||||
// Left smooth, the same knot is a local extremum: Fritsch-Carlson pins the tangent to 0 on
|
||||
// BOTH sides, so the slope is continuous there.
|
||||
const VelocityCurve smooth = peakContour(/*hardPeak=*/false);
|
||||
const double sLeft = (smooth.eval(64.0) - smooth.eval(64.0 - h)) / h;
|
||||
const double sRight = (smooth.eval(64.0 + h) - smooth.eval(64.0)) / h;
|
||||
CHECK(near(sLeft, 0.0, 1e-4));
|
||||
CHECK(near(sRight, 0.0, 1e-4));
|
||||
}
|
||||
|
||||
// --- 2. Per-segment monotonicity, on a contour that is not globally monotone ---
|
||||
|
||||
static void testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour() {
|
||||
VelocityCurve c = VelocityCurve::fromPoints(
|
||||
{{0.0, 0.2}, {20.0, 0.9}, {50.0, 0.1}, {90.0, 0.85}, {110.0, 0.15}, {127.0, 0.6}},
|
||||
CurveDomain::Unipolar);
|
||||
c.setHard(2, true); // one hard knot, so the guarantee is asserted across a joint too
|
||||
const std::vector<VelocityPoint>& pts = c.points();
|
||||
for (std::size_t i = 0; i + 1 < pts.size(); ++i) {
|
||||
const double lo = (std::min)(pts[i].value, pts[i + 1].value);
|
||||
const double hi = (std::max)(pts[i].value, pts[i + 1].value);
|
||||
for (int s = 0; s <= 200; ++s) {
|
||||
const double x = pts[i].velocity +
|
||||
(pts[i + 1].velocity - pts[i].velocity) * (s / 200.0);
|
||||
const double y = c.eval(x);
|
||||
CHECK(y >= lo - 1e-12);
|
||||
CHECK(y <= hi + 1e-12);
|
||||
}
|
||||
}
|
||||
// ...and it genuinely rises AND falls, so the assertion above is not vacuously about a
|
||||
// monotone curve.
|
||||
CHECK(c.eval(20.0) > c.eval(0.0));
|
||||
CHECK(c.eval(50.0) < c.eval(20.0));
|
||||
}
|
||||
|
||||
// --- 3. The 128-point ceiling refuses without disturbing the contour ----------
|
||||
|
||||
static void testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical() {
|
||||
VelocityCurve c = VelocityCurve::rampDown();
|
||||
for (std::size_t i = 0; c.size() < kMaxCurvePoints; ++i) {
|
||||
const double x = 1.0 + static_cast<double>(i);
|
||||
CHECK(c.addPoint(x, 0.5) >= 0);
|
||||
}
|
||||
CHECK(c.size() == kMaxCurvePoints);
|
||||
const std::vector<VelocityPoint> before = c.points();
|
||||
CHECK(c.addPoint(63.5, 0.25) == -1);
|
||||
const std::vector<VelocityPoint>& after = c.points();
|
||||
CHECK(after.size() == before.size());
|
||||
for (std::size_t i = 0; i < before.size(); ++i) {
|
||||
// Bit-identical, not merely close: a refused add must not perturb a drawn shape at all.
|
||||
CHECK(after[i].velocity == before[i].velocity);
|
||||
CHECK(after[i].value == before[i].value);
|
||||
CHECK(after[i].hard == before[i].hard);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. The two full-length endpoints always survive --------------------------
|
||||
|
||||
static void testEndpointDeletionIsRefused() {
|
||||
VelocityCurve c = peakContour(false);
|
||||
const std::size_t n = c.size();
|
||||
CHECK(!c.deletePoint(0));
|
||||
CHECK(!c.deletePoint(n - 1));
|
||||
CHECK(c.size() == n);
|
||||
CHECK(c.points().front().velocity == kCurveXMin);
|
||||
CHECK(c.points().back().velocity == kCurveXMax);
|
||||
// An interior point still deletes, so the refusal is about the endpoints and not about
|
||||
// deletion being broken.
|
||||
CHECK(c.deletePoint(2));
|
||||
CHECK(c.size() == n - 1);
|
||||
}
|
||||
|
||||
// --- 5. The hard/smooth toggle round-trips ------------------------------------
|
||||
|
||||
static void testTogglingHardThenSmoothRestoresTheEvaluatedContour() {
|
||||
VelocityCurve c = peakContour(false);
|
||||
std::vector<double> baseline;
|
||||
for (int i = 0; i <= 127; ++i) baseline.push_back(c.eval(i));
|
||||
|
||||
CHECK(c.toggleHard(2));
|
||||
bool changedSomewhere = false;
|
||||
for (int i = 0; i <= 127; ++i) {
|
||||
if (!near(c.eval(i), baseline[static_cast<std::size_t>(i)], 1e-12)) changedSomewhere = true;
|
||||
}
|
||||
CHECK(changedSomewhere); // the toggle must actually do something, or the round trip is empty
|
||||
CHECK(c.points()[2].hard);
|
||||
|
||||
CHECK(c.toggleHard(2));
|
||||
CHECK(!c.points()[2].hard);
|
||||
for (int i = 0; i <= 127; ++i) {
|
||||
CHECK(c.eval(i) == baseline[static_cast<std::size_t>(i)]); // exact, not approximate
|
||||
}
|
||||
}
|
||||
|
||||
// --- 6. Both states survive a mode flip, in memory and across save/reload -----
|
||||
|
||||
// Distinctive staged values on all three envelopes, so "exactly as left" is checkable rather
|
||||
// than accidentally equal to a default.
|
||||
static InstrumentParams paramsWithBothStates() {
|
||||
InstrumentParams p;
|
||||
p.play.playMode = PlayMode::Gate;
|
||||
p.play.adsr.attackSeconds = 0.37;
|
||||
p.play.adsr.decaySeconds = 0.21;
|
||||
p.play.adsr.sustainLevel = 0.42;
|
||||
p.play.adsr.releaseSeconds = 0.66;
|
||||
p.play.pitchEnv.enabled = true;
|
||||
p.play.pitchEnv.peakSemitones = -7.5;
|
||||
p.play.pitchEnv.shape.attackSeconds = 0.11;
|
||||
p.play.filter.enabled = true;
|
||||
p.play.filter.env.decaySeconds = 0.29;
|
||||
p.play.filter.env.sustainLevel = 0.33;
|
||||
|
||||
VelocityCurve drawn = peakContour(/*hardPeak=*/true);
|
||||
p.play.ampSpline.mode = EnvMode::Spline;
|
||||
p.play.ampSpline.contour = drawn;
|
||||
p.play.pitchSpline.contour = drawn; // stored, but left Staged: the inactive half
|
||||
p.play.filterSpline.contour = drawn;
|
||||
return p;
|
||||
}
|
||||
|
||||
static void testStagedAndSplineStatesBothSurviveAFlipAndASaveReload() {
|
||||
InstrumentParams p = paramsWithBothStates();
|
||||
const VelocityCurve drawn = p.play.ampSpline.contour;
|
||||
|
||||
// In memory: flipping the amp back to Staged keeps the contour, and forward again keeps the
|
||||
// staged values. Neither converts into the other.
|
||||
p.play.ampSpline.mode = EnvMode::Staged;
|
||||
CHECK(p.play.ampSpline.contour.equals(drawn));
|
||||
CHECK(p.play.adsr.attackSeconds == 0.37);
|
||||
p.play.ampSpline.mode = EnvMode::Spline;
|
||||
CHECK(p.play.adsr.sustainLevel == 0.42);
|
||||
|
||||
ComponentState st;
|
||||
st.selectionId = "cap-1";
|
||||
st.params = p;
|
||||
const ComponentState back = deserializeComponentState(serializeComponentState(st), 48000.0);
|
||||
const PlaySeconds& r = back.params.play;
|
||||
|
||||
CHECK(r.adsr.attackSeconds == 0.37);
|
||||
CHECK(r.adsr.decaySeconds == 0.21);
|
||||
CHECK(r.adsr.sustainLevel == 0.42);
|
||||
CHECK(r.adsr.releaseSeconds == 0.66);
|
||||
CHECK(r.pitchEnv.peakSemitones == -7.5);
|
||||
CHECK(r.pitchEnv.shape.attackSeconds == 0.11);
|
||||
CHECK(r.filter.env.decaySeconds == 0.29);
|
||||
CHECK(r.filter.env.sustainLevel == 0.33);
|
||||
|
||||
CHECK(r.ampSpline.mode == EnvMode::Spline);
|
||||
CHECK(r.pitchSpline.mode == EnvMode::Staged);
|
||||
CHECK(r.filterSpline.mode == EnvMode::Staged);
|
||||
// The contour itself, hard flags included, on all three — the inactive ones too.
|
||||
CHECK(r.ampSpline.contour.equals(drawn));
|
||||
CHECK(r.pitchSpline.contour.equals(drawn));
|
||||
CHECK(r.filterSpline.contour.equals(drawn));
|
||||
CHECK(r.ampSpline.contour.points()[2].hard);
|
||||
}
|
||||
|
||||
// --- 7. A v12 payload still loads ---------------------------------------------
|
||||
|
||||
// Rewrites the params-payload version field to 12, leaving the v12 prefix byte-identical (v13
|
||||
// is a strict suffix, so the prefix IS what a v12 writer emitted). The reader is positional and
|
||||
// bounded, so it stops before the appended tail and never sees it.
|
||||
static std::vector<std::uint8_t> asV12Payload(std::vector<std::uint8_t> bytes) {
|
||||
int patched = 0;
|
||||
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
|
||||
const std::uint32_t marker = static_cast<std::uint32_t>(bytes[i]) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 3]) << 24);
|
||||
const std::uint32_t ver = static_cast<std::uint32_t>(bytes[i + 4]) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 5]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 6]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 7]) << 24);
|
||||
if (marker != kParamsFormatMarker || ver != kParamsPayloadVersion) continue;
|
||||
bytes[i + 4] = 12;
|
||||
++patched;
|
||||
}
|
||||
CHECK(patched == 1); // exactly one payload header, or the rewrite is meaningless
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static void testAV12PayloadLoadsWithoutLoss() {
|
||||
InstrumentParams p = paramsWithBothStates();
|
||||
p.play.playMode = PlayMode::Trigger; // a v12 project can hold any mode
|
||||
p.velocityCurve.addPoint(70.0, 0.4);
|
||||
p.velocityCurve.setHard(1, true);
|
||||
ComponentState st;
|
||||
st.selectionId = "cap-legacy";
|
||||
st.params = p;
|
||||
|
||||
const ComponentState back =
|
||||
deserializeComponentState(asV12Payload(serializeComponentState(st)), 48000.0);
|
||||
const PlaySeconds& r = back.params.play;
|
||||
|
||||
// Everything v12 carried comes through untouched.
|
||||
CHECK(back.selectionId == "cap-legacy");
|
||||
CHECK(r.playMode == PlayMode::Trigger);
|
||||
CHECK(r.adsr.attackSeconds == 0.37);
|
||||
CHECK(r.adsr.sustainLevel == 0.42);
|
||||
CHECK(r.pitchEnv.peakSemitones == -7.5);
|
||||
CHECK(r.filter.env.decaySeconds == 0.29);
|
||||
CHECK(back.params.velocityCurve.size() == 3);
|
||||
CHECK(near(back.params.velocityCurve.eval(70.0), 0.4));
|
||||
|
||||
// Everything v13 added lifts to its default: Staged on all three, the y = 1 - x contour,
|
||||
// and no hard flag anywhere (v12 had nowhere to store one).
|
||||
CHECK(r.ampSpline.mode == EnvMode::Staged);
|
||||
CHECK(r.pitchSpline.mode == EnvMode::Staged);
|
||||
CHECK(r.filterSpline.mode == EnvMode::Staged);
|
||||
CHECK(r.ampSpline.contour.equals(VelocityCurve::rampDown()));
|
||||
CHECK(!back.params.velocityCurve.points()[1].hard);
|
||||
}
|
||||
|
||||
// --- 8. A stored contour rescales to a different-length sample ---------------
|
||||
|
||||
static SampleData splineAmpSample(std::size_t frames, const VelocityCurve& contour) {
|
||||
SampleData s;
|
||||
s.frames.assign(frames, 1.0f); // DC: the rendered value IS the envelope
|
||||
s.rootNote = 60;
|
||||
s.sampleRate = 48000;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.ampSpline.mode = EnvMode::Spline;
|
||||
s.play.ampSpline.contour = contour;
|
||||
return s;
|
||||
}
|
||||
|
||||
static std::vector<double> renderVoice(const SampleData& s, std::size_t frames) {
|
||||
Voice v;
|
||||
v.start(60, 100, s);
|
||||
std::vector<double> out;
|
||||
out.reserve(frames);
|
||||
for (std::size_t i = 0; i < frames; ++i) out.push_back(v.renderFrame());
|
||||
return out;
|
||||
}
|
||||
|
||||
static void testAContourReplaysProportionallyOnADifferentLengthSample() {
|
||||
const VelocityCurve contour = peakContour(/*hardPeak=*/true);
|
||||
const std::size_t shortLen = 1000;
|
||||
const std::size_t longLen = 3000;
|
||||
const SampleData s1 = splineAmpSample(shortLen, contour);
|
||||
const SampleData s2 = splineAmpSample(longLen, contour);
|
||||
const std::vector<double> a = renderVoice(s1, shortLen);
|
||||
const std::vector<double> b = renderVoice(s2, longLen);
|
||||
|
||||
// The rendered value at frame i of the short sample is the contour at phase i/shortLen; the
|
||||
// long sample reaches the SAME phase at frame 3i. Shape preserved, proportionally.
|
||||
for (std::size_t i = 1; i + 1 < shortLen; ++i) {
|
||||
const double phase = static_cast<double>(i) / static_cast<double>(shortLen);
|
||||
CHECK(near(a[i], contour.eval(xAt(phase)), 1e-6));
|
||||
CHECK(near(b[i * 3], a[i], 1e-6));
|
||||
}
|
||||
// Not a flat contour, so the agreement above is a real shape match.
|
||||
CHECK(a[shortLen / 2] > a[10] + 0.2);
|
||||
}
|
||||
|
||||
// --- 9. A fresh spline EG opens on the smooth y = 1 - x ----------------------
|
||||
|
||||
static void testAFreshSplineEgDefaultsToTheSmoothDownwardSlope() {
|
||||
const SplineEnv fresh;
|
||||
CHECK(fresh.mode == EnvMode::Staged); // drawn is opt-in; the CONTOUR is what defaults here
|
||||
const VelocityCurve& c = fresh.contour;
|
||||
CHECK(c.size() == 2);
|
||||
CHECK(!c.points()[0].hard);
|
||||
CHECK(!c.points()[1].hard);
|
||||
// Two collinear knots reduce the Hermite tangents to the shared secant, so it is an exact
|
||||
// straight line — and a straight line is smooth.
|
||||
for (int i = 0; i <= 127; ++i) {
|
||||
CHECK(near(c.eval(i), 1.0 - static_cast<double>(i) / 127.0, 1e-12));
|
||||
}
|
||||
}
|
||||
|
||||
// --- 10. Gate is unavailable while a spline EG is active ---------------------
|
||||
|
||||
// The rule has one home (splineActive) and one enforcement point on the way to the engine
|
||||
// (resolvePlay). The editor's Gate segment refuses and paints Disabled off the same predicate.
|
||||
static void testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards() {
|
||||
PlaySeconds stored;
|
||||
stored.playMode = PlayMode::Gate;
|
||||
CHECK(!splineActive(stored));
|
||||
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
|
||||
|
||||
for (EnvMode* slot : {&stored.ampSpline.mode, &stored.pitchSpline.mode,
|
||||
&stored.filterSpline.mode}) {
|
||||
*slot = EnvMode::Spline;
|
||||
CHECK(splineActive(stored));
|
||||
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
|
||||
*slot = EnvMode::Staged;
|
||||
CHECK(!splineActive(stored));
|
||||
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
|
||||
}
|
||||
|
||||
// And the staged knobs of a drawn envelope go inert — drawn-but-dead, not removed — while
|
||||
// its depth knob, which scales either shape, stays live.
|
||||
using namespace reasampler::instrument::ui;
|
||||
DeckEnableState gates;
|
||||
gates.pitchEnvEnabled = true;
|
||||
gates.filterEnabled = true;
|
||||
CHECK(!deckKnobInert(DeckParam::kAttack, gates));
|
||||
gates.ampSpline = true;
|
||||
CHECK(deckKnobInert(DeckParam::kAttack, gates));
|
||||
CHECK(deckKnobInert(DeckParam::kSustain, gates));
|
||||
CHECK(deckKnobInert(DeckParam::kTrigDecay, gates));
|
||||
gates.pitchSpline = true;
|
||||
CHECK(deckKnobInert(DeckParam::kPitchEnvAttack, gates));
|
||||
CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates));
|
||||
gates.filterSpline = true;
|
||||
CHECK(deckKnobInert(DeckParam::kFilterEnvRelease, gates));
|
||||
CHECK(!deckKnobInert(DeckParam::kFilterModAmt, gates));
|
||||
}
|
||||
|
||||
// --- 11. The velocity->amp curve is the same grammar -------------------------
|
||||
|
||||
static void testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete() {
|
||||
using namespace reasampler::instrument::ui;
|
||||
const VelocityCurve::Box box{100, 50, 127, 101};
|
||||
VelocityCurve amp = VelocityCurve::flat();
|
||||
CHECK(amp.addPoint(64.0, 0.25) == 1);
|
||||
const auto px = amp.pixelFromPoint(box, amp.points()[1]);
|
||||
|
||||
// Control-click resolves to the toggle — the identical resolution the spline EG overlay
|
||||
// gets, because it is the identical function.
|
||||
const SplineEdit toggle =
|
||||
resolveSplineEdit(amp, box, SplineGesture::kControlLeft, px.x, px.y);
|
||||
CHECK(toggle.kind == SplineEditKind::kToggleHard);
|
||||
CHECK(toggle.index == 1);
|
||||
const double smoothMid = amp.eval(48.0);
|
||||
CHECK(amp.toggleHard(1));
|
||||
CHECK(amp.points()[1].hard);
|
||||
CHECK(!near(amp.eval(48.0), smoothMid, 1e-9)); // the hard flag reaches the amp response
|
||||
|
||||
// Right-click delete is unchanged: it resolves on an interior node and the endpoint guard
|
||||
// still refuses the two ends.
|
||||
const SplineEdit del = resolveSplineEdit(amp, box, SplineGesture::kRight, px.x, px.y);
|
||||
CHECK(del.kind == SplineEditKind::kDelete);
|
||||
CHECK(del.index == 1);
|
||||
CHECK(amp.deletePoint(1));
|
||||
CHECK(amp.size() == 2);
|
||||
CHECK(!amp.deletePoint(0));
|
||||
CHECK(!amp.deletePoint(1));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testHardPointGivesDifferingOneSidedSlopes();
|
||||
testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour();
|
||||
testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical();
|
||||
testEndpointDeletionIsRefused();
|
||||
testTogglingHardThenSmoothRestoresTheEvaluatedContour();
|
||||
testStagedAndSplineStatesBothSurviveAFlipAndASaveReload();
|
||||
testAV12PayloadLoadsWithoutLoss();
|
||||
testAContourReplaysProportionallyOnADifferentLengthSample();
|
||||
testAFreshSplineEgDefaultsToTheSmoothDownwardSlope();
|
||||
testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards();
|
||||
testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete();
|
||||
if (g_fail == 0) std::printf("spline_egs: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user