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
+7 -2
View File
@@ -13,14 +13,19 @@ reasampler_test(velocity_curve LINK velocity_curve)
reasampler_pure_library(master_gain SOURCES master_gain.cpp)
reasampler_test(master_gain LINK master_gain)
# Declared before sampler_core because the voice now runs one per sounding note.
add_subdirectory(filter)
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
# block render). The per-sample render half stays inline in voice.h precisely so this TU
# boundary costs the hot path nothing.
reasampler_pure_library(sampler_core
SOURCES voice.cpp voice_engine.cpp
LINK PUBLIC peaks pitch_shift velocity_curve)
LINK PUBLIC peaks pitch_shift velocity_curve filter)
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
# or REAPER type reaching the core would fail to compile or link here.
reasampler_test(sampler_core LINK sampler_core)
add_subdirectory(filter)
# The filter's own seams are covered by the four targets in filter/; this one covers the
# integration: pipeline order, per-voice independence, and the off-by-default bit-identity.
reasampler_test(sampler_filter LINK sampler_core)
+8 -6
View File
@@ -6,10 +6,10 @@ The pure per-voice filter a sounding voice runs: a Zavalishin TPT/SVF with a con
morph under one of two laws — HP→BP→LP or HP→notch→LP — and a drive stage. No REAPER, no
VST3, no allocation, no I/O. Everything
here lives in `reasampler::instrument::engine::filter`, nested per the
directory-mirrors-namespace convention — this keeps `FilterSettings` and friends out of
`reasampler::instrument::engine` proper, where `zone_params.h` lives, since this module has
no call site yet to force a collision into the open at compile time. Five files, one
responsibility each:
directory-mirrors-namespace convention, which keeps `FilterSettings` and friends out of
`reasampler::instrument::engine` proper where `play_params.h` lives — and `play_params.h`
now stores a `FilterSettings` by value, so that separation is load-bearing rather than
merely tidy. Five files, one responsibility each:
- `filter_params` — the control domain: normalized [0,1] knob position → cutoff Hz, Q, and
drive depth, plus the exact inverses for cutoff and Q.
@@ -224,8 +224,10 @@ topology.
band-pass, `HighNotchLow` on pure high-pass, since it has no band tap to land on.
- **Measuring a null needs a ring-time-adequate settle window.** At `Q = 10` the leftover
transient alone reads as 52 dB after 0.15 s and would be mistaken for the noise floor.
- **No call site yet.** Wiring the filter into the voice path is a separate track; nothing
in `sampler_core` references this module today.
- **The call site is `Voice::advanceFrame`**, between the pitch stage and the amp multiply.
It re-`prepare()`s only when the modulated cutoff crosses one step of a 2048-step
quantization of the sweep, because a solve costs a `tan` plus the morph's `cos`/`sin` — an
unmodulated voice must not pay for them per frame (see `voice.h`'s `kFilterModSteps`).
- **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
budget expressed in samples is therefore itself a rate assumption — a fixed 20000 samples
is ample at 48k and expires mid-decay at 96k and above.
+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"
+22
View File
@@ -92,6 +92,24 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
pitchEnv_.configure(p.pitchEnv);
pitchEnv_.noteOn();
// Filter: fresh integrators per note (prepare() preserves state on purpose, so a note-on
// is the one place that must clear it). Velocity maps through the curve once here, off the
// per-frame path, exactly as the amp's velocityGain_ does.
filterOn_ = p.filter.enabled;
if (filterOn_) {
filterSettings_ = p.filter.settings;
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
filterModAmount_ = p.filter.modAmount;
filterKeyTrack_ = p.filter.keyTrack;
filterVelOffset_ =
p.filter.velAmount * p.filter.velocityCurve.eval(static_cast<double>(velocity));
filterRate_ = static_cast<double>(sample.sampleRate);
filterEnv_.configure(p.filter.env);
filterEnv_.noteOn();
filter_.reset();
updateFilterCutoffBase(note);
}
// Prime the already-sized per-channel shifters with the first window of the actual
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
// the sample end, since that silence is the true stream there). The tap parks on source
@@ -162,6 +180,9 @@ void Voice::retune(int note) {
if (!active_ || sample_ == nullptr) return;
note_ = note;
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
if (filterOn_) updateFilterCutoffBase(note);
}
void Voice::release() {
@@ -169,6 +190,7 @@ void Voice::release() {
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
releasing_ = true;
env_.noteOff();
filterEnv_.noteOff();
}
} // namespace reasampler
+92 -6
View File
@@ -13,6 +13,8 @@
#include "core/audio/peaks.h"
#include "core/instrument/engine/envelopes.h"
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/velocity_curve.h"
@@ -40,6 +42,22 @@ inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
return std::pow(2.0, semis / 12.0);
}
// One octave expressed in the cutoff control's normalized domain, read out of the filter
// module's OWN inverse rather than re-derived from its endpoints — the log law belongs to
// filter_params, and a second copy here could drift from it. Evaluated at note-on only.
inline double filterNormPerOctave() {
namespace flt = instrument::engine::filter;
return static_cast<double>(flt::filterNormFromCutoffHz(2.0f * flt::kFilterCutoffMinHz) -
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, and it collapses the solve to nothing across a static
// envelope stage: an unmodulated voice pays one integer compare per frame.
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
@@ -159,6 +177,37 @@ 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). prepare() deliberately
// preserves integrator state, so a moving cutoff glides rather than clicking.
void tickFilterCutoff() {
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_);
}
// The cutoff position before the envelope: the stored knob position plus this note's
// velocity offset and key-tracking. Recomputed at note-on and at a legato retune (both
// move the note), never per frame.
void updateFilterCutoffBase(int note) {
double base = filterCutoffNorm_ + filterVelOffset_;
if (filterKeyTrack_ != 0.0 && sample_ != nullptr) {
base += filterKeyTrack_ *
(static_cast<double>(note - sample_->rootNote) / 12.0) *
filterNormPerOctave();
}
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
}
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
// applied ungated so the boundary frame reproduces the old level exactly.
@@ -252,6 +301,9 @@ private:
const double envFactor =
(pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
// Both pitch branches leave the UNENVELOPED post-pitch signal here; the filter acts on
// it and the amp gain is applied afterwards, so the pipeline is pitch -> filter -> amp
// and the amp envelope shapes the filtered result (drive included).
double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// Feed the shifters the source stream at unity rate (duration held) and transpose
@@ -282,7 +334,7 @@ private:
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
outL = shiftedL * gain;
outL = shiftedL;
if (stereo) {
if (haveR && shiftR_.configured()) {
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
@@ -300,13 +352,12 @@ private:
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
gain;
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
} else {
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
// shifted value from the mono feed; mirror it to R. Do NOT call
// shiftL_.process again this frame.
outRlocal = shiftedL * gain;
outRlocal = shiftedL;
}
}
++feedPos_;
@@ -330,16 +381,34 @@ private:
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
outL = srcL * gain;
outL = srcL;
if (stereo) {
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
outRlocal = srcR * gain;
outRlocal = srcR;
}
ratio_ = baseRatio_ * envFactor;
}
// Skipped whole when disengaged (the default), so an un-filtered render stays
// bit-identical to the pre-filter engine.
if (filterOn_) {
tickFilterCutoff();
outL = static_cast<double>(filter_.process(0, static_cast<float>(outL)));
// Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the
// filtered result is exactly what a second identical filter would produce — one
// less kernel pass per frame for the same samples.
if (stereo) {
outRlocal = haveR
? static_cast<double>(filter_.process(1, static_cast<float>(outRlocal)))
: outL;
}
}
outL *= gain;
if (stereo) outRlocal *= gain;
// Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal
// restart, seed the blend weight at 1.0 so this frame's output is
// outₙ*(1w) + ref*w = out*(11) + ref*1 = ref (exact boundary identity).
@@ -401,6 +470,23 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
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.
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
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
//