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