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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_++);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user