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
+1 -1
View File
@@ -289,7 +289,7 @@ anything for a trigger shape.
### `map/`
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v12), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag). v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v13), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag). v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
+8 -2
View File
@@ -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"
+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);
}
+12 -2
View File
@@ -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) {
+12 -2
View File
@@ -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;
+29 -6
View File
@@ -128,7 +128,11 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve,
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
const double vel = bitsToDouble(r.u64());
const double value = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, value});
// A NaN velocity breaks fromPoints' stable_sort (not a strict weak ordering with NaN
// present); a NaN value reaches the RT eval's multiply. Same non-finite-falls-back-to-0
// guard as every other wire double this codec reads.
pts.push_back(VelocityPoint{std::isfinite(vel) ? vel : 0.0,
std::isfinite(value) ? value : 0.0});
}
if (r.ok) {
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain);
@@ -149,24 +153,43 @@ void readSplineEnv(ByteReader& r, SplineEnv& s) {
const double x = bitsToDouble(r.u64());
const double y = bitsToDouble(r.u64());
const bool hard = (r.u8() != 0);
pts.push_back(VelocityPoint{x, y, hard});
// Same NaN guard as readCurveTail: an x NaN breaks fromPoints' sort, a y NaN reaches
// SplineCursor::eval's multiply into the per-sample amp gain.
pts.push_back(VelocityPoint{std::isfinite(x) ? x : 0.0, std::isfinite(y) ? y : 0.0, hard});
}
if (!r.ok) return;
s.mode = spline ? EnvMode::Spline : EnvMode::Staged;
if (pts.size() < 2) {
// fromPoints' own sub-2-point fallback is flat()/zero() by DOMAIN — the neutral velocity
// curve response (a full-open gate). A spline EG's documented neutral is y = 1 - x
// instead, so a malformed/short block substitutes that rather than fromPoints' default.
s.contour = VelocityCurve::rampDown();
return;
}
s.contour = VelocityCurve::fromPoints(std::move(pts),
reasampler::instrument::engine::CurveDomain::Unipolar);
}
// Apply a hard-flag tail to an already-read velocity curve. A count that disagrees with the
// curve fromPoints actually produced is dropped rather than applied to shifted knots.
// curve fromPoints actually produced — including an out-of-bounds or truncated one — is
// dropped rather than applied to shifted knots, and the whole params record parsed ahead of
// this tail survives (component_state_io.h's documented promise): if THIS call is what tripped
// r.ok (a truncated count field), it is revived before returning. An r.ok already false on
// entry (an earlier, unrelated field genuinely truncated) is left alone — that failure is not
// this tail's to forgive.
void readHardFlags(ByteReader& r, VelocityCurve& curve) {
const bool enteredOk = r.ok;
const std::uint32_t count = r.u32();
if (!r.ok) {
if (enteredOk) r.ok = true; // a truncated count field: nothing to apply
return;
}
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
if (count > remaining) { r.ok = false; return; }
if (count > remaining) return; // bound-and-skip: cannot safely reserve/read this many
std::vector<std::uint8_t> flags;
flags.reserve(count);
for (std::uint32_t i = 0; i < count && r.ok; ++i) flags.push_back(r.u8());
if (!r.ok || flags.size() != curve.size()) return;
for (std::uint32_t i = 0; i < count; ++i) flags.push_back(r.u8());
if (flags.size() != curve.size()) return;
for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
}
+1 -1
View File
@@ -259,7 +259,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
// The three drawn contours are normalized over the sample's own length, so no rate resolves
// them — they carry through verbatim, which is also what makes a different-length capture
// replay the same shape proportionally.
out.ampSpline = stored.ampSpline;
out.ampSpline = stored.ampSpline;
out.pitchSpline = stored.pitchSpline;
out.filterSpline = stored.filterSpline;
// Gate is unavailable while any EG is drawn — see splineActive (play_params.h) for why.
+7 -3
View File
@@ -36,9 +36,8 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
penv.captionWidth = 58;
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
// The mode toggle rides the caption slack rather than the knob row: every env group's
// knob row is wider than its caption row, so this costs no group width — and the deck
// has six pixels of headroom on its first row at the editor's floor width.
// The mode toggle rides the caption slack rather than the knob row — costs no group
// width; see this module's CLAUDE.md bullet (knob_deck) for the headroom this relies on.
penv.captionToggle2 = {id(DeckParam::kPitchEnvMode), kEnvModeSegW};
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
id(DeckParam::kPitchEnvHold),
@@ -289,6 +288,11 @@ bool deckKnobInert(DeckParam id, const DeckEnableState& state) {
case DeckParam::kTrigHold:
case DeckParam::kTrigDecay:
return state.ampSpline;
// The Trigger %-length knob is inert whenever ANY spline is active (not just the amp's):
// a drawn contour always covers the full sample length (splineActive, play_params.h), so
// the engine ignores lengthFraction in that case regardless of which EG is drawn.
case DeckParam::kTrigLength:
return state.ampSpline || state.pitchSpline || state.filterSpline;
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvHold:
case DeckParam::kPitchEnvDecay:
+2 -3
View File
@@ -65,9 +65,8 @@ struct DeckGroupDesc {
DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none
DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none
// A second caption toggle, placed immediately left of the first (or in its place when the
// first is absent). Exists because a group whose knob row is wider than its caption row has
// caption slack a toggle can occupy for free — a rowToggle would widen the GROUP, and the
// deck has six pixels of headroom on its first row at the editor's floor width.
// first is absent) — why this exists rather than a rowToggle is recorded once, at this
// module's CLAUDE.md bullet.
DeckToggleDesc captionToggle2;
std::vector<int> cellIds; // knob cells; -1 = blank reserve
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none