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
+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