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:
@@ -183,10 +183,16 @@ struct PlayParams {
|
||||
// sample length, which IS the Trigger/one-shot playback model — so Gate is not available while
|
||||
// any spline EG is active. resolvePlay enforces it on the way to the engine; the editor's
|
||||
// play-mode toggle refuses the Gate segment so the two agree.
|
||||
//
|
||||
// The pitch/filter terms are gated on their own `enabled` flag to match Voice::start's binder
|
||||
// (voice.cpp only binds pitchSplineCur_/filterSplineCur_ when that flag is set): without this,
|
||||
// a Spline mode flip on a disabled pitch/filter envelope would cost Gate for zero modulation,
|
||||
// since the binder would never actually engage. Amp has no such flag, so it counts unconditionally.
|
||||
template <class Play>
|
||||
bool splineActive(const Play& p) {
|
||||
return p.ampSpline.mode == EnvMode::Spline || p.pitchSpline.mode == EnvMode::Spline ||
|
||||
p.filterSpline.mode == EnvMode::Spline;
|
||||
return p.ampSpline.mode == EnvMode::Spline ||
|
||||
(p.pitchEnv.enabled && p.pitchSpline.mode == EnvMode::Spline) ||
|
||||
(p.filter.enabled && p.filterSpline.mode == EnvMode::Spline);
|
||||
}
|
||||
|
||||
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -102,8 +102,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where
|
||||
// playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
// playEnd = start + round(lengthFraction*(frames-start)) — except kTrigLength is INERT
|
||||
// while any spline EG is active (splineActive, play_params.h): a contour is a pure
|
||||
// function over the FULL sample length, so truncating playEnd_ to a %-length would
|
||||
// hard-cut it mid-shape. The staged Trigger AHD below (trigSpan) plays the same full
|
||||
// span in that case, matching the "drawn-but-dead" treatment of the other staged knobs.
|
||||
double frac = splineActive(p) ? 1.0 : p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
@@ -258,6 +262,12 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
|
||||
else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
|
||||
pitchEnv_.applyLive(live.pitchEnv);
|
||||
}
|
||||
// The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but
|
||||
// pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_,
|
||||
// which already glides through rModAmount_'s live ramp regardless of spline state (below),
|
||||
// this is the one place a live pitch-depth move must be re-applied by hand. Only meaningful
|
||||
// while pitchSplineCur_ is bound; harmless (and cheap) to set otherwise.
|
||||
pitchSplineDepth_ = live.pitchEnv.peakSemitones;
|
||||
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
|
||||
|
||||
if (snap) {
|
||||
|
||||
@@ -178,10 +178,20 @@ private:
|
||||
// the voice.
|
||||
double tickAmplitude() {
|
||||
double amp;
|
||||
if (ampSplineCur_.active()) {
|
||||
// playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it —
|
||||
// splineActive, play_params.h); the guard is a pure-core defense against a hand-built
|
||||
// SampleData pairing Gate with an amp spline, which would otherwise bypass env_
|
||||
// entirely — release() then has no envelope to end, and an active sustain loop rings
|
||||
// forever.
|
||||
if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) {
|
||||
// A contour covers the sample end to end, so the head leaving the span IS the end of
|
||||
// the note — the exhaustion path in advanceFrame is what frees the voice.
|
||||
// the note — the exhaustion path in advanceFrame is what frees the voice. A contour
|
||||
// that flatlines at 0 across its FINAL segment is a permanent terminus (no later
|
||||
// segment to rise out of), so that case frees early too, the spline analogue of a
|
||||
// staged AHD's finished() — mid-contour dips do not, since the spline is deliberately
|
||||
// not globally monotone.
|
||||
amp = ampSplineCur_.eval(splinePhase());
|
||||
if (amp == 0.0 && ampSplineCur_.onFinalSegment()) amplitudeDone_ = true;
|
||||
} else if (playMode_ == PlayMode::Gate) {
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
|
||||
Reference in New Issue
Block a user