fix: close review findings on spline EGs — engine, codec, and popup/overlay UI grammar

Live pitch depth, Gate/Spline enable-rule agreement, inert kTrigLength, NaN wire guards, hard-flag-tail corruption no longer wipes the record, RT/cold spline tie-break, retired alt-click, marker-shadow fix, plus new test coverage.
This commit is contained in:
2026-07-31 22:27:41 -04:00
parent e44bd42dd9
commit 1c774226d3
22 changed files with 414 additions and 88 deletions
+61 -9
View File
@@ -341,21 +341,46 @@ static void testAFreshSplineEgDefaultsToTheSmoothDownwardSlope() {
// 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.
//
// Pitch and filter additionally gate on their own `enabled` flag, matching Voice::start's
// binder (voice.cpp only binds pitchSplineCur_/filterSplineCur_ under that same condition): a
// Spline mode flip on a still-disabled envelope produces no modulation, so it must not cost
// Gate either — the predicate and the binder must agree on one enable rule. Amp has no such
// flag and counts on its mode alone.
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);
}
stored.ampSpline.mode = EnvMode::Spline;
CHECK(splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
stored.ampSpline.mode = EnvMode::Staged;
CHECK(!splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
stored.pitchSpline.mode = EnvMode::Spline;
CHECK(!splineActive(stored)); // pitchEnv.enabled is still false: no modulation, no cost
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
stored.pitchEnv.enabled = true;
CHECK(splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
stored.pitchSpline.mode = EnvMode::Staged;
stored.pitchEnv.enabled = false;
CHECK(!splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
stored.filterSpline.mode = EnvMode::Spline;
CHECK(!splineActive(stored)); // filter.enabled is still false: the filter is fully off
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
stored.filter.enabled = true;
CHECK(splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
stored.filterSpline.mode = EnvMode::Staged;
stored.filter.enabled = false;
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.
@@ -407,6 +432,32 @@ static void testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete() {
CHECK(!amp.deletePoint(1));
}
// --- 12. SplineCursor's binary-search branch agrees with the cold reader ------
// Test 8 only walks a monotone forward read, which never leaves SplineCursor::locate's
// select(seg_+1) fast path. A backwards/jumping read forces the actual binary search — and at
// a duplicate-X knot (a drawn step) the RT cursor must resolve to the SAME point the cold
// VelocityCurve::eval() would, or a backwards read audibly steps to the wrong side of the step.
static void testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead() {
// A step at x=64: two knots sharing an X but different Y.
VelocityCurve c = VelocityCurve::fromPoints(
{{0.0, 0.1}, {32.0, 0.3}, {64.0, 0.9}, {64.0, 0.2}, {96.0, 0.6}, {127.0, 0.4}},
CurveDomain::Unipolar);
SplineCursor cur;
cur.bind(c);
// Deliberately out of order, so every eval but the first forces locate()'s binary search
// rather than the forward-walk fast path.
const double xs[] = {100.0, 10.0, 64.0, 40.0, 64.0, 5.0, 127.0, 20.0, 0.0, 90.0};
for (double x : xs) {
const double phase = x / kCurveXMax;
CHECK(near(cur.eval(phase), c.eval(x), 1e-6));
}
// The duplicate knot itself: both readers resolve to the SAME one (the first, per
// VelocityCurve::eval's "first containing segment" rule).
CHECK(near(cur.eval(64.0 / kCurveXMax), 0.9, 1e-6));
}
int main() {
testHardPointGivesDifferingOneSidedSlopes();
testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour();
@@ -419,6 +470,7 @@ int main() {
testAFreshSplineEgDefaultsToTheSmoothDownwardSlope();
testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards();
testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete();
testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead();
if (g_fail == 0) std::printf("spline_egs: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}