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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ₙ*(1−w) + ref*w = out*(1−1) + 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.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user