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
+19 -5
View File
@@ -23,8 +23,8 @@ inline constexpr double kCurveYMax = 1.0;
// Point-count ceiling. A MUSICAL bound, not a performance one: long rhythmic phrases need the
// resolution, and at roughly two points per articulation event 128 is about four bars of 16ths.
// Segment lookup is logarithmic and the editor's node separation is the real density limit, so
// there is nothing to buy by lowering it. DO NOT LOWER.
// Segment lookup is logarithmic (<=7 steps at this ceiling), so there is no performance case for
// lowering it. DO NOT LOWER.
inline constexpr std::size_t kMaxCurvePoints = 128;
// The curve's Y range. UNIPOLAR [0,1] is a GAIN — the amp's domain, where the do-nothing
@@ -229,6 +229,11 @@ public:
}
void clear() { pts_ = nullptr; n_ = 0; }
bool active() const { return n_ >= 2; }
// True once the cursor has settled on the contour's LAST segment: past this point there is
// no further point to rise into, so a value read here that reaches 0 is a genuine permanent
// terminus (Voice::tickAmplitude's early-free), unlike a 0 touched mid-contour, which a
// later segment may still rise out of (the spline is deliberately not globally monotone).
bool onFinalSegment() const { return seg_ + 2 == n_; }
// `phase` is normalized position over the contour's whole span, [0,1]; out-of-range clamps
// to the terminal values (a note past its span holds the contour's last level).
@@ -238,7 +243,12 @@ public:
: kCurveXMin + phase * (kCurveXMax - kCurveXMin);
if (x <= x0_ && seg_ == 0) return y0_;
if (x >= x1_ && seg_ + 2 == n_) return y1_;
if (x < x0_ || x > x1_) locate(x);
// x <= x0_ (not just <): landing exactly on the cached segment's LEFT edge normally
// reproduces y0_ either way, but at a duplicate-X step (coincident knots with
// DIFFERENT Y) the cached segment may be the LATER of the two — re-locate so a query
// sitting exactly on the shared X always resolves through locate()'s tie-break, which
// agrees with the cold VelocityCurve::eval's first-containing-segment rule.
if (x <= x0_ || x > x1_) locate(x);
if (span_ <= 0.0) return y1_; // coincident-X knots: a step, no interior to blend
return hermiteAt(y0_, y1_, span_, mA_, mB_, (x - x0_) / span_);
}
@@ -248,10 +258,14 @@ private:
// binary search over the X-ordered array.
void locate(double x) {
if (x > x1_ && seg_ + 2 < n_ && x <= pts_[seg_ + 2].velocity) { select(seg_ + 1); return; }
// Leftmost segment containing x: smallest lo with pts_[lo+1].velocity >= x. At
// coincident-X knots (a drawn step) this picks the FIRST segment ending at the shared X,
// matching VelocityCurve::eval's cold linear walk — the two readers must agree here or a
// backwards/jumping read can return a different knot's Y than a forward one would.
std::size_t lo = 0, hi = n_ - 2;
while (lo < hi) {
const std::size_t mid = lo + (hi - lo + 1) / 2;
if (pts_[mid].velocity <= x) lo = mid; else hi = mid - 1;
const std::size_t mid = lo + (hi - lo) / 2;
if (pts_[mid + 1].velocity < x) lo = mid + 1; else hi = mid;
}
select(lo);
}