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