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:
@@ -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;
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user