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
+2 -2
View File
@@ -1,8 +1,8 @@
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / ui)
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / param / ui)
## Scope
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in six
subdirectories:
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
@@ -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_++);
}
+13
View File
@@ -5,12 +5,25 @@
// not link the bank model and the WAV codec to reach one value struct. `resolvePlay`, which
// turns them into the engine's frame domain, stays in sample_map with the rest of the mapping.
#include <cstdint>
#include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / …
namespace reasampler::instrument::map {
using instrument::engine::VelocityCurve;
// THE seconds -> frames fold, and the one home for its rounding: resolvePlay resolves the whole
// bundle through it, and the audio thread's live patch (param/param_live) resolves one stage
// time through it, so a stage time can never land on a different frame depending on the writer.
// A non-positive rate yields 0 rather than inventing one; a negative time floors at 0.
inline std::int64_t secondsToFrames(double seconds, double sampleRate) {
if (!(sampleRate > 0.0)) return 0;
double f = seconds * sampleRate;
if (!(f > 0.0)) return 0; // also catches NaN
return static_cast<std::int64_t>(f + 0.5);
}
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
+1 -5
View File
@@ -215,11 +215,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
// carries through untouched, already a fraction.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) {
double f = sec * sr;
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
const auto secToFrames = [sr](double sec) { return secondsToFrames(sec, sr); };
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
AhdParams a;
+2 -2
View File
@@ -180,7 +180,7 @@ struct InstrumentParams {
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
// engines.
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
@@ -215,7 +215,7 @@ struct InstrumentParams {
struct ResolvedCapture {
std::string relativePath; // project-relative; the shell resolves + decodes it
int rootNote = 60; // effective: override, else bank intrinsic, else 60
double keyTrack = 1.0;
double keyTrack = kKeyTrackDefault;
VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
+41 -13
View File
@@ -11,6 +11,12 @@ these onto `Steinberg::Vst::Parameter`; it decides nothing.
A sixth peer of `engine/` / `map/` / `note/` / `bake/` / `ui/`, and it sits ABOVE `ui/`: the
parameter list is a function of `deckParamCommit` and the value binding, never the reverse.
**Where an exposed control's value lives is `valueHomeFor`'s answer, and the exposed set is
asserted against it.** Two controls sit beside the parameter set rather than in it — master gain
(the processor's atomic) and pitch key-track (`InstrumentParams::keyTrack`) — and a promotion
whose control has no home would no-op silently in both directions on the host path with nothing
to catch it at compile time. That is exactly what happened to id 1000 before the guard existed.
## Invariants
### The id table is FOREVER-FROZEN
@@ -57,6 +63,13 @@ no longer exist.
- `param_units``UnitKind`, `unitStringFor`, `plainRangeFor`, the `toPlain` / `toNormalized`
pair, and the defaults read off a default-constructed `PlaySeconds`.
- `param_format` — the eight formatters and the digits parser behind `getParamValueByString`.
- `param_live` — the AUDIO-THREAD half: one exposed control patched into the live block in
place, allocation-free and lock-free, for the host's `IParameterChanges` queue. It exists
because the model layer cannot run there (`PlaySeconds` carries velocity curves and spline
contours, so `resolvePlay` allocates) while the queue is delivered there. Every law is called —
`ui::storedFromNorm` and `map::secondsToFrames` are the same two the model path uses; what is
new is the ROUTING, and that is pinned by an exhaustive equivalence test against the model path
over every exposed control rather than by two tables that happen to agree.
## Gotchas
@@ -68,18 +81,33 @@ no longer exist.
- **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.**
No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule
out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere.
- **A curve exponent inside the knob detent but not exactly neutral reads `1.00` to the host.**
The detent lives in `curve_law`'s norm↔exponent map and the host's only handle is the norm, so
the host cannot see an off-detent near-neutral exponent — reachable only by an overlay knot
drag, which writes through `curveFromLevelAt` rather than the knob law. The editor's own label
deliberately reads the stored field directly and still shows the true value; that divergence is
structural to VST3, not a formatter defect.
- **A curve exponent inside the knob detent but not exactly neutral is NEUTRALIZED by any host
touch — a VALUE consequence, not a display one.** The detent lives in `curve_law`'s
norm↔exponent map and the host's only handle is the norm, so the host reads such an exponent
back as `1.00` (the editor's own label reads the stored field and still shows the true value).
The sharp half is the WRITE: a host write of that norm reaches `ui::setDeckParam`
`storedFromNorm``util::curveFromKnobNorm`, whose ±0.01 detent rewrites the stored exponent
to exactly `1.0`. So an off-detent near-neutral exponent set by an overlay knot drag is
silently flattened by any host touch or lane pass over that parameter.
**Assessed and ACCEPTED, not merely documented:** the alternative is to widen the exposed
parameter's law so the detent band is addressable, and §6.3 freezes that law on the first
shipped build — a permanent change to twelve parameters' normalization, to preserve a
difference the user cannot see on the knob (the detent exists precisely because a drag cannot
land on the identity reliably) and cannot hear (the band is ±0.047 of the exponent). Removing
the detent from the WRITE path alone would leave the knob unable to reach the identity, which
is the defect it was added for. The residual is confined to knot-drawn near-neutral curves.
- **Master gain's plain value at norm 0 is `-inf`**, which is outside the declared 60…+24 range
on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there.
- **The filter's four store their position as a `float`, so a not-yet-stored norm can display
one digit differently.** A host previewing a value it has sent but that has not round-tripped
through the stored float differs from the editor by up to a float ulp; at a value landing
exactly on a display rounding boundary that is worth one integer percent on morph. Both
surfaces read the MODEL in every settled state, so this is a transient of the write itself,
not a standing divergence — `test_param_format` holds those four to the plain value rather
than to the string for exactly this reason.
- **MORPH ALONE can display one digit differently from a not-yet-stored norm.** The filter's four
store their position as a `float`, but cutoff, Q and drive cast the incoming norm to `float`
*inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those
three are held to digit-for-digit string equality like everything else. Morph's path is
full-double (`clamp01(n) * 100`), so the float the model stores and the double the host holds
are genuinely different inputs — worth one integer percent at a value landing on a display
rounding boundary. Both surfaces read the MODEL in every settled state, so it is a transient of
the write itself, not a standing divergence; `test_param_format` holds morph alone to the plain
value rather than to the string.
- **A host write the MODEL clamps is not a settled state either.** Trigger length's stored
domain is `(0,1]`, so a host norm of 0 comes back as 0.01. `setParamNormalized` caches what the
model took, so the host never holds the rejected value — the sweep skips the clamped steps for
that reason rather than loosening its comparison.
+21 -6
View File
@@ -5,13 +5,28 @@ reasampler_test(param_id LINK param_id)
# The norm <-> plain layer. deck_values carries the tapers' full scales and the two field
# resolvers the defaults are read through; filter_params and master_gain are the frozen laws the
# filter's four and the gain report through, CALLED rather than restated.
# filter's four and the gain report through, CALLED rather than restated. filter_params rather
# than the whole `filter` target: this is the parameter surface, and a link edge from it onto the
# per-voice filter KERNEL would put the voice DSP in reach of any future extension-side consumer
# of param_format which root CLAUDE.md's bake invariant forbids.
reasampler_pure_library(param_units
SOURCES param_units.cpp
LINK PUBLIC deck_values param_taper curve_law master_gain filter)
reasampler_test(param_units LINK param_units param_id)
LINK PUBLIC deck_values param_taper curve_law master_gain filter_params)
# sample_map for the test alone: the host-vs-editor default agreement reads the two instance
# scalars where they LIVE, and one of them is a field of InstrumentParams.
reasampler_test(param_units LINK param_units param_id sample_map)
reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_units)
# param_id is linked for the test only: the one-formatter-two-consumers assertion sweeps the
# exposed set, which is identity's answer rather than this module's.
reasampler_test(param_format LINK param_format param_id)
# param_id and sample_map are linked for the test only: the one-formatter-two-consumers assertion
# sweeps the exposed set (identity's answer, not this module's) and reads pitch key-track where it
# lives, on InstrumentParams.
reasampler_test(param_format LINK param_format param_id sample_map)
# The audio thread's half: the live block plus the two laws it patches through. No engine
# the block is a value, not a thing the voice owns.
reasampler_pure_library(param_live
SOURCES param_live.cpp
LINK PUBLIC deck_values live_params)
# sample_map for the test alone: the equivalence assertion drives the MODEL path
# (setDeckParam -> resolvePlay -> foldLive) as its reference.
reasampler_test(param_live LINK param_live param_id param_units sample_map)
+105
View File
@@ -0,0 +1,105 @@
// param_live.cpp — see param_live.h. Three field resolvers plus one dispatch; every law is
// called, none is restated.
#include "core/instrument/param/param_live.h"
#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold)
#include "core/instrument/ui/deck_values.h" // storedFromNorm (setDeckParam's own map)
namespace reasampler::instrument::param {
namespace {
using engine::LiveValues;
// The block member a control names, in the same shape deck_values' two field resolvers take:
// LOCATION only, no law. Null for a control the block does not carry.
std::int64_t* frameField(LiveValues& v, DeckParam deck) {
switch (deck) {
case DeckParam::kAttack: return &v.adsr.attackFrames;
case DeckParam::kHold: return &v.adsr.holdFrames;
case DeckParam::kDecay: return &v.adsr.decayFrames;
case DeckParam::kRelease: return &v.adsr.releaseFrames;
case DeckParam::kTrigAttack: return &v.ampAhd.attackFrames;
case DeckParam::kTrigDecay: return &v.ampAhd.decayFrames;
case DeckParam::kPitchEnvAttack: return &v.pitchEnv.shape.attackFrames;
case DeckParam::kPitchEnvDecay: return &v.pitchEnv.shape.decayFrames;
case DeckParam::kFilterEnvAttack: return &v.filterEnv.attackFrames;
case DeckParam::kFilterEnvHold: return &v.filterEnv.holdFrames;
case DeckParam::kFilterEnvDecay: return &v.filterEnv.decayFrames;
case DeckParam::kFilterEnvRelease: return &v.filterEnv.releaseFrames;
case DeckParam::kFilterTrigAttack: return &v.filterAhd.attackFrames;
case DeckParam::kFilterTrigDecay: return &v.filterAhd.decayFrames;
default: return nullptr;
}
}
// The filter's four, which store their normalized position as float in the block exactly as the
// parameter set stores it.
float* normField(LiveValues& v, DeckParam deck) {
switch (deck) {
case DeckParam::kFilterMorph: return &v.filterSettings.morphNorm;
case DeckParam::kFilterCutoff: return &v.filterSettings.cutoffNorm;
case DeckParam::kFilterQ: return &v.filterSettings.resonanceNorm;
case DeckParam::kFilterDrive: return &v.filterSettings.driveNorm;
default: return nullptr;
}
}
double* doubleField(LiveValues& v, DeckParam deck) {
switch (deck) {
case DeckParam::kSustain: return &v.adsr.sustainLevel;
case DeckParam::kAttackCurve: return &v.adsr.attackCurve;
case DeckParam::kDecayCurve: return &v.adsr.decayCurve;
case DeckParam::kReleaseCurve: return &v.adsr.releaseCurve;
case DeckParam::kTrigHold: return &v.ampAhd.holdFraction;
case DeckParam::kTrigAttackCurve: return &v.ampAhd.attackCurve;
case DeckParam::kTrigDecayCurve: return &v.ampAhd.decayCurve;
case DeckParam::kPitchEnvHold: return &v.pitchEnv.shape.holdFraction;
case DeckParam::kPitchEnvAttackCurve: return &v.pitchEnv.shape.attackCurve;
case DeckParam::kPitchEnvDecayCurve: return &v.pitchEnv.shape.decayCurve;
case DeckParam::kPitchEnvDepth: return &v.pitchEnv.peakSemitones;
case DeckParam::kFilterEnvSustain: return &v.filterEnv.sustainLevel;
case DeckParam::kFilterEnvAttackCurve: return &v.filterEnv.attackCurve;
case DeckParam::kFilterEnvDecayCurve: return &v.filterEnv.decayCurve;
case DeckParam::kFilterEnvReleaseCurve: return &v.filterEnv.releaseCurve;
case DeckParam::kFilterTrigHold: return &v.filterAhd.holdFraction;
case DeckParam::kFilterTrigAttackCurve: return &v.filterAhd.attackCurve;
case DeckParam::kFilterTrigDecayCurve: return &v.filterAhd.decayCurve;
case DeckParam::kFilterModAmt: return &v.filterModAmount;
case DeckParam::kFilterVel: return &v.filterVelAmount;
case DeckParam::kFilterKeyTrack: return &v.filterKeyTrack;
case DeckParam::kRate: return &v.playRate;
case DeckParam::kPitch: return &v.pitchOffsetSemitones;
case DeckParam::kKeyTrack: return &v.keyTrack;
default: return nullptr;
}
}
} // namespace
bool applyLiveParam(LiveValues& block, DeckParam deck, double normalized, int sampleRate) {
// Trigger length is the one control the block does not carry verbatim: what it publishes is
// the SPLINE-FOLDED fraction, so a write while a contour is active must be inert here for
// the same reason the knob is inert in the editor.
if (deck == DeckParam::kTrigLength) {
if (!block.splineActive) block.lengthFraction = ui::storedFromNorm(deck, normalized);
return true;
}
if (std::int64_t* f = frameField(block, deck)) {
*f = map::secondsToFrames(ui::storedFromNorm(deck, normalized),
static_cast<double>(sampleRate));
return true;
}
if (float* f = normField(block, deck)) {
*f = static_cast<float>(ui::storedFromNorm(deck, normalized));
return true;
}
if (double* f = doubleField(block, deck)) {
*f = ui::storedFromNorm(deck, normalized);
return true;
}
return false;
}
} // namespace reasampler::instrument::param
+29
View File
@@ -0,0 +1,29 @@
// param_live.h — the AUDIO-THREAD half of a host parameter write: one exposed control patched
// into the live block, in place, with no allocation and no lock. It exists because the model
// layer cannot run on the audio thread (PlaySeconds carries velocity curves and spline contours,
// so resolvePlay allocates), while `IParameterChanges` is delivered there.
#pragma once
#include "core/instrument/engine/live_params.h"
#include "core/instrument/ui/deck_groups.h" // DeckParam
namespace reasampler::instrument::param {
using ui::DeckParam;
// Writes `normalized` for `deck` into `block`. RT-SAFE: no allocation, no lock, no transcendental
// beyond the taper's own. Returns false for a control this block does not carry — master gain,
// which reaches the audio as the processor's own atomic, and anything unexposed.
//
// The value laws are NOT restated here: `ui::storedFromNorm` is the same norm -> stored map
// `setDeckParam` writes with, and `map::secondsToFrames` the same fold `resolvePlay` uses. What
// IS new is the routing — which member of the block a control names — and that is pinned by an
// exhaustive equivalence test against the model path over every exposed control, rather than by
// two tables that happen to agree.
//
// `sampleRate` is the rate the loaded capture was BUILT at (the processor's builtSampleRate_),
// so a patched stage time lands on exactly the frames the build would have resolved.
bool applyLiveParam(engine::LiveValues& block, DeckParam deck, double normalized, int sampleRate);
} // namespace reasampler::instrument::param
+59 -4
View File
@@ -66,10 +66,49 @@ UnitKind unitKindFor(DeckParam deck) {
return UnitKind::Decibels;
case DeckParam::kFilterCutoff:
return UnitKind::Hertz;
default:
// The twelve curve exponents, Q and drive. Everything else has no row at all.
// The twelve curve exponents and the filter's two dimensionless tone controls. Listed
// rather than defaulted, and everything with no parameter row at all is listed with
// them: a `default:` here would let a control promoted later inherit Dimensionless
// silently, and §6.3 freezes an exposed parameter's normalization on the first shipped
// build — so the wrong answer would be permanent rather than correctable.
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
case DeckParam::kAttackCurve:
case DeckParam::kDecayCurve:
case DeckParam::kReleaseCurve:
case DeckParam::kTrigAttackCurve:
case DeckParam::kTrigDecayCurve:
case DeckParam::kPitchEnvAttackCurve:
case DeckParam::kPitchEnvDecayCurve:
case DeckParam::kFilterEnvAttackCurve:
case DeckParam::kFilterEnvDecayCurve:
case DeckParam::kFilterEnvReleaseCurve:
case DeckParam::kFilterTrigAttackCurve:
case DeckParam::kFilterTrigDecayCurve:
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kPitchEnvEnable:
case DeckParam::kFilterEnable:
case DeckParam::kFilterLaw:
case DeckParam::kAmpVelCurve:
case DeckParam::kPitchVelCurve:
case DeckParam::kFilterVelCurve:
case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect:
case DeckParam::kFilterEnvSelect:
case DeckParam::kAmpEnvMode:
case DeckParam::kPitchEnvMode:
case DeckParam::kFilterEnvMode:
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
case DeckParam::kLimiterEnable:
case DeckParam::kMasterMeter:
case DeckParam::kMasterGr:
case DeckParam::kCount:
return UnitKind::Dimensionless;
}
return UnitKind::Dimensionless; // unreachable for a valid enumerator; silences a warning.
}
const char* unitStringFor(DeckParam deck) {
@@ -192,9 +231,18 @@ double toNormalized(DeckParam deck, double plain) {
return plain;
}
bool storesNormalized(DeckParam deck) {
ValueHome valueHomeFor(DeckParam deck) {
PlaySeconds defaults;
return ui::deckFloatField(deck, defaults) != nullptr;
if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm;
if (ui::deckDoubleField(deck, defaults)) return ValueHome::ParamSet;
if (deck == DeckParam::kMasterGain || deck == DeckParam::kKeyTrack) {
return ValueHome::InstanceScalar;
}
return ValueHome::None;
}
bool storesNormalized(DeckParam deck) {
return valueHomeFor(deck) == ValueHome::ParamSetNorm;
}
double defaultPlain(DeckParam deck) {
@@ -204,10 +252,17 @@ double defaultPlain(DeckParam deck) {
if (const float* stored = ui::deckFloatField(deck, defaults)) {
return toPlain(deck, static_cast<double>(*stored));
}
// The two instance scalars, whose default is not a field of PlaySeconds.
if (deck == DeckParam::kMasterGain) return 0.0; // unity, and the sharpest exactness case
if (deck == DeckParam::kKeyTrack) {
return kKeyTrackDefault * kPercentFullScale;
}
const double* field = ui::deckDoubleField(deck, defaults);
if (!field) return 0.0;
switch (unitKindFor(deck)) {
// Time converts seconds -> ms here and ms -> seconds in toNormalized, so its exactness
// additionally rests on x*1000/1000 == x — param_taper guarantees its quantum in SECONDS,
// not in ms. It holds for today's three Time defaults; a new one is a case to re-check.
case UnitKind::Time: return *field * 1000.0; // stored seconds
case UnitKind::PercentUnipolar: return *field * kPercentFullScale;
case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2
+11
View File
@@ -48,6 +48,17 @@ PlainRange plainRangeFor(DeckParam deck);
double toPlain(DeckParam deck, double normalized);
double toNormalized(DeckParam deck, double plain);
// WHERE a control's value actually lives. The host's read and write paths branch on this, and
// the exposed set is asserted against it: a control promoted into the list with no home would
// otherwise no-op silently in BOTH directions, with nothing to catch it at compile time.
enum class ValueHome {
None, // not a scalar control at all — a toggle, a radio, a curve-popup cell
ParamSetNorm, // the filter's four: the stored double IS the normalized position
ParamSet, // every other knob the parameter set carries
InstanceScalar, // beside the parameter set: master gain, and the pitch key-track scalar
};
ValueHome valueHomeFor(DeckParam deck);
// The filter's four tone controls STORE their normalized position (payload v9), so their default
// normalized value is that stored double verbatim and no taper participates in a host's
// reset-to-default. Reporting Hz / Q / drive depth for them means CALLING their frozen laws, not
+3 -3
View File
@@ -201,8 +201,10 @@ DeckParam curveParamFor(DeckParam knob) {
LiveCommit deckParamCommit(DeckParam id) {
switch (id) {
// The one note-on-latched control; the header owns why.
// The note-on-latched controls; the header owns why each one latches.
case DeckParam::kRate:
case DeckParam::kKeyTrack:
case DeckParam::kTrigLength:
return LiveCommit::NoteOnLatched;
// Live by the tier's own definition — one atomic store the audio thread picks up at the
// next block, no bridge read and no re-decode. It reaches the audio beside the live
@@ -256,9 +258,7 @@ LiveCommit deckParamCommit(DeckParam id) {
// to non-live. Reasons live in the header.
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kTrigLength:
case DeckParam::kPitchEnvEnable:
case DeckParam::kKeyTrack:
case DeckParam::kFilterEnable:
case DeckParam::kAmpVelCurve:
case DeckParam::kPitchVelCurve:
+55 -76
View File
@@ -89,6 +89,49 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
}
}
double storedFromNorm(DeckParam id, double norm) {
switch (id) {
// The filter's four STORE their normalized position (payload v9), so the identity IS
// their law — deckFloatField's four, and the reason it is a separate resolver.
case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
return clamp01(norm);
case DeckParam::kRate:
return rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio);
case DeckParam::kPitch:
case DeckParam::kPitchEnvDepth:
return depthSemitonesFromNorm(norm, kPitchDepthMaxSemis);
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
return deckBipolarFromNorm(norm);
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
return keyTrackFromNorm(norm);
case DeckParam::kTrigLength:
// lengthFraction is (0,1]; a small floor so a zero-length trigger never plays
// nothing.
return (std::max)(0.01, clamp01(norm));
default:
break;
}
// The rest are decided by the display unit alone, which is what makes the fourteen stage
// times and the twelve curve dials one line each rather than twenty-six.
switch (deckParamUnit(id)) {
case UnitCategory::Milliseconds: return timeSecondsFromNorm(norm);
case UnitCategory::Exponent: return util::curveFromKnobNorm(norm);
case UnitCategory::Percent: return clamp01(norm); // hold fractions, sustain levels
// Decibels is master gain, whose stored value is a LINEAR gain the processor owns
// rather than a field of the parameter set; the two enums above are handled by id.
case UnitCategory::Semitones:
case UnitCategory::Decibels:
case UnitCategory::None:
break;
}
return norm;
}
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
switch (id) {
case DeckParam::kPlayMode:
@@ -112,88 +155,24 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
case DeckParam::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break;
case DeckParam::kRate:
play.playRate = rateRatioFromNorm(value, kRateMinRatio, kRateMaxRatio); break;
case DeckParam::kPitch:
play.pitchOffsetSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis); break;
case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break;
case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case DeckParam::kRelease: play.adsr.releaseSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kTrigLength:
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays
// nothing.
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
break;
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = timeSecondsFromNorm(value); break;
case DeckParam::kTrigAttackCurve:
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kTrigDecayCurve:
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
case DeckParam::kPitchEnvAttack:
play.pitchEnv.shape.attackSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kPitchEnvHold:
play.pitchEnv.shape.holdFraction = clamp01(value); break;
case DeckParam::kPitchEnvDecay:
play.pitchEnv.shape.decaySeconds = timeSecondsFromNorm(value); break;
case DeckParam::kPitchEnvAttackCurve:
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvDecayCurve:
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvDepth:
play.pitchEnv.peakSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis);
break;
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
case DeckParam::kFilterLaw:
play.filter.settings.morphLaw =
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
break;
case DeckParam::kFilterMorph:
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterCutoff:
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterQ:
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterDrive:
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
case DeckParam::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
case DeckParam::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
case DeckParam::kFilterKeyTrack:
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
case DeckParam::kFilterEnvAttack:
play.filter.env.attackSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterEnvHold:
play.filter.env.holdSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterEnvDecay:
play.filter.env.decaySeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterEnvSustain:
play.filter.env.sustainLevel = clamp01(value); break;
case DeckParam::kFilterEnvRelease:
play.filter.env.releaseSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterEnvAttackCurve:
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterEnvDecayCurve:
play.filter.env.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterEnvReleaseCurve:
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterTrigAttack:
play.filter.trigEnv.attackSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterTrigHold:
play.filter.trigEnv.holdFraction = clamp01(value); break;
case DeckParam::kFilterTrigDecay:
play.filter.trigEnv.decaySeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterTrigAttackCurve:
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterTrigDecayCurve:
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
default: break;
default:
// Every knob: the one norm -> stored law, into the one field the control names.
// Both halves are shared with the audio thread's live patch (param/param_live), so
// a control cannot take a different taper or land in a different field depending on
// which surface wrote it. A toggle or a value living outside PlaySeconds resolves to
// neither field and falls through untouched.
if (float* f = deckFloatField(id, play)) {
*f = static_cast<float>(storedFromNorm(id, value));
} else if (double* d = deckDoubleField(id, play)) {
*d = storedFromNorm(id, value);
}
break;
}
// ONE normalization point for every control that can flip splineActive — a mode toggle
// (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an
+14
View File
@@ -11,6 +11,7 @@
#include "core/instrument/ui/deck_groups.h" // DeckParam
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
#include "core/util/clamp01.h"
namespace reasampler::instrument::ui {
@@ -28,6 +29,13 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
// Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls.
inline constexpr double kKeyTrackMax = 2.0;
// The pitch key-track scalar lives beside the play bundle (on InstrumentParams / SampleData),
// so its two conversions cannot ride the PlaySeconds binding below. One home for them anyway:
// the editor knob, the host's write path and the live fold would otherwise each spell the
// division out.
inline double keyTrackFromNorm(double norm) { return util::clamp01(norm) * kKeyTrackMax; }
inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); }
// Rate's range: ALIASES of the stretcher's own measured ratio bounds, so the knob's ends are the
// engine's clamp rather than a second opinion of it. The taper takes them as arguments for the
// same reason the depth taper takes its throw — engine/time_stretch.h owns the numbers.
@@ -41,6 +49,12 @@ inline constexpr double kRateMaxRatio = engine::kStretchRateMax;
// shell reads those from the processor.
double deckParamNorm(DeckParam id, const PlaySeconds& play);
// The STORED value a knob's normalized position maps to — the norm -> value half of the binding
// on its own, because the audio thread needs it without a PlaySeconds to write into
// (`param/param_live`). setDeckParam IS this composed with the field lookup below, so the two
// cannot carry different tapers. Answers `norm` unchanged for a control with no stored scalar.
double storedFromNorm(DeckParam id, double norm);
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
// Mutates `play` in place, touching exactly the one field the control names.
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
+33 -14
View File
@@ -98,22 +98,34 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
next touch re-imposing, a superseded value. Master gain has its own funnel
(`setMasterGainLinear`) because it is the one exposed control that does not ride the
parameter set.
- **`setState` ordering against the host's first parameter block is irrelevant by
construction.** There is one model and one funnel per control, so whichever writes last wins
and the host's display follows the model either way — the ordering is not assumed, it is
removed as a question.
- **`process()` reads no parameter queue and is unchanged by the parameter surface.** A host
write arrives on the UI/main thread and reaches the audio thread through the SAME live block
the editor's knobs publish into, observed once per `render()` — block boundaries, last write
wins. `[verify — DAW]` that REAPER delivers automation to a single-component plug-in through
`IEditController::setParamNormalized` and not through `ProcessData::inputParameterChanges`
alone; if it is the latter only, an RT-safe drain is required and `process()` is where it
would have to land.
- **BOTH delivery channels are serviced, and the audio-side one is the normative one.**
`IEditController::setParamNormalized` is the CONTROLLER channel — the SDK says a controller
"should update the according GUI element(s) only" there, so nothing about the audio may depend
on a host calling it. `ProcessData::inputParameterChanges` is the AUDIO channel, and the SDK's
own single-component sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains it
in `process()` while also implementing `setParamNormalized`. We do both, for the same reason.
- **The audio thread is the sole writer of the block the ENGINE reads.** Two `LiveParams`
blocks: the model's publishers (editor commits, reload, `setState`) write `liveParams_` off
the audio thread and may allocate on the way; `process()` merges that block with the host's
automation points into `automationLive_`, which is what `SampleData::live` points at. Two
blocks rather than one because the seqlock's single-writer contract is load-bearing and the two
writers genuinely differ in thread. The merge republishes ONLY when either side moved, so a
block carrying neither costs one relaxed load and the engine's read shape is unchanged.
- **The automation values fold back into the model on the UI thread** (`drainAutomationToModel`,
called from `getState`, the editor's sync tick, and the bake's reload tail). The blob is
authoritative, so a value that never came back would be lost on save. The fold is suppressed
from notifying the host — the values came FROM it, and echoing them would let a lane in write
mode re-record its own playback.
- **`setState` does not need an ordering guarantee against the host's first parameter block.**
An automation point held by the audio thread is re-applied over every merge, so a written lane
outranks the restore whichever way round the two arrive — which is VST3's own rule, not a race
we lost.
- **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what
is exposed, an invented map would hijack CCs the user's controller already sends, and
REAPER's own per-parameter MIDI learn covers the case without freezing anything.
`IParameterFunctionName` and `IAutomationState` are assessed and not implemented; the reasons
are in the product spec and are not re-surveyed here.
`[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without
freezing anything. `IParameterFunctionName` and `IAutomationState` are assessed and not
implemented — `bake/CLAUDE.md` owns the `IAutomationState` reasoning, at its one consequence
site.
**Non-goals / guardrails.**
- The instrument never captures and never inserts into the arrange. Playback is a
@@ -151,6 +163,13 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
## Gotchas
- **`reasampler_processor.h` is a documented ~600-line-ceiling exception** (root `CLAUDE.md`,
structural heuristic 1), on the same footing as `voice.h`'s: it is ONE class declaration, so
the seam the heuristic asks for does not exist — a split would be an arbitrary bisection, and
the implementation is already split across three TUs on its real seams. Its bulk is the
drain-slot proof, the RT-discipline constraints and the two-block automation contract, all of
which the comment conventions name as keep-worthy. Not silent overshoot.
- **The bake click only ARMS; the editor's sync tick runs it.** Calling
`Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
nested inside a mouse handler with `SetCapture` held, while the invoked action re-points
+1 -1
View File
@@ -90,7 +90,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
param_id param_units param_format
param_id param_units param_format param_live
limiter meter_accumulate meter_ballistics master_meter bake_hold
file_bytes curve_law stroke_aa
curve_tessellate
+15 -5
View File
@@ -1,13 +1,14 @@
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
// resolve every paint/hit-test path shares, the shell's half of the control-value binding (the
// per-instance controls the parameter set does not carry — key-track, voice count, master gain,
// preview velocity — plus the value labels), and the node-drag clamp bounds. The parameter-set
// half is the pure `deck_values` module. The orthogonal half — which stored struct each editor
// selection names — is editor_models. Value logic only: no painting, no window plumbing.
// preview velocity — plus each knob's plain value and its label), and the node-drag clamp
// bounds. The parameter-set half is the pure `deck_values` module. The orthogonal half — which
// stored struct each editor selection names — is editor_models. Value logic only.
#include "shell/instrument/reasampler_editor.h"
#include <algorithm>
#include <cmath> // isfinite (the gain's -inf label)
#include <cstdint>
#include <cstdio> // snprintf (deck value labels)
#include <string>
@@ -241,11 +242,20 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
// The digits come from the ONE formatter; everything the editor adds around them is static
// chrome — a constant prefix or suffix cannot diverge from what the host shows.
const double plain = deckPlainValue(id);
char digits[24];
instrument::param::formatPlainFor(deck, deckPlainValue(id), digits, sizeof(digits));
instrument::param::formatPlainFor(deck, plain, digits, sizeof(digits));
const auto kind = instrument::param::unitKindFor(deck);
const char* caret =
instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : "";
return caret + std::string(digits) + instrument::param::unitStringFor(deck);
// The gain at true silence reads "-inf", not "-infdB": there is no decibel value there.
if (kind == instrument::param::UnitKind::Decibels && !std::isfinite(plain)) {
return std::string(digits);
}
// The stage times are the one category that carries a space before its unit, and always did —
// this surface's own typography, not the host's (ParameterInfo::units is the bare string).
const char* gap = kind == instrument::param::UnitKind::Time ? " " : "";
return caret + std::string(digits) + gap + instrument::param::unitStringFor(deck);
}
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
@@ -119,6 +119,10 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
dragStartEnv_ = env;
dragSampleFrames_ = frames;
dragStartParams_ = params_;
// Peer of mouseDownDeck's bracket. LATCHING with no id named, because a node or
// knot drag can move more than one exposed parameter and the grab cannot know
// which; the release and capture-lost paths close it generically.
if (processor_) processor_->beginParamGestureLatch();
return true; // node moves once the cursor drags
}
return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
+5
View File
@@ -120,6 +120,11 @@ void ReaSamplerEditor::attachedToParent() {
}
void ReaSamplerEditor::removedFromParent() {
// The processor outlives this view, so a bracket left open here would leave the host holding
// an edit forever and every later internal write to that parameter would emit a bare
// performEdit. The capture-lost path normally closes it; this does not rely on Windows
// delivering WM_CAPTURECHANGED before the window goes away.
if (processor_) processor_->endParamGesture();
if (childHwnd_) {
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
DestroyWindow(childHwnd_);
+4
View File
@@ -141,6 +141,10 @@ void ReaSamplerEditor::onSyncTimer() {
// edit surface exactly as a reload would. Unconditional: it self-cancels when nothing is
// armed, so no commit site has to remember to ask for it.
processor_->flushLatencyRestart();
// Peers of it: both deliver work the originating thread could not do where it stood — a host
// callback from inside reloadMutex_, and a model write from the audio thread.
processor_->flushGainNotify();
processor_->drainAutomationToModel();
// Resolve the bake affordance's availability on the SAME tick that paints it, so it
// can never be enabled on one tick and refuse on the next.
+3
View File
@@ -120,6 +120,9 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
if (!tempo) return fail("the project tempo could not be read");
// Fold anything the host's automation wrote into the model FIRST: the bake renders the sound
// the user approved, and an automated value the model has not picked up yet is part of it.
processor.drainAutomationToModel();
const InstrumentParams dialed = processor.instrumentParams();
const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote;
+145 -32
View File
@@ -1,17 +1,14 @@
// instrument_params.cpp — the VST3 adapter over core/instrument/param: the Parameter subclass
// whose toPlain/toNormalized ARE the taper, the one construction of the unit and parameter
// lists, and the model projection both directions. It DECIDES nothing — the pure module owns
// the frozen table, the laws and the formatter.
//
// The blob stays authoritative. A parameter is a THIRD SURFACE onto InstrumentParams/PlaySeconds
// — a peer of the deck knob and the overlay node, never a second copy of the value. getState
// serializes the model; the controller's own value list is a cache written FROM the model and
// never read as truth.
// whose toPlain/toNormalized ARE the taper, the construction of the unit and parameter lists,
// the model projection both directions, and both delivery channels (the controller's write and
// the audio thread's queue drain). It DECIDES nothing — the pure module owns the frozen table,
// the laws and the formatter.
#include "shell/instrument/reasampler_processor.h"
#include "base/source/fstring.h"
#include "pluginterfaces/base/ustring.h"
#include "pluginterfaces/vst/ivstparameterchanges.h" // IParameterChanges / IParamValueQueue
#include "core/instrument/engine/master_gain.h"
#include "core/instrument/param/param_format.h"
@@ -123,7 +120,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal
setMasterGainLinear(instrument::engine::masterGainLinearFromNorm(value));
} else {
InstrumentParams params = instrumentParams();
instrument::ui::setDeckParam(row->deck, params.play, value, /*segment=*/0);
writeDeckParamToModel(params, row->deck, value);
setInstrumentParams(params);
publishLiveParams();
}
@@ -134,11 +131,25 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal
tag, modelParamNormalized(instrumentParams(), row->deck));
}
void ReaSamplerProcessor::writeDeckParamToModel(InstrumentParams& params, DeckParam deck,
double normalized) {
// The two homes a control's value can have, and the ONE place the host path knows the
// difference — the editor draws the same split at applyParamControl. param::valueHomeFor is
// the predicate; a promotion whose control has neither home fails param_units' own test
// rather than no-oping silently here.
if (deck == DeckParam::kKeyTrack) {
params.keyTrack = instrument::ui::keyTrackFromNorm(normalized);
return;
}
instrument::ui::setDeckParam(deck, params.play, normalized, /*segment=*/0);
}
double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params,
DeckParam deck) const {
if (deck == DeckParam::kMasterGain) {
return instrument::engine::masterGainNormFromLinear(masterGainLinear());
}
if (deck == DeckParam::kKeyTrack) return instrument::ui::keyTrackNormFrom(params.keyTrack);
return instrument::ui::deckParamNorm(deck, params.play);
}
@@ -152,43 +163,145 @@ void ReaSamplerProcessor::syncParamsFromModel() {
}
}
void ReaSamplerProcessor::notifyParamsFromModel(const InstrumentParams& before,
void ReaSamplerProcessor::notifyParamsFromModel(const double* beforeNorms,
const InstrumentParams& after) {
if (paramNotifySuppressed_) return;
// The bake's reset moves ~40 values at once. Grouping them tells the host they are ONE act,
// which is what an undo stack and an automation lane both want; the SDK provides exactly
// this for exactly this case (ivsteditcontroller.h, IComponentHandler2).
const bool group = componentHandler2 && !gestureLatching_;
if (group) componentHandler2->startGroupEdit();
for (const param::ParamRow& row : param::exposedParams()) {
if (row.deck == DeckParam::kMasterGain) continue; // its own funnel notifies it
const double now = modelParamNormalized(after, row.deck);
if (now == modelParamNormalized(before, row.deck)) continue;
if (now == beforeNorms[static_cast<std::size_t>(row.deck)]) continue;
notifyParamChanged(row.id, now);
}
if (group) componentHandler2->finishGroupEdit();
}
bool ReaSamplerProcessor::gestureIsOpen(param::ParamId id) const {
for (std::size_t i = 0; i < openGestureCount_; ++i) {
if (openGestureIds_[i] == id) return true;
}
return false;
}
void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalized) {
EditControllerEx1::setParamNormalized(id, normalized);
if (!componentHandler) return;
// A drag holds its own begin/end across the whole gesture so a host in touch or latch mode
// sees one continuous edit; every other writer — a reset, an envelope-node drag, the bake's
// reset — emits a degenerate one-point gesture, which is what makes the host DISPLAY follow
// it instead of re-imposing the pre-write value on the next touch.
const bool inGesture = openGestureId_ == id;
if (!inGesture) beginEdit(id);
performEdit(id, normalized);
if (!inGesture) endEdit(id);
}
void ReaSamplerProcessor::beginParamGesture(DeckParam deck) {
const param::ParamId id = param::paramIdFor(deck);
if (id == 0 || !param::isExposed(deck)) return;
endParamGesture(); // a grab while one is open cannot leave the previous unclosed
openGestureId_ = id;
if (gestureIsOpen(id)) {
performEdit(id, normalized);
return;
}
if (gestureLatching_ && openGestureCount_ < kMaxOpenGestures) {
// First move this drag has made on this parameter: open its bracket and hold it, so the
// whole drag is one edit rather than a run of one-point ones.
openGestureIds_[openGestureCount_++] = id;
beginEdit(id);
performEdit(id, normalized);
return;
}
// Every non-drag writer — a reset, the bake's reset — emits a degenerate one-point gesture,
// which is what makes the host DISPLAY follow it instead of re-imposing the pre-write value
// on the next touch.
beginEdit(id);
}
void ReaSamplerProcessor::endParamGesture() {
if (openGestureId_ == 0) return;
const param::ParamId id = openGestureId_;
openGestureId_ = 0; // cleared FIRST: endEdit can re-enter through a host's own callback
performEdit(id, normalized);
endEdit(id);
}
void ReaSamplerProcessor::beginParamGestureLatch() {
endParamGesture(); // a grab while one is open cannot leave the previous unclosed
gestureLatching_ = true;
}
void ReaSamplerProcessor::beginParamGesture(DeckParam deck) {
beginParamGestureLatch();
const param::ParamId id = param::paramIdFor(deck);
if (id == 0 || !param::isExposed(deck)) return;
openGestureIds_[openGestureCount_++] = id;
beginEdit(id);
}
bool ReaSamplerProcessor::drainInputParameterChanges(IParameterChanges* changes) {
if (!changes) return false;
// RT-SAFE, and the one non-obvious part of that: exposedRowFor walks exposedParams(), whose
// backing vector is a function-local static built on FIRST CALL. buildParameterList() calls
// it from initialize(), which the SDK guarantees precedes any process() — so the allocation
// has already happened by the time the audio thread gets here.
bool landed = false;
const int32 queues = changes->getParameterCount();
for (int32 q = 0; q < queues; ++q) {
IParamValueQueue* queue = changes->getParameterData(q);
if (!queue) continue;
const int32 points = queue->getPointCount();
if (points <= 0) continue;
// The LAST point of the queue wins for the block. Applying every point at its sample
// offset would put a "did anything change" question on the per-voice-per-sample path,
// which the phase-wide guardrail forbids.
int32 offset = 0;
ParamValue value = 0.0;
if (queue->getPoint(points - 1, offset, value) != kResultTrue) continue;
const param::ParamRow* row = param::exposedRowFor(queue->getParameterId());
if (!row) continue;
landed = true;
const auto slot = static_cast<std::size_t>(row->deck);
automationNorm_[slot] = value;
automationHeld_[slot] = true;
// Master gain reaches the audio beside the block rather than through it, so its
// automation write is the same one relaxed store the knob makes.
if (row->deck == DeckParam::kMasterGain) {
masterGain_.store(
static_cast<float>(instrument::engine::masterGainLinearFromNorm(value)),
std::memory_order_relaxed);
}
// Publish to the UI thread, which folds it back into the model — the blob stays
// authoritative, so a value that never came back would be lost on save.
automationPublished_[slot].store(value, std::memory_order_relaxed);
automationPending_[slot].store(true, std::memory_order_release);
}
if (landed) automationAny_.store(true, std::memory_order_release);
return landed;
}
void ReaSamplerProcessor::drainAutomationToModel() {
if (!automationAny_.exchange(false, std::memory_order_acquire)) return;
InstrumentParams params = instrumentParams();
bool moved = false;
for (const param::ParamRow& row : param::exposedParams()) {
const auto slot = static_cast<std::size_t>(row.deck);
if (!automationPending_[slot].exchange(false, std::memory_order_acquire)) continue;
const double value = automationPublished_[slot].load(std::memory_order_relaxed);
// Master gain's model IS the atomic the audio thread already wrote; there is nothing to
// fold, only the controller cache to refresh below.
if (row.deck != DeckParam::kMasterGain) {
writeDeckParamToModel(params, row.deck, value);
moved = true;
}
}
// Suppressed for the whole fold: these values CAME from the host, and echoing them back
// through performEdit would let a lane in write mode re-record its own playback. The
// controller cache is still refreshed, so the host's display and the editor follow.
const bool wasSuppressed = paramNotifySuppressed_;
paramNotifySuppressed_ = true;
if (moved) {
setInstrumentParams(params);
publishLiveParams();
}
syncParamsFromModel();
paramNotifySuppressed_ = wasSuppressed;
}
void ReaSamplerProcessor::endParamGesture() {
gestureLatching_ = false;
if (openGestureCount_ == 0) return;
// Latched into a local and the state cleared FIRST: endEdit can re-enter through a host's
// own callback, and must not find a bracket this call is in the middle of closing.
param::ParamId closing[kMaxOpenGestures];
const std::size_t count = openGestureCount_;
for (std::size_t i = 0; i < count; ++i) closing[i] = openGestureIds_[i];
openGestureCount_ = 0;
for (std::size_t i = 0; i < count; ++i) endEdit(closing[i]);
}
} // namespace reasampler::vst
+19 -7
View File
@@ -146,16 +146,18 @@ std::string ReaSamplerProcessor::reloadInstrument() {
buildFromRef(*sel, params, projectDir, mode)) {
sample = std::move(*decoded);
havePlayable = true;
// Point the built snapshot at the instance's ONE live block and seed it from
// the very PlayParams the voices latch, so an untouched knob folds to the same
// frames the build resolved and a note-on with a live block sounds identical
// to one without.
sample.live = &liveParams_;
// Point the built snapshot at the instance's ONE engine-facing block and seed the
// MODEL block from the very PlayParams the voices latch, so an untouched knob folds
// to the same frames the build resolved and a note-on with a live block sounds
// identical to one without. process() merges the seed into automationLive_ at the
// top of the first block after this, which is where the swap below becomes visible
// too — so the new snapshot's first note reads it.
sample.live = &automationLive_;
{
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play));
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
}
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved
@@ -226,6 +228,8 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
// bake chain only ever runs from that tick, so the arm would be drained on the next one
// anyway. At the tail for the same reason setState's is (see there).
flushLatencyRestart();
// The reset's gain notification, armed under reloadMutex_ inside that reload.
flushGainNotify();
}
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
@@ -289,8 +293,16 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
// gain sitting above every snapshot, the same shape as ONE BLOCK, ONE RATE (see
// builtSampleRate_).
if (gainAtNextPublish_) {
setMasterGainLinear(*gainAtNextPublish_);
// The MIRROR is inline (that is the sound this publish belongs to); the host
// notification is ARMED and delivered after reloadMutex_ is released. performEdit
// reaches the host handler, a host may re-enter this object synchronously from it, and
// setActive(false) takes this same non-recursive mutex — the identical hazard the
// latency restart is deferred for.
const bool moved = publishMasterGainLinear(*gainAtNextPublish_);
gainAtNextPublish_.reset();
// Armed only on a real change, so a reset that lands on the gain already set writes
// nothing into a host's automation lane — the same compare setMasterGainLinear makes.
if (moved) gainNotifyPending_.store(true, std::memory_order_release);
}
LoadedInstrument* evicted = draining_.exchange(prev);
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
+35 -11
View File
@@ -91,20 +91,26 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
reloadInstrument();
paramNotifySuppressed_ = false;
// Every exposed parameter now reads the blob's value. Ordering against the host's first
// parameter block is irrelevant BY CONSTRUCTION rather than by assumption: there is one
// model and one funnel per control, so whichever of the two writes last simply wins, and
// the host's display follows the model either way.
// Every exposed parameter now reads the blob's value. Its ordering against the host's first
// parameter block does not need to be known: an automation point held by the audio thread is
// re-applied over every merge, so a lane outranks this restore whichever way round the two
// arrive. That is VST3's own rule — a written lane outranks anything the plug-in sets — not
// a race we lost.
syncParamsFromModel();
// This caller has no editor to flush for it. At the TAIL on purpose: a host that services the
// restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads
// against the refs above, which are only fully restored once this function has run to here.
flushLatencyRestart();
flushGainNotify();
return kResultOk;
}
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
if (!state) return kResultFalse;
// Before the snapshot, not after: the blob is authoritative, so anything the host's
// automation wrote must be in the model by the time it is serialised. This is the one drain
// site that is not an optimisation — a save with no editor open still has to see it.
drainAutomationToModel();
// Persists the full instance state — never written to the "reasampler" bank ext-state.
// No pick serializes to {"", default params}, restoring as silence (never auto-playing
// sample #1).
@@ -163,10 +169,15 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
}
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
InstrumentParams before;
// The exposed values alone, not the whole set: this funnel fires per mouse move on every live
// knob and node drag, and InstrumentParams owns seven vectors — copying all of them to diff
// 44 doubles is the cost, and the diff is what the notification actually needs.
double before[kDeckParamSlots];
{
std::lock_guard<std::mutex> lock(paramsMutex_);
before = params_;
for (const instrument::param::ParamRow& row : instrument::param::exposedParams()) {
before[static_cast<std::size_t>(row.deck)] = modelParamNormalized(params_, row.deck);
}
params_ = params;
}
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
@@ -250,8 +261,9 @@ void ReaSamplerProcessor::clearMasterBusClip() {
void ReaSamplerProcessor::publishLiveParams() {
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
if (rate <= 0) return;
const InstrumentParams params = instrumentParams();
const instrument::engine::LiveValues block =
instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate));
instrument::engine::foldLive(resolvePlay(params.play, rate), params.keyTrack);
// livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against
// reloadInstrument's publish — held for the publish call only, not the fold above.
std::lock_guard<std::mutex> lock(livePublishMutex_);
@@ -329,20 +341,32 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
rebuildVoiceEngine();
}
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
bool ReaSamplerProcessor::publishMasterGainLinear(double linear) {
// Clamp to the master_gain taper (0 = silence, cap = +24 dB). One relaxed atomic
// store — no rebuild, no lock (a post-sum trim is not a keymap fact).
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
const double maxLin = masterGainMaxLinear();
if (linear > maxLin) linear = maxLin;
const float value = static_cast<float>(linear);
const float previous = masterGain_.exchange(value, std::memory_order_relaxed);
return masterGain_.exchange(value, std::memory_order_relaxed) != value;
}
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
const bool moved = publishMasterGainLinear(linear);
// Gain's own notification funnel — it is the one exposed control that does not ride the
// parameter set, so setInstrumentParams' diff cannot see it. Compared for a real change so a
// reload's republish of an unmoved gain writes nothing into a host's automation lane.
if (paramNotifySuppressed_ || previous == value) return;
if (paramNotifySuppressed_ || !moved) return;
notifyParamChanged(instrument::param::kParamMasterGain,
instrument::engine::masterGainNormFromLinear(linear));
instrument::engine::masterGainNormFromLinear(masterGainLinear()));
}
void ReaSamplerProcessor::flushGainNotify() {
if (!componentHandler) return; // an arm raised before the handler connected waits
if (!gainNotifyPending_.exchange(false, std::memory_order_acquire)) return;
if (paramNotifySuppressed_) return;
notifyParamChanged(instrument::param::kParamMasterGain,
instrument::engine::masterGainNormFromLinear(masterGainLinear()));
}
void ReaSamplerProcessor::previewNoteOn(int note) {
@@ -16,6 +16,8 @@
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
#include "pluginterfaces/vst/vstspeaker.h"
#include "core/instrument/engine/master_gain.h" // the automation write of the gain's own atomic
#include "core/instrument/param/param_live.h" // the RT-safe patch of one control into the block
#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor
#include "shell/instrument/reasampler_embed.h" // embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
@@ -200,6 +202,33 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
(drain && drain->fullyIdle()) ? drain->installedAt : 0,
std::memory_order_relaxed);
// Host automation, merged into the engine-facing block BEFORE the note marshalling below, so
// a note-on in this block latches this block's values. See automationLive_ (header) for why
// the merge is here rather than in the model's own publisher.
{
const bool dirty = drainInputParameterChanges(data.inputParameterChanges);
const std::uint32_t modelGen = liveParams_.generation();
if (dirty || modelGen != seenModelGeneration_) {
// Declared INSIDE the branch: LiveValues carries default member initializers, so a
// block where nothing moved must not pay to construct one.
instrument::engine::LiveValues merged;
// Re-read the model's fold and re-apply every held automation value over it: without
// the re-apply, any knob move would revert an automated parameter until its lane's
// next point.
if (liveParams_.read(merged) != 0) {
seenModelGeneration_ = modelGen;
const int builtRate = builtSampleRate_.load(std::memory_order_relaxed);
for (std::size_t i = 0; i < kDeckParamSlots; ++i) {
if (!automationHeld_[i]) continue;
instrument::param::applyLiveParam(
merged, static_cast<instrument::ui::DeckParam>(i), automationNorm_[i],
builtRate);
}
automationLive_.publish(merged);
}
}
}
// Marshal MIDI note-on/off at block granularity (no per-event sample-offset split;
// sample-accurate scheduling is a later tier). Note-offs also route to the drain
// engine so a note held across a reload releases its old-snapshot voice too.
+81 -24
View File
@@ -125,17 +125,12 @@ public:
// Hands the host our LICE IPlugView editor.
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
// A host write of one exposed parameter. Applies it to THE model through that control's
// existing commit tier — live publish, or the master-gain atomic — and never through a
// fourth route. Nothing reachable from here touches reloadInstrument or rebuildVoiceEngine,
// which is structural rather than careful: every reload- and rebuild-tier control is omitted
// from the parameter list, so no id maps to one.
//
// Parameter values reach the audio thread through the SAME block the editor's knobs publish
// into, which the engine observes ONCE per render() — i.e. at BLOCK BOUNDARIES, last write
// wins for that block. Sample-accurate application would put a per-sample "did anything
// change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids.
// process() reads no parameter queue and is unchanged by the parameter surface.
// The CONTROLLER-side write — a host GUI gesture on the generic panel, and whatever a host
// mirrors here for display. Applies it to THE model through that control's existing commit
// tier and never through a fourth route. Nothing reachable from here touches reloadInstrument
// or rebuildVoiceEngine, which is structural rather than careful: every reload- and
// rebuild-tier control is omitted from the parameter list, so no id maps to one.
// NOT the automation channel; this directory's CLAUDE.md owns which channel is which.
Steinberg::tresult PLUGIN_API setParamNormalized(
Steinberg::Vst::ParamID tag, Steinberg::Vst::ParamValue value) override;
@@ -144,10 +139,20 @@ public:
// through IComponentHandler. UI/main thread.
void syncParamsFromModel();
// A knob drag's host-edit bracket, so a host in touch or latch mode records ONE continuous
// edit rather than a burst of one-point gestures. Idempotent: a grab while one is open
// closes it first, and endParamGesture with none open does nothing. UI thread only.
// Folds what the audio thread took from the host's parameter queue back into the model and
// the controller cache, suppressing the host notification (those values came FROM it).
// UI/main thread; a no-op when nothing was automated. Called wherever the model is about to
// be READ as authoritative — getState, the bake, the editor's tick.
void drainAutomationToModel();
// A drag's host-edit bracket, so a host in touch or latch mode records ONE continuous edit
// per parameter rather than a burst of one-point ones. Every parameter the drag notifies
// opens its bracket on first touch and holds it until endParamGesture — LATCHING rather than
// declared up front, because an envelope-node drag moves a set the grab cannot name. The
// deck-knob form additionally opens its own id at once, which is the id the host sees a touch
// on even if the drag produces no move. Idempotent. UI thread only.
void beginParamGesture(instrument::ui::DeckParam deck);
void beginParamGestureLatch();
void endParamGesture();
// Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
@@ -264,6 +269,9 @@ public:
return static_cast<double>(masterGain_.load(std::memory_order_relaxed));
}
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
// The mirror alone, with no host notification: for the one writer that runs under
// reloadMutex_ and must arm rather than emit. True when the value actually moved.
bool publishMasterGainLinear(double linear);
// The master-bus limiter's single enable (persisted in the parameter set). UI thread only:
// a thin wrapper over setInstrumentParams, the one funnel that mirrors the flag onto the
@@ -284,6 +292,11 @@ public:
// same tick, so its arm would drain on the next one regardless).
void flushLatencyRestart();
// Delivers the master-gain host notification a reload deferred (the bake's reset gain lands
// under reloadMutex_, and performEdit may re-enter this object). Drained beside the latency
// restart, from the same sites and for the same reason.
void flushGainNotify();
// Fires a one-shot preview note-on/off through the live VoiceEngine — the same
// noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice
// count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
@@ -306,18 +319,33 @@ private:
// initialize().
void buildParameterList();
// The normalized value a control reads at, from the model — the projection §6.1 calls a
// third surface. Master gain reads the processor's own atomic; everything else reads the
// parameter set through the deck's binding.
// THE automation read, on the audio thread, at the BLOCK BOUNDARY: the last point of each
// queue wins. RT-safe — relaxed atomic stores only. Sample-accurate application would put a
// per-sample "did anything change" question on the per-voice-per-sample path, which the
// phase-wide guardrail forbids. True when at least one point landed, which is what makes the
// merge below it conditional.
bool drainInputParameterChanges(Steinberg::Vst::IParameterChanges* changes);
// The normalized value a control reads at, from the model — a projection of it, never a
// cached shadow. Master gain reads the processor's own atomic, pitch key-track the scalar
// beside the play bundle; everything else reads the parameter set through the deck's binding.
double modelParamNormalized(const InstrumentParams& params,
instrument::ui::DeckParam deck) const;
// Notifies the host of every exposed control whose value differs between the two parameter
// sets. Called from setInstrumentParams — the ONE funnel every writer already goes through
// so no internal write can leave the host displaying, and on next touch re-imposing, a
// superseded value. The bake's reset is the first non-gesture writer this covers.
void notifyParamsFromModel(const InstrumentParams& before, const InstrumentParams& after);
// The write peer of that read, and the ONE host-side write of a control's value: both the
// controller's setParamNormalized and the audio thread's automation fold go through it, so
// neither can miss a control whose value lives outside the parameter set.
static void writeDeckParamToModel(InstrumentParams& params, instrument::ui::DeckParam deck,
double normalized);
// Notifies the host of every exposed control whose normalized value moved. `beforeNorms` is
// indexed by DeckParam ordinal. Called from setInstrumentParams — the ONE funnel every writer
// already goes through — so no internal write can leave the host displaying, and on next
// touch re-imposing, a superseded value. The bake's reset is the first non-gesture writer
// this covers, and the reason the emission is grouped.
void notifyParamsFromModel(const double* beforeNorms, const InstrumentParams& after);
void notifyParamChanged(instrument::param::ParamId id, double normalized);
bool gestureIsOpen(instrument::param::ParamId id) const;
// If process() published that the drain instrument is fully idle, move it into the
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
@@ -376,8 +404,13 @@ private:
ReaperBridge bridge_;
// The parameter whose drag bracket is currently open, 0 for none. UI thread only.
instrument::param::ParamId openGestureId_ = 0;
// The parameters whose drag bracket is currently open, and whether a drag is in flight at
// all. UI thread only. Past the cap a write degrades to a one-point gesture rather than
// dropping — the pre-bracket behaviour, not a new failure mode.
static constexpr std::size_t kMaxOpenGestures = 8;
instrument::param::ParamId openGestureIds_[kMaxOpenGestures] = {};
std::size_t openGestureCount_ = 0;
bool gestureLatching_ = false;
// Set across setState so a LOAD is not reflected back to the host as an edit. Main thread
// only, and non-atomic on purpose: the SDK calls setState there and nowhere else.
bool paramNotifySuppressed_ = false;
@@ -387,6 +420,26 @@ private:
// Both live_ and draining_ observe this same block — a block owned by a snapshot would
// leave the drain's still-sounding voices deaf to the knob under them.
instrument::engine::LiveParams liveParams_;
// The block the ENGINE reads, and the ONE thing SampleData::live points at. Written only by
// the audio thread, which merges liveParams_ with the host's automation points once per
// block and republishes ONLY when either side moved — so a block carrying neither costs one
// relaxed load and the engine's own read shape is unchanged. Two blocks because the seqlock's
// single-writer contract is load-bearing and the two writers differ in thread; this
// directory's CLAUDE.md owns the argument.
instrument::engine::LiveParams automationLive_;
// Audio thread only. The last liveParams_ generation merged, and the sticky automation values
// re-applied over every merge — without them a model republish (any knob move) would revert
// an automated parameter until its lane's next point.
std::uint32_t seenModelGeneration_ = 0;
static constexpr std::size_t kDeckParamSlots =
static_cast<std::size_t>(instrument::ui::DeckParam::kCount);
double automationNorm_[kDeckParamSlots] = {};
bool automationHeld_[kDeckParamSlots] = {};
// The audio thread's publication of those values to the UI thread's fold. automationAny_
// makes the idle drain a single load.
std::atomic<double> automationPublished_[kDeckParamSlots] = {};
std::atomic<bool> automationPending_[kDeckParamSlots] = {};
std::atomic<bool> automationAny_{false};
// Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams)
// only — separate from reloadMutex_ so a knob drag's publish never blocks behind a
// reload's WAV decode. The audio thread never takes this; process() only reads via
@@ -435,6 +488,10 @@ private:
// the bake's reset, which the old capture would be the wrong thing to apply it to.
// Guarded by reloadMutex_, consumed by publishBuiltLocked.
std::optional<double> gainAtNextPublish_;
// Armed when that deferred gain lands, delivered by flushGainNotify once reloadMutex_ is
// released. Same shape and same reason as latencyRestartPending_ (see it): a host handler
// callback must never run inside this mutex.
std::atomic<bool> gainNotifyPending_{false};
// The decoded PCM parked across a deactivate, so an activation cycle costs no disk read
// and no WAV decode: activation is "the audio thread may run", not "the sample is