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:
@@ -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 (1–32, 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 Fritsch–Carlson 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 Fritsch–Carlson 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user