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