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:
2026-07-31 21:33:59 -04:00
parent f115904e4f
commit e44bd42dd9
33 changed files with 1739 additions and 364 deletions
+43 -11
View File
@@ -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
View File
@@ -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
View File
@@ -26,27 +26,27 @@ static int g_fail = 0;
// toggle + row toggle), MASTER (1 cell, no toggle).
static std::vector<DeckGroupDesc> shellLikeDeck() {
std::vector<DeckGroupDesc> g;
g.push_back({0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}});
g.push_back({1, 38, {}, {101, 48}, {6}, {}});
g.push_back({2, 58, {}, {102, 32}, {7, 8, 9}, {}});
g.push_back({3, 38, {}, {103, 40}, {10}, {104, 44}});
g.push_back({4, 46, {}, {}, {11}, {}});
g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}});
g.push_back({1, 38, {}, {101, 48}, {}, {6}, {}});
g.push_back({2, 58, {}, {102, 32}, {}, {7, 8, 9}, {}});
g.push_back({3, 38, {}, {103, 40}, {}, {10}, {104, 44}});
g.push_back({4, 46, {}, {}, {}, {11}, {}});
return g;
}
static void testGroupWidth() {
// Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6.
DeckGroupDesc amp{0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}};
DeckGroupDesc amp{0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}};
CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX);
// Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12.
DeckGroupDesc pitch{1, 38, {}, {101, 48}, {6}, {}};
DeckGroupDesc pitch{1, 38, {}, {101, 48}, {}, {6}, {}};
CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX);
// Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122.
DeckGroupDesc voice{3, 38, {}, {103, 40}, {10}, {104, 44}};
DeckGroupDesc voice{3, 38, {}, {103, 40}, {}, {10}, {104, 44}};
CHECK(deckGroupWidth(voice) ==
kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
// No toggles: max(caption, cells) + padding.
DeckGroupDesc master{4, 46, {}, {}, {11}, {}};
DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}};
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
}
@@ -148,7 +148,7 @@ static void testHitTest() {
// A blank cell (id -1) misses even though its rect exists.
std::vector<DeckGroupDesc> trig;
trig.push_back({0, 78, {}, {100, 44}, {20, 21, 22, -1, -1}, {}});
trig.push_back({0, 78, {}, {100, 44}, {}, {20, 21, 22, -1, -1}, {}});
const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
const DeckCellLayout& blank = tl.groups[0].cells[4];
CHECK(blank.id == -1);
@@ -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) ==
+110
View File
@@ -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;
}
+424
View File
@@ -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;
}