filter: sweep the corner continuously; raise the editor floor to 840x620
Cutoff-only re-solve (15.5 vs 56.9 ns/frame) makes the unquantized sweep affordable, replacing the 2048-step mod quantizer. Live-compute parameters remain blocked on a shell-architecture ruling.
This commit is contained in:
@@ -51,14 +51,6 @@ inline double filterNormPerOctave() {
|
||||
flt::filterNormFromCutoffHz(flt::kFilterCutoffMinHz));
|
||||
}
|
||||
|
||||
// A modulated cutoff re-solves the SVF coefficients, which costs a tan() plus the morph's
|
||||
// cos/sin — so the solve is gated on the modulated position crossing one step of this
|
||||
// quantization of the sweep. 2048 steps over three decades is ~0.06 semitone, far under the
|
||||
// ear's resolution for a filter corner. tickFilterCutoff's own modAmount==0 early-out (see
|
||||
// there) skips the envelope/clamp/quantize math too, so a static, already-solved voice pays
|
||||
// two cheap compares per frame there, never the tan/cos/sin.
|
||||
inline constexpr int kFilterModSteps = 2048;
|
||||
|
||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a
|
||||
// poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks.
|
||||
// When the caller opts in (start()'s declickTakeover), start() records the last rendered
|
||||
@@ -178,26 +170,29 @@ private:
|
||||
return amp;
|
||||
}
|
||||
|
||||
// Advances the filter envelope and re-solves the filter's coefficients when the modulated
|
||||
// cutoff has moved a whole quantization step (see kFilterModSteps); state preservation
|
||||
// across that solve is voice_filter's own contract (voice_filter.h / filter/CLAUDE.md).
|
||||
// Advances the filter envelope and re-solves the corner from the modulated cutoff. The
|
||||
// solve is UNQUANTIZED: the corner tracks the envelope continuously, so a sweep glides
|
||||
// rather than staircasing. State preservation across the solve is voice_filter's own
|
||||
// contract (voice_filter.h / filter/CLAUDE.md). Do not reintroduce a step quantizer on the
|
||||
// control value to save the solve — setCutoffNorm exists to make the solve cheap instead.
|
||||
//
|
||||
// filterModAmount_ is fixed for the note's lifetime (set once in start()), so once the
|
||||
// first solve has run (filterModStep_ != -1) a zero depth can only ever re-derive the same
|
||||
// cutoff — skip the envelope tick, clamp, and quantization entirely rather than pay them
|
||||
// to land on the answer already solved. filterModStep_ == -1 (forced by start()/retune()
|
||||
// via updateFilterCutoffBase) still falls through here so the base cutoff's own solve runs.
|
||||
// Two exact skips, neither of which rounds the control: filterModAmount_ is fixed for the
|
||||
// note's lifetime, so a zero depth can only ever re-derive the cutoff already solved; and a
|
||||
// held envelope (sustain, or finished) reproduces the previous position bit-for-bit. Both
|
||||
// compare the value itself, so they can never suppress a move the ear would hear.
|
||||
// filterSolved_ == false (forced by start()/retune() via updateFilterCutoffBase) falls
|
||||
// through both so a moved base always re-solves.
|
||||
void tickFilterCutoff() {
|
||||
if (filterModAmount_ == 0.0 && filterModStep_ != -1) return;
|
||||
if (filterModAmount_ == 0.0 && filterSolved_) return;
|
||||
double cut = static_cast<double>(filterBaseCutoff_) +
|
||||
filterModAmount_ * filterEnv_.tick();
|
||||
if (cut < 0.0) cut = 0.0;
|
||||
if (cut > 1.0) cut = 1.0;
|
||||
const int step = static_cast<int>(cut * kFilterModSteps + 0.5);
|
||||
if (step == filterModStep_) return;
|
||||
filterModStep_ = step;
|
||||
filterSettings_.cutoffNorm = static_cast<float>(cut);
|
||||
filter_.prepare(filterSettings_, filterRate_);
|
||||
const float cutNorm = static_cast<float>(cut);
|
||||
if (filterSolved_ && cutNorm == filterSolvedCutoff_) return;
|
||||
filterSolvedCutoff_ = cutNorm;
|
||||
filterSolved_ = true;
|
||||
filter_.setCutoffNorm(cutNorm, filterRate_);
|
||||
}
|
||||
|
||||
// The cutoff position before the envelope: the stored knob position plus this note's
|
||||
@@ -213,7 +208,7 @@ private:
|
||||
if (base < 0.0) base = 0.0;
|
||||
if (base > 1.0) base = 1.0;
|
||||
filterBaseCutoff_ = static_cast<float>(base);
|
||||
filterModStep_ = -1; // forces the next frame to solve
|
||||
filterSolved_ = false; // forces the next frame to solve
|
||||
}
|
||||
|
||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||
@@ -479,21 +474,21 @@ private:
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// The voice's OWN filter and filter envelope — per-voice, never shared, so two notes at
|
||||
// different envelope phases are filtered independently. filterSettings_ is this note's
|
||||
// copy of the control positions with cutoffNorm overwritten per solve; filterCutoffNorm_
|
||||
// keeps the unmodulated knob position the base is rebuilt from. filterRate_ <= 0 makes
|
||||
// prepare() bypass rather than invent a rate.
|
||||
// different envelope phases are filtered independently. filterCutoffNorm_ keeps the
|
||||
// unmodulated knob position the base is rebuilt from. Q, morph and drive are note-constants
|
||||
// solved once by start()'s prepare(), which is why every later re-solve is cutoff-only.
|
||||
// filterRate_ <= 0 makes prepare() bypass rather than invent a rate.
|
||||
instrument::engine::filter::VoiceFilter filter_;
|
||||
AdsrEnvelope filterEnv_;
|
||||
instrument::engine::filter::FilterSettings filterSettings_;
|
||||
bool filterOn_ = false;
|
||||
double filterRate_ = 0.0;
|
||||
double filterCutoffNorm_ = 1.0;
|
||||
double filterModAmount_ = 0.0;
|
||||
double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note
|
||||
double filterKeyTrack_ = 0.0;
|
||||
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
|
||||
int filterModStep_ = -1; // last solved cutoff step; -1 forces a solve
|
||||
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
|
||||
float filterSolvedCutoff_ = 1.0f; // the position the live coefficients were solved from
|
||||
bool filterSolved_ = false; // false forces the next frame to solve
|
||||
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
|
||||
Reference in New Issue
Block a user