instrument: the filter's velocity depth knob returns and multiplies the bipolar curve; the pre-v12 lift is a pure domain re-tag

This commit is contained in:
2026-07-31 20:24:40 -04:00
parent cfb53aade3
commit 5e290119c5
22 changed files with 243 additions and 166 deletions
@@ -9,6 +9,7 @@ LiveValues foldLive(const PlayParams& params) {
LiveValues v;
v.filterSettings = params.filter.settings;
v.filterModAmount = params.filter.modAmount;
v.filterVelAmount = params.filter.velAmount;
v.filterKeyTrack = params.filter.keyTrack;
v.filterEnv = params.filter.env;
v.filterAhd = params.filter.trigEnv;
+4
View File
@@ -34,6 +34,10 @@ inline constexpr double kLiveRampSeconds = 0.020;
struct LiveValues {
filter::FilterSettings filterSettings{};
double filterModAmount = 0.0;
// The DEPTH scaling the velocity curve, not the curve's value: the note's velocity is
// latched, its depth is a control, exactly as filterKeyTrack is a control over a latched
// note number.
double filterVelAmount = 0.0;
double filterKeyTrack = 0.0;
AdsrParams filterEnv{};
AhdParams filterAhd{};
+7 -5
View File
@@ -110,21 +110,23 @@ struct PitchEnvParams {
// normalized control positions verbatim rather than a parallel set, so no control range is
// re-derived here; `filter_params.h` owns every law that maps them to Hz/Q/depth.
//
// The modulation depths below land in that same normalized cutoff domain and sum before a
// single clamp; all are zero/neutral by default.
// The three modulation depths below land in that same normalized cutoff domain and sum
// before a single clamp; all three are zero/neutral by default.
struct FilterParams {
bool enabled = false;
instrument::engine::filter::FilterSettings settings;
double modAmount = 0.0; // bipolar [-1,+1], envelope -> cutoff
double velAmount = 0.0; // bipolar [-1,+1], scales velocityCurve's output
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
// The filter envelope takes the same shape the amp does under the active play mode:
// AHDSR in Gate, AHD in Trigger. Both are stored, so a mode flip cannot lose either
// mode's dialled values (see core/instrument/CLAUDE.md).
AdsrParams env; // Gate: the same staged AHDSR the amp runs; frames
AhdParams trigEnv; // Trigger: the same staged AHD the amp runs; frames
// Velocity -> cutoff, in the normalized cutoff domain. BIPOLAR, so the curve is both the
// shape and the amount — there is no separate depth knob behind it (the retired velAmount
// was exactly that, and a signed depth multiplying a signed curve made the sign unreadable).
// Velocity -> cutoff, in the normalized cutoff domain. The contribution is
// velAmount * velocityCurve.eval(velocity): the BIPOLAR curve carries the shape (and its
// own sign), the depth knob scales it, and BOTH apply. The curve is flat at 0 by default,
// so no depth setting produces velocity modulation until a curve is drawn.
VelocityCurve velocityCurve = VelocityCurve::zero();
};
@@ -97,8 +97,7 @@ namespace {
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
// makes the spline reproduce a straight line for linear()-style input. Homogeneous of degree 1
// in the secants, which is what makes eval homogeneous in y (see the header).
// makes the spline reproduce a straight line for linear()-style input.
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
if (dPrev * dNext <= 0.0) return 0.0;
const double w1 = 2.0 * spanNext + spanPrev;
+4 -8
View File
@@ -17,10 +17,10 @@ inline constexpr double kVelMax = 127.0;
inline constexpr double kCurveYMax = 1.0;
// The curve's Y range. UNIPOLAR [0,1] is a GAIN — the amp's domain, where the do-nothing
// curve is flat at 1. BIPOLAR [-1,1] is a SIGNED modulation depth — the pitch and filter
// curve is flat at 1. BIPOLAR [-1,1] is a SIGNED modulation shape — the pitch and filter
// domains, where the do-nothing curve is flat at 0 and the sign picks the direction. A
// bipolar curve is therefore both the shape and the amount: there is no separate depth
// control behind it.
// bipolar curve does not preclude a depth control beside it: the filter has one, and the two
// compose multiplicatively (play_params.h).
enum class CurveDomain { Unipolar, Bipolar };
constexpr double curveYMin(CurveDomain d) { return d == CurveDomain::Bipolar ? -1.0 : 0.0; }
@@ -46,11 +46,7 @@ inline constexpr int kCurveNodeGrabRadius = 6;
// overshoots a segment's value range. For collinear knots the tangents reduce to the secant
// 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 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.
// deletable.
class VelocityCurve {
public:
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
+5 -1
View File
@@ -131,7 +131,8 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
filterModAmount_ = p.filter.modAmount;
filterKeyTrack_ = p.filter.keyTrack;
filterVelOffset_ = p.filter.velocityCurve.eval(static_cast<double>(velocity));
filterVelCurve_ = p.filter.velocityCurve.eval(static_cast<double>(velocity));
filterVelOffset_ = p.filter.velAmount * filterVelCurve_;
filterRate_ = static_cast<double>(sample.sampleRate);
rModAmount_.set(p.filter.modAmount);
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
@@ -248,6 +249,9 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
}
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
filterKeyTrack_ = live.filterKeyTrack;
// The note's curve value stays latched; only the depth over it is live. Both this and the
// key-track depth land in the base cutoff, so they glide through rBaseCutoff_ below.
filterVelOffset_ = live.filterVelAmount * filterVelCurve_;
filterSettings_.morphLaw = live.filterSettings.morphLaw;
const double baseTarget = filterCutoffBaseTarget(note_);
if (snap) {
+4 -1
View File
@@ -602,7 +602,10 @@ private:
double filterRate_ = 0.0;
double filterCutoffNorm_ = 1.0;
double filterModAmount_ = 0.0;
double filterVelOffset_ = 0.0; // velocityCurve.eval(velocity), fixed per note
// The curve's value at THIS note's velocity — a fact about the note, latched at note-on —
// and the product with the live depth, which a live depth move recomputes.
double filterVelCurve_ = 0.0;
double filterVelOffset_ = 0.0;
double filterKeyTrack_ = 0.0;
instrument::engine::filter::FilterSettings filterSettings_{}; // the note's tone controls
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped