Report the instrument's automatable parameters to the host under a frozen id table, in signal-flow order, with real units

42 of 44 ids issued: pitch key-track and Trigger length stay reserved
pending a live path. Master gain reclassified Live — it never reloaded.
This commit is contained in:
2026-08-02 15:14:16 -04:00
parent c7afa3a80f
commit bfaa0f2614
38 changed files with 1742 additions and 218 deletions
+32
View File
@@ -84,6 +84,37 @@ The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still
parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is
declared ahead of the instrument slots at that member in `reasampler_processor.h`.
**The VST3 parameter surface is a THIRD surface onto the one model, never a second copy.** The
pure half — the frozen id table, the exposed set, the plain-value layer, the formatter — is
`core/instrument/param` and is documented there; this directory only adapts it.
- **The blob stays authoritative.** `getState` serializes the model and nothing new is
persisted; the controller's own value list is a cache written FROM the model and never read
as truth. A load pushes the model into that cache through `syncParamsFromModel` WITHOUT
notifying the host, which the SDK requires.
- **`setInstrumentParams` is the notification funnel**, for the same reason it is already the
limiter mirror's: every writer of the parameter set — the editor's commits, `setState`, the
bake's adopt — passes through it, so no internal write can leave the host displaying, and on
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.
- **`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.
**Non-goals / guardrails.**
- The instrument never captures and never inserts into the arrange. Playback is a
read-only act over the bank. Any instrument path that places a timeline item, or that
@@ -112,6 +143,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
- `instrument_params` — the VST3 adapter over `core/instrument/param`: one `Parameter` subclass whose `toPlain`/`toNormalized` ARE the taper and whose `toString` calls the one formatter, the single construction of the unit and parameter lists (ascending id, which is also the presentation order), the `setParamNormalized` projection onto the model through each control's existing commit tier, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws and the formatter.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_interaction.h` — the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam — these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
+2
View File
@@ -46,6 +46,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
reasampler_processor.cpp
processor_state.cpp
processor_reload.cpp
instrument_params.cpp
# The editor family is split on the Sample face's band axis: session/bridge state,
# param plumbing plus the shared band-layout resolve, then paint and input in
# matching sets, plus the two band-independent surfaces and the platform TU.
@@ -89,6 +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
limiter meter_accumulate meter_ballistics master_meter bake_hold
file_bytes curve_law stroke_aa
curve_tessellate
+32 -117
View File
@@ -13,11 +13,12 @@
#include <string>
#include <vector>
#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
#include "core/instrument/param/param_format.h" // THE formatter every value label reads through
#include "core/instrument/param/param_id.h" // whether a control has a parameter row at all
#include "core/instrument/ui/bake_hold.h" // the Hold knob's ladder map
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
#include "core/instrument/ui/deck_values.h" // the parameter-set binding + its ms units
#include "core/instrument/ui/deck_values.h" // the parameter-set binding
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
#include "core/util/clamp01.h"
#include "core/util/curve_law.h" // the ONE curve-exponent domain
@@ -33,18 +34,13 @@ using instrument::ui::deckHeight;
using instrument::ui::kDeckKnobSize;
using instrument::ui::kPad;
using instrument::ui::deckParamNorm;
using instrument::ui::formatEnvTimeMs;
using instrument::ui::kEnvTimeMaxSeconds;
using instrument::ui::kKeyTrackMax;
using instrument::ui::resetDeckParam;
using instrument::ui::sampleDeckGroups;
using instrument::ui::setDeckParam;
using instrument::engine::formatMasterGainLabel;
using instrument::engine::masterGainLinearFromNorm;
using instrument::engine::masterGainNormFromLinear;
using instrument::engine::filter::filterCutoffHzFromNorm;
using instrument::engine::filter::filterDriveDepthFromNorm;
using instrument::engine::filter::filterQFromNorm;
using util::clamp01;
namespace {
@@ -220,117 +216,36 @@ void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
}
}
std::string ReaSamplerEditor::deckValueLabel(int id) const {
char buf[24];
buf[0] = '\0';
const PlaySeconds& play = params_.play;
switch (id < 0 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
case ParamControl::kAttack:
formatEnvTimeMs(play.adsr.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kHold:
formatEnvTimeMs(play.adsr.holdSeconds, buf, sizeof(buf)); break;
case ParamControl::kDecay:
formatEnvTimeMs(play.adsr.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kSustain:
snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break;
case ParamControl::kRelease:
formatEnvTimeMs(play.adsr.releaseSeconds, buf, sizeof(buf)); break;
case ParamControl::kTrigLength:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break;
case ParamControl::kTrigAttack:
formatEnvTimeMs(play.trigAhd.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kTrigHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigAhd.holdFraction * 100.0); break;
case ParamControl::kTrigDecay:
formatEnvTimeMs(play.trigAhd.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kPitchEnvAttack:
formatEnvTimeMs(play.pitchEnv.shape.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kPitchEnvHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.pitchEnv.shape.holdFraction * 100.0); break;
case ParamControl::kPitchEnvDecay:
formatEnvTimeMs(play.pitchEnv.shape.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kPitchEnvDepth:
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
case ParamControl::kKeyTrack:
snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break;
case ParamControl::kRate: {
// One decimal below 100 % only: the taper is linear in semitones, so the lower half
// spends 50 percentage points on the same twelve semitones the upper half spends
// 100 on — a whole percent is twice as coarse a step down there.
const double pct = play.playRate * 100.0;
snprintf(buf, sizeof(buf), pct < 100.0 ? "%.1f%%" : "%.0f%%", pct);
break;
}
case ParamControl::kPitch:
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchOffsetSemitones); break;
case ParamControl::kVoiceCount:
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
case ParamControl::kMasterGain:
formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
// Filter readouts run the stored normalized positions back through the module's OWN
// laws, so what the label says is what the kernel is solved for.
case ParamControl::kFilterMorph: {
const double m = play.filter.settings.morphNorm;
snprintf(buf, sizeof(buf), "%.0f%%", m * 100.0);
break;
}
case ParamControl::kFilterCutoff: {
const float hz = filterCutoffHzFromNorm(play.filter.settings.cutoffNorm);
if (hz >= 1000.0f) snprintf(buf, sizeof(buf), "%.2fk", hz / 1000.0f);
else snprintf(buf, sizeof(buf), "%.0fHz", hz);
break;
}
case ParamControl::kFilterQ:
snprintf(buf, sizeof(buf), "%.2f",
static_cast<double>(filterQFromNorm(play.filter.settings.resonanceNorm)));
break;
case ParamControl::kFilterDrive:
snprintf(buf, sizeof(buf), "%.2f",
static_cast<double>(filterDriveDepthFromNorm(play.filter.settings.driveNorm)));
break;
case ParamControl::kFilterModAmt:
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break;
case ParamControl::kFilterVel:
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.velAmount * 100.0); break;
case ParamControl::kFilterKeyTrack:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break;
case ParamControl::kFilterEnvAttack:
formatEnvTimeMs(play.filter.env.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterEnvHold:
formatEnvTimeMs(play.filter.env.holdSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterEnvDecay:
formatEnvTimeMs(play.filter.env.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterEnvSustain:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.env.sustainLevel * 100.0); break;
case ParamControl::kFilterEnvRelease:
formatEnvTimeMs(play.filter.env.releaseSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterTrigAttack:
formatEnvTimeMs(play.filter.trigEnv.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterTrigHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.trigEnv.holdFraction * 100.0); break;
case ParamControl::kFilterTrigDecay:
formatEnvTimeMs(play.filter.trigEnv.decaySeconds, buf, sizeof(buf)); break;
// Every curve exponent reads the same way: the neutral shows as 1.00.
case ParamControl::kAttackCurve:
case ParamControl::kDecayCurve:
case ParamControl::kReleaseCurve:
case ParamControl::kTrigAttackCurve:
case ParamControl::kTrigDecayCurve:
case ParamControl::kPitchEnvAttackCurve:
case ParamControl::kPitchEnvDecayCurve:
case ParamControl::kFilterEnvAttackCurve:
case ParamControl::kFilterEnvDecayCurve:
case ParamControl::kFilterEnvReleaseCurve:
case ParamControl::kFilterTrigAttackCurve:
case ParamControl::kFilterTrigDecayCurve:
snprintf(buf, sizeof(buf), "^%.2f", curveExponentFor(id, play));
break;
default:
// The chrome knobs (preview velocity, bake Hold) are labeled at their own call
// site; nothing else here.
break;
double ReaSamplerEditor::deckPlainValue(int id) const {
const auto deck = static_cast<instrument::ui::DeckParam>(id);
// A curve exponent is read off its stored field, never round-tripped through the knob law:
// that law's centre detent snaps anything near-neutral back to exactly 1.0, so a round trip
// would misreport a stored exponent that isn't neutral as 1.00. The host has only the norm
// and therefore cannot make this distinction — param/CLAUDE.md records the divergence.
if (instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent) {
return curveExponentFor(id, params_.play);
}
return std::string(buf);
return instrument::param::toPlain(deck, deckControlNorm(id));
}
std::string ReaSamplerEditor::deckValueLabel(int id) const {
if (id < 0 || id >= static_cast<int>(ParamControl::kCount)) return {};
const auto deck = static_cast<instrument::ui::DeckParam>(id);
// The one deck knob with no plain-value layer at all: an already-integer count.
if (deck == ParamControl::kVoiceCount) {
char buf[24];
snprintf(buf, sizeof(buf), "%d", voiceCount_);
return std::string(buf);
}
if (instrument::param::paramIdFor(deck) == 0) return {};
// 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.
char digits[24];
instrument::param::formatPlainFor(deck, deckPlainValue(id), digits, sizeof(digits));
const char* caret =
instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : "";
return caret + std::string(digits) + instrument::param::unitStringFor(deck);
}
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
+8 -3
View File
@@ -85,6 +85,13 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
invalidate();
}
if (drag_ == DragKind::kNone) return;
// Stack RAII rather than a call at each exit: this handler leaves through several early
// returns, and the release commit's final performEdit must land INSIDE the bracket the grab
// opened while the bracket itself may not outlive the handler on any path.
struct GestureClose {
ReaSamplerProcessor* p;
~GestureClose() { if (p) p->endParamGesture(); }
} gestureClose{processor_};
const DragKind kind = drag_;
const int paramId = dragParamId_;
const int curveIdx = curvePointIndex_;
@@ -107,9 +114,7 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
// directly. Voice count: the label/needle tracks live during the drag but the engine
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
const bool deckTransient =
kind == DragKind::kDeckKnob &&
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
paramId == static_cast<int>(ParamControl::kMasterGain));
kind == DragKind::kDeckKnob && deckKnobIsProcessorSide(paramId);
if (kind == DragKind::kScrollThumb || deckTransient) {
// Commit the voice count now that the drag is complete (one rebuild per full drag).
if (deckTransient && processor_ &&
+14 -2
View File
@@ -125,6 +125,12 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
dragStartParams_ = params_;
dragStartX_ = x;
dragStartY_ = y;
// Opens the host's edit bracket for the whole gesture, so a host in touch or latch mode
// records one continuous edit rather than a burst of one-point ones. Closed on release
// and on capture-lost; a control with no parameter row is a no-op inside the processor.
if (processor_) {
processor_->beginParamGesture(static_cast<instrument::ui::DeckParam>(dragParamId_));
}
invalidate();
}
// The deck band swallows its own clicks either way — no fall-through to the waveform.
@@ -188,8 +194,14 @@ void ReaSamplerEditor::dragDeck(int x, int y) {
}
applyDeckKnob(dragParamId_, norm);
// A live control is delivered on every move, not only on release — that is the whole
// point: the note already sounding tracks the hand on the knob.
if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive();
// point: the note already sounding tracks the hand on the knob. The processor-side knobs
// are skipped because applyDeckKnob already wrote them straight through; commitLive would
// only re-push an unchanged parameter set. The release and capture-lost paths ask this same
// question; the reset path never reaches the commit for them at all.
if (!deckKnobIsProcessorSide(dragParamId_) &&
dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) {
commitLive();
}
invalidate();
}
+5 -3
View File
@@ -249,9 +249,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
// abandoned value indefinitely instead of rolling back.
const bool transient = self->drag_ == DragKind::kScrollThumb ||
(self->drag_ == DragKind::kDeckKnob &&
(self->dragParamId_ == -2 ||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
ReaSamplerEditor::deckKnobIsProcessorSide(self->dragParamId_));
if (!transient) {
self->params_ = self->dragStartParams_;
// A live drag already reached the voices AND the processor's own
@@ -263,6 +261,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
self->commitLive();
}
}
// Peer of onMouseUp's bracket close, and after the rollback for the same
// reason: the rollback's own performEdit belongs inside the bracket the grab
// opened, and an abandoned drag must not leave the host's edit open.
if (self->processor_) self->processor_->endParamGesture();
self->drag_ = DragKind::kNone;
self->dragParamId_ = -1;
self->dragInnerCellId_ = -1; // inner-dial drag state (peer reset)
+6
View File
@@ -213,6 +213,12 @@ void ReaSamplerEditor::commitLive() {
processor_->publishLiveParams();
}
bool ReaSamplerEditor::deckKnobIsProcessorSide(int paramId) {
return paramId == -2 ||
paramId == static_cast<int>(ParamControl::kVoiceCount) ||
paramId == static_cast<int>(ParamControl::kMasterGain);
}
bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
// The decision itself is the pure liveCommitFor's; this is only the shell's drag-kind
// vocabulary mapped onto it, so the routing is pinned by deck_groups' tests rather than
+194
View File
@@ -0,0 +1,194 @@
// 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.
#include "shell/instrument/reasampler_processor.h"
#include "base/source/fstring.h"
#include "pluginterfaces/base/ustring.h"
#include "core/instrument/engine/master_gain.h"
#include "core/instrument/param/param_format.h"
#include "core/instrument/param/param_id.h"
#include "core/instrument/param/param_units.h"
#include "core/instrument/ui/deck_values.h"
using namespace Steinberg;
using namespace Steinberg::Vst;
namespace reasampler::vst {
namespace param = instrument::param;
using instrument::ui::DeckParam;
namespace {
void assign128(String128 dst, const char* src) {
UString(dst, str16BufferSize(String128)).fromAscii(src);
}
// One class for all of them: the law is per-control data inside the pure module, so a subclass
// per unit category would model nothing that a DeckParam does not already say.
class DeckParameter : public Parameter {
public:
explicit DeckParameter(const param::ParamRow& row) : deck_(row.deck) {
assign128(info.title, row.title);
assign128(info.shortTitle, row.shortTitle);
assign128(info.units, param::unitStringFor(row.deck));
info.id = row.id;
info.unitId = row.unit;
// Continuous, every one of them — and structurally so rather than by luck: stepCount > 0
// is only meaningful for a discrete control, and every discrete control is reload or
// rebuild tier and therefore not exposed at all. The editor's shift-snap is a DRAG
// interaction and must never be published here: stepCount quantizes the parameter
// permanently, for the host's automation too, and freezes into the forever contract.
info.stepCount = 0;
// COMPUTED from the default, never a normalized literal — a hand-written normalized
// default is a second source of truth for it and drifts from the taper silently.
info.defaultNormalizedValue = param::defaultNormalized(row.deck);
// No kIsBypass on anything: the plugin is an instrument and exposes no bypass.
info.flags = ParameterInfo::kCanAutomate;
valueNormalized = info.defaultNormalizedValue;
}
ParamValue toPlain(ParamValue normalized) const SMTG_OVERRIDE {
return param::toPlain(deck_, normalized);
}
ParamValue toNormalized(ParamValue plain) const SMTG_OVERRIDE {
return param::toNormalized(deck_, plain);
}
void toString(ParamValue normalized, String128 out) const SMTG_OVERRIDE {
char digits[24];
param::formatPlainFor(deck_, param::toPlain(deck_, normalized), digits, sizeof(digits));
assign128(out, digits);
}
bool fromString(const TChar* text, ParamValue& normalized) const SMTG_OVERRIDE {
String str(text);
str.toMultiByte(kCP_Utf8);
double plain = 0.0;
if (!param::parsePlain(param::unitKindFor(deck_), str.text8(), plain)) return false;
normalized = param::toNormalized(deck_, plain);
return true;
}
OBJ_METHODS(DeckParameter, Parameter)
private:
DeckParam deck_;
};
} // namespace
void ReaSamplerProcessor::buildParameterList() {
// One unit per deck group that carries an exposed parameter, so a host can present the list
// under its group names rather than as one flat run.
struct UnitDesc { UnitID id; const char* name; };
static const UnitDesc kUnits[] = {
{param::kUnitPitch, "Pitch"},
{param::kUnitPitchEnv, "Pitch Env"},
{param::kUnitFilter, "Filter"},
{param::kUnitFilterEnv, "Filter Env"},
{param::kUnitAmp, "Amp Env"},
{param::kUnitMaster, "Master"},
};
for (const UnitDesc& u : kUnits) {
String128 name;
assign128(name, u.name);
addUnit(new Unit(name, u.id));
}
// Ascending id IS the presentation order, which is what makes identity order and
// presentation order agree by construction rather than by maintenance.
for (const param::ParamRow& row : param::exposedParams()) {
parameters.addParameter(new DeckParameter(row));
}
}
tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamValue value) {
const param::ParamRow* row = param::exposedRowFor(tag);
if (!row) return kResultFalse;
// Notification is suppressed for the duration: this write CAME from the host, and echoing it
// back through performEdit would let a lane in write mode re-record its own playback.
const bool wasSuppressed = paramNotifySuppressed_;
paramNotifySuppressed_ = true;
// The host's write takes the control's EXISTING commit tier and no other. Nothing here can
// reach reloadInstrument or rebuildVoiceEngine, and that is structural: every reload- and
// rebuild-tier control is omitted from the list, so no id maps to one.
if (row->deck == DeckParam::kMasterGain) {
setMasterGainLinear(instrument::engine::masterGainLinearFromNorm(value));
} else {
InstrumentParams params = instrumentParams();
instrument::ui::setDeckParam(row->deck, params.play, value, /*segment=*/0);
setInstrumentParams(params);
publishLiveParams();
}
paramNotifySuppressed_ = wasSuppressed;
// The container caches what the MODEL took, not what the host sent — a control whose write
// clamped would otherwise read back the out-of-range value the clamp rejected.
return EditControllerEx1::setParamNormalized(
tag, modelParamNormalized(instrumentParams(), row->deck));
}
double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params,
DeckParam deck) const {
if (deck == DeckParam::kMasterGain) {
return instrument::engine::masterGainNormFromLinear(masterGainLinear());
}
return instrument::ui::deckParamNorm(deck, params.play);
}
void ReaSamplerProcessor::syncParamsFromModel() {
const InstrumentParams params = instrumentParams();
for (const param::ParamRow& row : param::exposedParams()) {
// EditControllerEx1's own setter, NOT ours: this is the LOAD direction, and the SDK is
// explicit that a controller must never pass a load back to the host through
// IComponentHandler — it updates the GUI element only.
EditControllerEx1::setParamNormalized(row.id, modelParamNormalized(params, row.deck));
}
}
void ReaSamplerProcessor::notifyParamsFromModel(const InstrumentParams& before,
const InstrumentParams& after) {
if (paramNotifySuppressed_) return;
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;
notifyParamChanged(row.id, now);
}
}
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;
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
endEdit(id);
}
} // namespace reasampler::vst
+24 -1
View File
@@ -41,6 +41,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
// calls setupProcessing before setState on load), which the legacy v3 payload's
// frames->seconds conversion needs.
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
// A LOAD is not an edit: the SDK is explicit that a controller must never pass a restored
// value back to the host through IComponentHandler. The push into the controller happens
// once at the tail instead, through syncParamsFromModel.
paramNotifySuppressed_ = true;
setSelectedSampleId(cs.selectionId);
setInstrumentParams(cs.params);
// Restore the last-consumed assignment generation so a re-open does not re-apply a
@@ -86,6 +90,12 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
// A new blob is new facts — the legacy lift gets one fresh run per restored 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.
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.
@@ -153,8 +163,10 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
}
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
InstrumentParams before;
{
std::lock_guard<std::mutex> lock(paramsMutex_);
before = params_;
params_ = params;
}
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
@@ -173,6 +185,10 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
latencyRestartPending_.store(
params.limiterEnabled != latencyAnnounced_.load(std::memory_order_relaxed),
std::memory_order_release);
// The host-notification obligation, at the same one funnel and for the same reason the
// limiter mirror sits here: an internal write that skipped it would leave the host
// displaying — and, on the next touch, re-imposing — the superseded value.
notifyParamsFromModel(before, params);
}
void ReaSamplerProcessor::flushLatencyRestart() {
@@ -319,7 +335,14 @@ void ReaSamplerProcessor::setMasterGainLinear(double linear) {
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
const double maxLin = masterGainMaxLinear();
if (linear > maxLin) linear = maxLin;
masterGain_.store(static_cast<float>(linear), std::memory_order_relaxed);
const float value = static_cast<float>(linear);
const float previous = masterGain_.exchange(value, std::memory_order_relaxed);
// 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;
notifyParamChanged(instrument::param::kParamMasterGain,
instrument::engine::masterGainNormFromLinear(linear));
}
void ReaSamplerProcessor::previewNoteOn(int note) {
+13 -2
View File
@@ -256,6 +256,13 @@ private:
// liveCommitFor (deck_groups.h) for why.
bool dragCommitsLive(DragKind kind, int paramId = -1) const;
// The deck knobs whose value lives on the processor or on the editor rather than in the
// parameter set: the preview-velocity sentinel, voice count, master gain. applyDeckKnob
// writes each straight through, so params_ is not a rollback target for them and
// commitLive has nothing of theirs to push. Every drag path that ends a gesture — release,
// reset, capture-lost, per-move — asks this same question.
static bool deckKnobIsProcessorSide(int paramId);
// Commits `id` as the loaded capture. The one parameter set carries over — it governs
// whatever is loaded, so a load swaps the sound, not the settings.
void loadSelection(const std::string& id);
@@ -398,8 +405,12 @@ private:
// params edit, no reload).
void applyDeckKnob(int id, double norm);
// The knob's live value label shown during hover/drag: milliseconds, percents, Hz, signed
// semitones, a curve exponent, a voice count, or the master-gain dB.
// The knob's value in the REAL unit the host is told about (core/instrument/param).
double deckPlainValue(int id) const;
// The knob's live value label shown during hover/drag: the ONE per-category formatter's
// digits plus this surface's own static chrome (the unit suffix, a curve dial's caret).
// Empty for a control with no plain-value layer.
std::string deckValueLabel(int id) const;
ReaSamplerProcessor* processor_ = nullptr;
@@ -69,6 +69,8 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
addEventInput(STR16("MIDI In"), 16);
addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo);
buildParameterList();
return kResultOk;
}
@@ -24,6 +24,7 @@
#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block)
#include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume
#include "core/instrument/engine/voice_engine.h"
#include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding
namespace reasampler::vst {
@@ -124,6 +125,31 @@ 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.
Steinberg::tresult PLUGIN_API setParamNormalized(
Steinberg::Vst::ParamID tag, Steinberg::Vst::ParamValue value) override;
// Pushes every exposed parameter's normalized value from the model into the controller
// WITHOUT notifying the host — the load direction, which the SDK forbids reflecting back
// 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.
void beginParamGesture(instrument::ui::DeckParam deck);
void endParamGesture();
// Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
// inline TCP/MCP embed); all other iids delegate to SingleComponentEffect unchanged.
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
@@ -276,6 +302,23 @@ public:
std::string usageInstanceGuid();
private:
// Builds the unit and parameter lists from the derived exposed set. Called once, from
// 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.
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);
void notifyParamChanged(instrument::param::ParamId id, double normalized);
// 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
// tails die. Off the audio thread only (driven by pollBankSync); safe against a racing
@@ -333,6 +376,12 @@ private:
ReaperBridge bridge_;
// The parameter whose drag bracket is currently open, 0 for none. UI thread only.
instrument::param::ParamId openGestureId_ = 0;
// 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;
// The ONE live-parameter block for this instance, declared ahead of the instrument slots
// so it outlives every snapshot that points at it (members destruct in reverse order).
// Both live_ and draining_ observe this same block — a block owned by a snapshot would