feat: run the per-voice filter between the pitch and amp stages, with its own deck

Params ride the one parameter set; payload v8 -> v9, off by default.
Deck composition moves to a pure deck_groups module in pitch -> filter -> amp order.
This commit is contained in:
2026-07-30 15:26:14 -04:00
parent c9c708a338
commit 67215509cb
25 changed files with 1354 additions and 191 deletions
+26 -2
View File
@@ -9,6 +9,7 @@
#include <vector>
#include "core/audio/peaks.h"
#include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/velocity_curve.h"
namespace reasampler {
@@ -90,15 +91,38 @@ struct PitchEnvParams {
double peakSemitones = 0.0; // signed depth at the peak
};
// Per-voice resonant filter, off by default (enabled=false -> the render path skips it
// entirely -> bit-identical to the un-filtered engine). Holds the filter module's OWN
// 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 three modulation depths all land in that same normalized cutoff domain and sum before a
// single clamp: `modAmount` scales the per-frame filter envelope, `velAmount` scales the
// note-on velocity through `velocityCurve`, and `keyTrack` moves cutoff by octaves per octave
// above the root. 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], velocity -> cutoff
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
AdsrParams env; // the same staged AHDSR the amp runs; frames
// Shapes velocity before velAmount scales it. Linear rather than the amp's flat() default
// because a flat curve under a depth control would make every velocity the same offset;
// the no-op at rest is velAmount == 0, not the curve.
VelocityCurve velocityCurve = VelocityCurve::linear();
};
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
// default is layered on at (de)serialization, see kDefaultPitchEngine.
// Varispeed, pitch envelope off, filter off) — core regression tests rely on this; the
// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine.
struct PlayParams {
PlayMode playMode = PlayMode::Gate;
AdsrParams adsr;
TriggerParams trigger;
PitchEngine pitchEngine = PitchEngine::Varispeed;
PitchEnvParams pitchEnv;
FilterParams filter;
};
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"