// instrument_params.cpp — the VST3 adapter over core/instrument/param: the Parameter subclass // 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" #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(); writeDeckParamToModel(params, row->deck, value); 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)); } 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); } 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 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 == beforeNorms[static_cast(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; 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); 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(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(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(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