de5654fb6f
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.
308 lines
14 KiB
C++
308 lines
14 KiB
C++
// 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<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;
|
|
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<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
|