Service both VST3 parameter channels, and promote pitch key-track and Trigger length so all 44 ids issue

The SDK's own single-component sample drains inputParameterChanges in
process() and implements setParamNormalized; automation was reading the
GUI channel alone. The audio thread now patches a block it solely owns.
This commit is contained in:
2026-08-02 16:22:17 -04:00
parent bfaa0f2614
commit de5654fb6f
44 changed files with 1205 additions and 302 deletions
@@ -1,10 +1,16 @@
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
# The frozen control-position laws are their own target: they are the filter's PARAMETER
# surface, and the VST3 parameter layer reports Hz/Q/drive through them. Kept separable so
# that consumer does not take a link edge onto the per-voice kernel — the extension's link
# graph must never be able to reach the voice DSP (root CLAUDE.md, the bake invariant).
reasampler_pure_library(filter_params SOURCES filter_params.cpp)
reasampler_pure_library(filter SOURCES
filter_params.cpp
filter_coeffs.cpp
filter_morph.cpp
voice_filter.cpp)
voice_filter.cpp
LINK PUBLIC filter_params)
# Four test targets along the module's own seams so each asserts one domain. filter_tests
# alone owns the analytic reference and the steady-state gain measurement — a forked copy of
+6 -1
View File
@@ -5,8 +5,13 @@
namespace reasampler::instrument::engine {
LiveValues foldLive(const PlayParams& params) {
LiveValues foldLive(const PlayParams& params, double keyTrack) {
LiveValues v;
v.keyTrack = keyTrack;
// Folded here, not at the voice: Voice::start reads the block's value directly, so the
// spline rule has to be applied on the way in or the two would answer differently.
v.splineActive = splineActive(params);
v.lengthFraction = effectiveLengthFraction(params);
v.filterSettings = params.filter.settings;
v.filterModAmount = params.filter.modAmount;
v.filterVelAmount = params.filter.velAmount;
+21 -2
View File
@@ -52,6 +52,19 @@ struct LiveValues {
// ordinarily live.
double playRate = 1.0;
double pitchOffsetSemitones = 0.0;
// Two more members of playRate's note-on-latched class, here for the same reason it is:
// both resolve a fact the voice fixes at note-on (the pitch ratio, and playEnd_), so live
// delivery would retune or re-span a note already struck. Voice::start receives them as
// arguments; applyLive never touches either.
double keyTrack = kKeyTrackDefault;
// ALREADY spline-folded (effectiveLengthFraction) — a drawn contour is a pure time function
// over the whole sample, so the stored knob is inert while one is active and the block must
// carry what the voice will actually play, not the stored value.
double lengthFraction = 1.0;
// The drawn-EG state the fold above reads. A mode flip travels by reload like the contours
// themselves, so this is not a control; it rides here only so a block-boundary write of
// Trigger length (a host automation point) can apply the SAME fold rather than un-doing it.
bool splineActive = false;
};
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
@@ -59,8 +72,10 @@ static_assert(std::is_trivially_copyable_v<LiveValues>,
"the live block is copied under a seqlock — it must stay a plain value");
// The ONE derivation of the live block from the parameter set. Every publisher goes through
// here so there is a single site to keep in step with PlayParams.
LiveValues foldLive(const PlayParams& params);
// here so there is a single site to keep in step with PlayParams. `keyTrack` is passed in
// because it belongs to the capture/instrument scalar beside the play bundle, not to
// PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit.
LiveValues foldLive(const PlayParams& params, double keyTrack);
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
// and an even generation; the reader copies the block and re-checks the generation, retrying
@@ -96,6 +111,10 @@ public:
seq_.store(next, std::memory_order_release); // even: complete and coherent
}
// The last generation published, without copying the block — one relaxed load, so a reader
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
// Copies the block into `out` and returns the generation actually observed, or 0 when
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
// a torn copy and MUST be discarded — compare the return against 0 before using it).
@@ -17,6 +17,13 @@ double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
double masterGainDbFromNorm(double norm) {
norm = clamp01(norm);
if (norm <= 0.0) return -std::numeric_limits<double>::infinity();
// UNITY IS EXACT, and the argument is arithmetic rather than structural — a host's
// reset-to-default arrives here as toPlain(defaultNormalized) and must land on 0.0 dB, not a
// hair off it. fl(60/84) differs from 60/84 by δ ≈ 1.6e-17; 84·δ ≈ 1.33e-15 sits under the
// half-ulp of 60 (3.55e-15), so -60 + fl(60/84)·84 rounds to exactly 60 and the sum to 0.
// PRECONDITION: no FP contraction. Fused into a single FMA the residue survives as 1.33e-15.
// Safe on the shipped MSVC/x64 default (no FMA without /arch:AVX2); a build that enables
// contraction here breaks the exactness test in test_param_units, which is where it surfaces.
return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb);
}
+6 -1
View File
@@ -148,6 +148,11 @@ struct FilterParams {
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
// Standard 12-tone-ET tracking, and the ONE home for that number: the capture's own scalar, the
// instrument's stored scalar and the live block all default from here, so a blob predating the
// field and a block published before the first note can never disagree about it.
inline constexpr double kKeyTrackDefault = 1.0;
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
// on this; the Preserve product default is layered on at (de)serialization, see
@@ -267,7 +272,7 @@ struct SampleData {
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
// (never per frame). Default flat y=1 — every velocity plays at unity.
+5 -5
View File
@@ -19,7 +19,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
}
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover,
double stretchRate) {
double stretchRate, double keyTrack, double lengthFraction) {
// Before any state reset, record the pre-cut reference (last rendered output) and mark
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
@@ -72,6 +72,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
// the control outright — the predicate is spelled the same way advanceFrame spells it.
preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
keyTrack_ = (keyTrack < 0.0) ? sample.keyTrack : keyTrack;
recomputeBaseRatio();
// pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at
// Pitch 0 it is a division by exactly 1.0.
@@ -120,10 +121,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
} else {
// Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)) —
// map/trigger_seam.h's formula, evaluated inline because the engine does not depend on
// map/. The spline fold is effectiveLengthFraction (play_params.h); a second copy of it
// here is what let a stored-but-inert %-knob shorten the bake while the voice played
// the whole take.
double frac = effectiveLengthFraction(p);
// map/. The caller's value is ALREADY spline-folded (foldLive does it); the snapshot
// fallback folds here, because a stored-but-inert %-knob must not shorten the span.
double frac = (lengthFraction < 0.0) ? effectiveLengthFraction(p) : lengthFraction;
if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately
if (frac > 1.0) frac = 1.0;
std::int64_t playLen = static_cast<std::int64_t>(
+17 -2
View File
@@ -45,6 +45,9 @@ inline double pitchRatio(int note, int rootNote) {
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
// At the root note the offset is 0 regardless of keyTrack.
// "Not supplied" for Voice::start's two snapshot-defaulted note-on latches; see start().
inline constexpr double kLatchFromSnapshot = -1.0;
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
const double semis = static_cast<double>(note - rootNote) * keyTrack;
return std::pow(2.0, semis / 12.0);
@@ -124,8 +127,16 @@ public:
// to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value —
// sample.play.playRate is NOT read here, because the published block outranks the snapshot's
// possibly-stale copy of it.
//
// `keyTrack` and `lengthFraction` are the other two members of stretchRate's note-on-latched
// class and arrive the same way, for the same structural reason. Negative = not supplied,
// which reads the snapshot's own value (sample.keyTrack, effectiveLengthFraction(play)) —
// both are non-negative by domain, so the sentinel can never collide with a real one.
// VoiceEngine::startVoice always supplies them, resolved from the published block when there
// is one; the sentinel is for a caller that has no block to consult.
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
double stretchRate = 1.0);
double stretchRate = 1.0, double keyTrack = kLatchFromSnapshot,
double lengthFraction = kLatchFromSnapshot);
// Mono legato takeover: re-pitch this active voice to `note` without touching the
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
@@ -201,7 +212,7 @@ private:
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
void recomputeBaseRatio() {
if (sample_ == nullptr) return;
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, sample_->keyTrack) *
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
}
@@ -710,6 +721,10 @@ private:
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive
double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1
// Key-track, LATCHED at note-on beside the rate. Held here rather than re-read off the
// snapshot so a legato retune and a live block re-apply the note's own value; a published
// move reaches the next note only.
double keyTrack_ = kKeyTrackDefault;
// Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters
// were never sized falls back to the varispeed one, and the two domains differ. Latched at
// note-on beside rateRatio_, which start() resolves from the same predicate.
+7 -2
View File
@@ -56,9 +56,14 @@ void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
refreshLive();
// THE read of the note-on-latched commit class, and the only one: a published block outranks
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
// never touches the rate — so a Rate move reaches the next note and no sounding one.
// touches none of these three — so a move reaches the next note and no sounding one.
const double rate = haveLive_ ? live_.playRate : sample_.play.playRate;
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate);
const double keyTrack = haveLive_ ? live_.keyTrack : sample_.keyTrack;
// Already spline-folded in the block; the snapshot branch folds here so the two agree.
const double lengthFraction =
haveLive_ ? live_.lengthFraction : effectiveLengthFraction(sample_.play);
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack,
lengthFraction);
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
voice.setStartOrder(nextStartOrder_++);
}