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
+2 -2
View File
@@ -262,13 +262,13 @@ 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's homogeneity in y (see `velocity_curve.h`) is what the codec's pre-v12 lift rests on.
- `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 shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `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/`
- `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. v12 also RE-INTERPRETS two frozen slots inside the v9 filter tail (its velocity curve is now bipolar and self-scaling; the retired velAmount slot carries a constant 1.0) — same bytes, version-keyed meaning, with the pre-v12 fold documented in `component_state_io.h`.
- `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. 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.
@@ -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
+13 -15
View File
@@ -93,18 +93,16 @@ namespace reasampler::instrument::map {
// 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 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.
// also RE-TAGS the DOMAIN of one frozen slot inside the v9 filter tail: that curve's y is read
// as BIPOLAR [-1,+1] from v12 on, having been UNIPOLAR [0,1] before. Every other filter slot,
// velAmount included, keeps its meaning — the cutoff contribution is still
// velAmount * curve(velocity).
// PRE-v12 LIFT: a domain re-tag and nothing more. A pre-v12 curve's stored y values all lie in
// [0,1], which is inside [-1,+1], so the widened box-clamp alters no knot and eval is unchanged
// at every velocity — a pre-v12 project sounds identical without any rounding argument. 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 re-narrows the domain, so a curve
// drawn into the negative half comes back with that half clamped to 0.
//
// 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
@@ -156,9 +154,9 @@ inline constexpr std::uint32_t kParamsCurveVersion = 10;
// v10 + the loop-crossfade frame count.
inline constexpr std::uint32_t kParamsLoopVersion = 11;
// v11 + the velocity->pitch curve, and the version from which the filter's velocity curve is
// bipolar and self-scaling. Both the appended tail and the filter-tail lift branch on THIS,
// never on kParamsPayloadVersion.
// v11 + the velocity->pitch curve; the appended tail branches on THIS, never on
// kParamsPayloadVersion. The filter curve's v12 domain re-tag needs no branch of its own — a
// pre-v12 curve's y values are already valid bipolar ones.
inline constexpr std::uint32_t kParamsVelocityVersion = 12;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
+18 -30
View File
@@ -93,12 +93,11 @@ void readSecondsPlayTail(ByteReader& r, InstrumentParams& p, double projectRate)
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// Read a velocity curve tail into `curve`, interpreting its y values in `domain` and scaling
// them by `yScale` (the pre-v12 filter lift folds a retired depth in that way — see
// component_state_io.h). fromPoints repairs the X-order/endpoint invariant defensively; a
// truncated read leaves `curve` at whatever default it came in with.
// Read a velocity curve tail into `curve`, interpreting its y values in `domain` — the domain
// is not on the wire, it is a property of the slot. fromPoints repairs the X-order/endpoint
// invariant defensively; a truncated read leaves `curve` at whatever default it came in with.
void readCurveTail(ByteReader& r, VelocityCurve& curve,
reasampler::instrument::engine::CurveDomain domain, double yScale) {
reasampler::instrument::engine::CurveDomain domain) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
@@ -108,7 +107,7 @@ 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 * yScale});
pts.push_back(VelocityPoint{vel, value});
}
if (r.ok) {
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain);
@@ -116,10 +115,10 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve,
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec. `preVelocityVersion`
// selects the pre-v12 lift: the frozen velAmount slot is folded into the curve's knots instead
// of being kept as a separate depth.
void readFilterTail(ByteReader& r, InstrumentParams& p, bool preVelocityVersion) {
// which is what makes a v8 blob play bit-identically under the new codec. The curve reads as
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
// v12 domain re-tag needs no version branch (see component_state_io.h).
void readFilterTail(ByteReader& r, InstrumentParams& p) {
FilterSeconds& f = p.play.filter;
f.enabled = (r.u8() != 0);
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
@@ -128,27 +127,20 @@ void readFilterTail(ByteReader& r, InstrumentParams& p, bool preVelocityVersion)
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
: engine::filter::MorphLaw::HighBandLow;
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these reach
// Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
double modAmount = bitsToDouble(r.u64());
double velAmount = bitsToDouble(r.u64());
double keyTrack = bitsToDouble(r.u64());
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
f.env.attackSeconds = bitsToDouble(r.u64());
f.env.holdSeconds = bitsToDouble(r.u64());
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::clamp(std::isfinite(velAmount) ? velAmount : 0.0, -1.0, 1.0)
: 1.0;
readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar,
velFold);
readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar);
}
// A curve exponent off the wire. A corrupt/non-finite value degrades to the LINEAR neutral
@@ -255,7 +247,7 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
if (curveTail) {
readCurveTail(r, p.velocityCurve,
reasampler::instrument::engine::CurveDomain::Unipolar, 1.0);
reasampler::instrument::engine::CurveDomain::Unipolar);
}
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
// leave the seconds product defaults on p.play.
@@ -315,9 +307,7 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
putLE(out, doubleToBits(f.modAmount));
// The retired filter velAmount's frozen slot: a constant 1.0 so a pre-v12 binary reading
// this blob scales the curve by unity rather than silencing it (see component_state_io.h).
putLE(out, doubleToBits(1.0));
putLE(out, doubleToBits(f.velAmount));
putLE(out, doubleToBits(f.keyTrack));
putLE(out, doubleToBits(f.env.attackSeconds));
putLE(out, doubleToBits(f.env.holdSeconds));
@@ -370,10 +360,8 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
if (hasStart) p.startPoint = r.i64();
readSecondsPlayTail(r, p, projectRate);
p.keyTrack = bitsToDouble(r.u64());
readCurveTail(r, p.velocityCurve, reasampler::instrument::engine::CurveDomain::Unipolar, 1.0);
if (pv >= kParamsFilterVersion) {
readFilterTail(r, p, /*preVelocityVersion=*/pv < kParamsVelocityVersion);
}
readCurveTail(r, p.velocityCurve, reasampler::instrument::engine::CurveDomain::Unipolar);
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
if (pv >= kParamsLoopVersion) {
// A negative fade is meaningless and would reach resolveLoop's clamp anyway; refusing
@@ -383,7 +371,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
}
if (pv >= kParamsVelocityVersion) {
readCurveTail(r, p.play.pitchVelocityCurve,
reasampler::instrument::engine::CurveDomain::Bipolar, 1.0);
reasampler::instrument::engine::CurveDomain::Bipolar);
}
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
+1
View File
@@ -244,6 +244,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.filter.enabled = stored.filter.enabled;
out.filter.settings = stored.filter.settings;
out.filter.modAmount = stored.filter.modAmount;
out.filter.velAmount = stored.filter.velAmount;
out.filter.keyTrack = stored.filter.keyTrack;
out.filter.velocityCurve = stored.filter.velocityCurve;
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
+1
View File
@@ -183,6 +183,7 @@ struct FilterSeconds {
bool enabled = false;
engine::filter::FilterSettings settings;
double modAmount = 0.0;
double velAmount = 0.0;
double keyTrack = 0.0;
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate
AhdSeconds trigEnv; // Trigger
+3
View File
@@ -48,6 +48,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
id(DeckParam::kFilterQ),
id(DeckParam::kFilterDrive),
id(DeckParam::kFilterModAmt),
id(DeckParam::kFilterVel),
id(DeckParam::kFilterKeyTrack)};
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
out.push_back(std::move(filter));
@@ -164,6 +165,7 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
case DeckParam::kFilterKeyTrack:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
@@ -250,6 +252,7 @@ bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled) {
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
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.
+4 -1
View File
@@ -40,6 +40,7 @@ enum class DeckParam {
kFilterQ, // resonance
kFilterDrive, // in-loop drive depth
kFilterModAmt, // filter envelope -> cutoff, +/-100%
kFilterVel, // velocity curve -> cutoff depth, +/-100%
kFilterKeyTrack, // note -> cutoff, 0..200%
kFilterLaw, // morph law row toggle: HP-BP-LP | HP-notch-LP
kFilterEnvAttack, // filter AHDSR (Gate)
@@ -128,7 +129,9 @@ DeckParam curveParamFor(DeckParam knob);
// the decoded PCM;
// - kKeyTrack and the three velocity-curve cells feed values a voice latches at note-on by
// design (the pitch ratio and the curve results), so live delivery would retune or re-gain
// a note already struck;
// a note already struck. kFilterVel is NOT one of them: it is the DEPTH over the filter
// curve's latched result, the exact shape kFilterKeyTrack already has over the latched note
// number, and it glides through the same base-cutoff ramp;
// - kTrigLength resolves playEnd_, a fact about the note, not a setting of it;
// - the overlay radios select what the editor DRAWS and reach no parameter at all.
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and