fix: close review findings on the velocity-curve deck and bipolar curves

Cancels the curve-node drag whenever the popup closes so Esc mid-drag can't alias the amp curve; generalizes CurveTarget routing to one switch; fixes stale/overstated comments; clamps a pre-v12 depth fold; adds deck-inertness and filter-fold test coverage.
This commit is contained in:
2026-07-31 19:46:37 -04:00
parent 9d38f87a2d
commit cfb53aade3
22 changed files with 223 additions and 99 deletions
+1 -1
View File
@@ -262,7 +262,7 @@ anything for a trigger shape.
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
- `velocity_curve` — the pure velocity transfer curve shared by all THREE destinations: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot). `eval(velocity)` called once per note-on. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation depth for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve is BOTH the shape and the amount — there is no depth control behind it, which is why the filter's `velAmount` retired into it. eval is HOMOGENEOUS in y (scaling every knot by k scales the curve by k exactly); the codec's pre-v12 lift rests on that.
- `velocity_curve` — the pure velocity transfer curve shared by all THREE destinations: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot). `eval(velocity)` called once per note-on. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation depth for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve is BOTH the shape and the amount — there is no depth control behind it, which is why the filter's `velAmount` retired into it. eval's homogeneity in y (see `velocity_curve.h`) is what the codec's pre-v12 lift rests on.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
### `map/`
@@ -43,7 +43,8 @@ int valueToY(const VelocityCurve::Box& box, double value, CurveDomain d) {
VelocityCurve VelocityCurve::flat() {
VelocityCurve c;
c.points_ = {{kVelMin, kCurveYMax}, {kVelMax, kCurveYMax}};
const double n = curveNeutral(CurveDomain::Unipolar);
c.points_ = {{kVelMin, n}, {kVelMax, n}};
return c;
}
@@ -56,7 +57,8 @@ VelocityCurve VelocityCurve::linear() {
VelocityCurve VelocityCurve::zero() {
VelocityCurve c;
c.domain_ = CurveDomain::Bipolar;
c.points_ = {{kVelMin, 0.0}, {kVelMax, 0.0}};
const double n = curveNeutral(CurveDomain::Bipolar);
c.points_ = {{kVelMin, n}, {kVelMax, n}};
return c;
}
+14 -3
View File
@@ -25,8 +25,10 @@ enum class CurveDomain { Unipolar, Bipolar };
constexpr double curveYMin(CurveDomain d) { return d == CurveDomain::Bipolar ? -1.0 : 0.0; }
// The value that changes nothing in each domain — unity gain, or zero modulation. Every
// defensive fallback lands here so a corrupt blob loses the shaping rather than inventing one.
// The value that changes nothing in each domain — unity gain, or zero modulation. THE one home
// for that value: eval()'s own empty-curve fallback reads it directly, and flat()/zero() (what
// fromPoints' sub-2-point fallback constructs) are built from it too, so a corrupt blob always
// loses the shaping rather than inventing one, however the fallback is reached.
constexpr double curveNeutral(CurveDomain d) { return d == CurveDomain::Bipolar ? 0.0 : 1.0; }
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
@@ -45,7 +47,10 @@ inline constexpr int kCurveNodeGrabRadius = 6;
// slope, so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints
// (velocity 0 and 127) are load-bearing: they keep eval total over the domain and are never
// deletable. eval is HOMOGENEOUS in y — scaling every knot's value by k scales the whole curve
// by k exactly, which is what lets the codec fold a retired depth control into stored knots.
// by k TO WITHIN DOUBLE ROUNDING (the Hermite basis and the Fritsch-Carlson tangent are exactly
// degree-1 homogeneous in real arithmetic; `fl(k*b) - fl(k*a)` isn't bit-identical to
// `k*(b-a)`), which is what lets the codec (component_state_io.h's v12 pre-lift) fold a retired
// depth control into stored knots and still sound identical.
class VelocityCurve {
public:
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
@@ -117,6 +122,12 @@ public:
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
private:
// Private: an implicit-default curve is empty (no endpoints) and Unipolar, so a stray
// default-construction wouldn't fail loudly — it would eval() to unity gain everywhere,
// or a full +/-1 (a full-scale transpose / wide-open filter) if ever read as bipolar. Build
// through flat()/linear()/zero()/fromPoints(), all of which establish the endpoint invariant.
VelocityCurve() = default;
// Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
// invariant, mutators preserve it.
std::vector<VelocityPoint> points_;
+11 -8
View File
@@ -90,18 +90,21 @@ namespace reasampler::instrument::map {
// prefix and lifts to 0 — the hard seam it always played.
//
// v12 (CURRENT WRITE FORMAT) is v11 PLUS the velocity->PITCH transfer curve (count + points,
// the same shape as v7's), appended after the loop crossfade. It also RE-INTERPRETS two frozen
// slots inside the v9 filter tail — the byte shape is untouched, only the meaning at v12+:
// the same shape as v7's), appended after the loop crossfade. Its y is a normalized fraction
// of kVelocityPitchRangeSemitones (play_params.h) — a full-scale constant that lives OUTSIDE
// this frozen ladder, so retuning it re-tunes every saved v12 project's pitch-curve throw. It
// also RE-INTERPRETS two frozen slots inside the v9 filter tail — the byte shape is untouched,
// only the meaning at v12+:
// * the filter's velocity curve is now BIPOLAR [-1,+1] and is the whole velocity->cutoff
// amount, not a [0,1] shape scaled by a separate depth;
// * the retired filter velAmount slot is written as a constant 1.0 and ignored on read.
// PRE-v12 LIFT: the stored [0,1] filter curve has every knot's y multiplied by that blob's
// velAmount and is re-read as bipolar. eval is homogeneous in y, so the lifted curve evaluates
// to exactly velAmount * oldCurve(v) — the product the voice used to compute per note — and a
// pre-v12 project sounds identical. A pre-v12 blob carries no pitch curve at all and lifts to
// the bipolar flat-at-zero default, which transposes nothing. A DOWNGRADE to a pre-v12 binary
// reads the constant 1.0 depth against a curve whose negative half clamps away, so it
// reproduces the curve's positive half only.
// velAmount and is re-read as bipolar. eval is homogeneous in y to within double rounding (see
// velocity_curve.h), so the lifted curve evaluates to velAmount * oldCurve(v) — the product the
// voice used to compute per note — and a pre-v12 project sounds identical. A pre-v12 blob
// carries no pitch curve at all and lifts to the bipolar flat-at-zero default, which transposes
// nothing. A DOWNGRADE to a pre-v12 binary reads the constant 1.0 depth against a curve whose
// negative half clamps away, so it reproduces the curve's positive half only.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
+6 -1
View File
@@ -140,8 +140,13 @@ void readFilterTail(ByteReader& r, InstrumentParams& p, bool preVelocityVersion)
f.env.decaySeconds = bitsToDouble(r.u64());
f.env.sustainLevel = bitsToDouble(r.u64());
f.env.releaseSeconds = bitsToDouble(r.u64());
// velAmount feeds a MULTIPLIER on the stored curve's knots (below), not a param the engine
// clamps on its own — the UI never dials it outside [-1,1] (deckBipolarFromNorm), so a
// corrupt-but-finite blob value outside that range must clamp here rather than silently
// scaling the lifted curve past what fromPoints' own [-1,1] box-clamp would then truncate.
const double velFold =
preVelocityVersion ? (std::isfinite(velAmount) ? velAmount : 0.0) : 1.0;
preVelocityVersion ? std::clamp(std::isfinite(velAmount) ? velAmount : 0.0, -1.0, 1.0)
: 1.0;
readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar,
velFold);
}
+30
View File
@@ -238,6 +238,36 @@ bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled) {
return false; // unreachable for a valid enumerator; silences a warning.
}
bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled) {
switch (id) {
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvHold:
case DeckParam::kPitchEnvDecay:
case DeckParam::kPitchEnvDepth:
return !pitchEnvEnabled;
case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
case DeckParam::kFilterModAmt:
case DeckParam::kFilterKeyTrack:
// The filter's velocity curve sits in the VELOCITY group but is a filter parameter:
// it goes inert with every other one, so no surface can reach a param the knobs can't.
case DeckParam::kFilterVelCurve:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterTrigDecay:
return !filterEnabled;
default:
return false;
}
}
bool liveCommitFor(LiveDragKind kind, int paramId) {
switch (kind) {
case LiveDragKind::kDeckKnob:
+11 -2
View File
@@ -96,8 +96,10 @@ enum DeckGroupId {
};
// Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE
// one place the three curve cells are named, so paint (draw a curve thumbnail, not a dial),
// hit-test (open a popup, not start a drag) and the popup's own title all read from it.
// one place a control id resolves to a curve target — paint (draw a curve thumbnail, not a
// dial) and hit-test (open a popup, not start a drag) both read this predicate rather than
// re-deriving which ids are curve cells. What each resolved target then shows (which stored
// curve, which title) is a separate switch — see the shell's curveFor/curveTitle.
enum class CurveTarget { kNone, kAmp, kPitch, kFilter };
CurveTarget curveTargetFor(int controlId);
@@ -163,6 +165,13 @@ OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
// a param a knob couldn't (envelope_edit.h). Amp has no enable toggle and is never inert.
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled);
// Whether a deck knob cell is drawn-but-dead: the pitch envelope's four knobs while it is
// disabled, and the filter group's tone/modulation knobs (plus its VELOCITY cell, a filter
// parameter that just sits in that group) while the filter is disabled. Every other id is
// always live. Mirrors overlayEnvInert's group-toggle-gates-its-knobs shape for the deck's own
// mouse-down/paint (the shell's deckKnobDisabled is a thin int-id wrapper over this).
bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled);
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
// centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints.
+1 -1
View File
@@ -20,7 +20,7 @@ inline constexpr int kEditorMinWidth = 840;
inline constexpr int kEditorMinHeight = 620;
// Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip,
// preview, velocity knob, curve button, channel toggle). sample_chrome partitions it.
// preview, velocity knob, channel toggle). sample_chrome partitions it.
inline constexpr int kTitleHeight = 26;
inline constexpr int kChromeRowHeight = 52;